Skip to content

feat(im): multi-IM architecture + WeChat adapter - #1

Closed
deepcoldy wants to merge 36 commits into
masterfrom
feat/weixin-ilink-integration
Closed

feat(im): multi-IM architecture + WeChat adapter#1
deepcoldy wants to merge 36 commits into
masterfrom
feat/weixin-ilink-integration

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Mar 24, 2026

Copy link
Copy Markdown
Owner

Summary

Refactor botmux from Lark-only to a multi-IM architecture, and add WeChat (企业微信/iLink) as the second IM adapter.

Architecture changes

  • ImAdapter interface (src/im/types.ts): capabilities, sendMessage, replyMessage, deleteMessage, updateMessage, downloadResource
  • ImCapabilities: declare per-IM feature support (cards, updateMessage, threads, etc.) — daemon branches behavior accordingly
  • IM adapter registry (src/im/registry.ts): factory function creates the right adapter from BotConfig.im field
  • LarkImAdapter (src/im/lark/adapter.ts): wraps existing Lark client/card-builder behind ImAdapter
  • Rename larkAppIdimBotId across all modules (DaemonSession, session-store, worker, types)
  • Decouple core from Lark: worker-pool, session-manager, command-handler no longer import from im/lark/ directly — use callbacks injected by daemon.ts

WeChat adapter

  • WeixinImAdapter (src/im/weixin/adapter.ts): implements ImAdapter for WeChat work groups
  • Auth flow (src/im/weixin/auth.ts): iLink OAuth token acquisition
  • Client (src/im/weixin/client.ts): send/reply messages via iLink API
  • Poller (src/im/weixin/poller.ts): long-poll for new messages (WeChat has no WebSocket push)
  • CLI weixin-auth command: interactive setup, auto-adds bot config to bots.json

Capability-driven branching

  • Non-card IMs (WeChat): skip repo selection card, auto-start with default workingDir
  • updateMessage no-op when adapter lacks the capability
  • buildStreamingCard / buildSessionCard passed as callbacks (Lark-specific)

Fixes

  • MCP server: call createImAdapter() after registerBot() so Lark client is registered for getBotClient() calls
  • CLI systemHints: add Lark instructions to Codex, Gemini, OpenCode (they don't honour MCP-level instructions, same as CoCo)

Test plan

  • Lark bot: new topic → repo selection → CLI spawn → streaming card → reply → /close
  • WeChat bot: botmux weixin-auth → bots.json updated → daemon restart → message round-trip
  • Multi-bot: Lark + WeChat bots coexist in same daemon
  • MCP tools: send_to_thread / react_to_message / get_thread_messages work for all CLIs
  • Standalone CLI: no botmux tools visible (two-gate detection)

@deepcoldy deepcoldy changed the title Feat/weixin ilink integration feat(im): multi-IM architecture + WeChat adapter Mar 24, 2026
- session-manager: replace direct Lark imports (downloadMessageResource,
  listChatBotMembers, MessageResource) with callback parameters
  (DownloadResourceFn, ListChatBotsFn, SendMessageFn, ResourceDescriptor)
- command-handler: move buildRepoSelectCard and deleteMessage into
  CommandHandlerDeps; add listChatBots dep for getAvailableBots calls
- Both files now have zero im/lark/ imports
- Add 4 new WorkerPoolCallbacks: updateMessage, isMessageWithdrawn,
  buildStreamingCard, buildSessionCard
- Add 3 new CommandHandlerDeps: buildRepoSelectCard, deleteMessage,
  listChatBots
- Pass downloadFn to downloadResources() and listChatBotsFn to
  getAvailableBots() at all call sites in daemon.ts and card-handler.ts
- Pass sendMessageFn to executeScheduledTask()
- Fix DownloadResourceFn type to use 'image' | 'file' union and
  Promise<string | void> return
- Fix WorkerPoolCallbacks card builder signatures to use CliId type
Skip repo selection card and streaming card updates for IMs that don't
support cards/updateMessage (e.g. WeChat); auto-start with default
workingDir and make updateMessage a no-op when the adapter lacks the
capability.
Root cause: daemon.ts called registerBot() but never createImAdapter(),
so LarkImAdapter constructor (which calls registerLarkClient()) was
never executed, causing all getBotClient() calls to throw.

Also: weixin-auth now auto-adds bot config to bots.json and offers
to restart daemon after successful auth.
MCP server runs as a separate subprocess and calls registerBot() to
set up bots. After the refactor, registerBot() no longer creates
Lark.Client — that's done by createImAdapter(). Without this call,
getBotClient() throws "Lark client not registered" for all MCP tools.
…nCode

These CLIs do not honour MCP-level `instructions` (same as CoCo).
Duplicate the Feishu/Lark usage guidance into systemHints so it gets
injected into the initial prompt, ensuring the CLI knows to use
send_to_thread, react_to_message, and get_thread_messages.
The WeChat adapter's start() was never called because daemon only had
Lark-specific startup code. Added else branch that constructs an
ImEventHandler (onNewTopic/onThreadReply/onCardAction) and calls
adapter.start(handler) for non-Lark IMs.
…ponse

iLink getupdates returns { msgs, sync_buf, get_updates_buf } on success
and { errcode, errmsg } on error. There is no `ret` field.
validateToken and poller were checking `data.ret === 0` which always
evaluated to false, causing token validation to always fail.
…Lark

sessionReply was always calling the Lark replyMessage function directly,
which throws "Lark client not registered" for WeChat sessions. Now
routes through the bot's adapter.replyMessage() when available.
…ding through adapter

WeChat card-builder now includes terminal URL in session/streaming
messages. Daemon's buildStreamingCard/buildSessionCard callbacks now
route through the session's adapter instead of hardcoded Lark builder.
…to sendMessage

iLink sendmessage API requires body format:
  { msg: { to_user_id, message_type: 2, message_state: 2, context_token, item_list } }
Was sending flat structure without msg wrapper, causing ret=-2 errors.
…er_id

Daemon uses wx-{userId} as rootId for WeChat sessions. The adapter
was passing this directly to iLink sendmessage as to_user_id, but
iLink expects the raw userId without prefix.
…ssage

iLink sendmessage requires:
- from_user_id: "" (empty but must be present)
- client_id: unique per message
- base_info: { channel_version } at body level

Without these fields the API returns {} silently without delivering.
- send_to_thread: non-Lark IMs send plain text via adapter.sendMessage()
  (no image upload, file names as text hints)
- get_thread_messages: returns empty for IMs without thread/history API
- list_bots: returns self-only for non-group-chat IMs
- server instructions updated for generic IM wording
…Message IMs

Worker-pool sends screen_update events every ~2s. For Lark, these
PATCH the existing card in-place. For WeChat (no updateMessage),
each update was creating a NEW message — flooding the chat.

Fix: buildStreamingCard returns empty string for working/starting
status on non-updateMessage IMs. Worker-pool skips send when empty.
Only idle (final result) generates a message. Worker ready sends
a static session card with terminal URL instead.
MCP server runs in a separate process without WeChat poller, so it
has no contextToken. Fix: daemon persists weixinContextToken and
weixinUserId to session data on every received message. MCP's
send_to_thread reads these and calls iLink API directly.
sessionReply was routing ALL bots through adapter.replyMessage() which
lost the replyInThread parameter. For p2p chats this broke topic
creation (messages replied flat instead of creating threads).

Fix: Lark uses direct client.replyMessage() with replyInThread.
Only non-thread IMs (WeChat) route through adapter.sendMessage().
Also: add nonStreamingIm/finalOutputSent flags for clean WeChat
screen_update handling in worker-pool.
Barrierml pushed a commit to Barrierml/botmux that referenced this pull request Jul 27, 2026
Phase D Batch A(8 页)的前 3 页,对应最高频痛点:

- prerequisites:加价值开头 + 适用/不适用 + 常见失败(Node<22 / CLI 没登录 / PATH
  找不到,正是「装完连不上」的主因)+ 下一步链接。
- faq:为 deepcoldy#1 痛点「机器人不回复」加**症状→根因→跳转**路由表,拆成 A 完全没反应 /
  B 别人不能用弹授权卡(对话权 vs 操作权、群@策略、oncall)/ C 终端有输出没发
  (botmux send)三条独立分支;发版「改权限/事件必须重新发版」补明。
- adapters:清单从 12 行补全到 registry 全量 **24 个**(事实源链 registry.ts),
  并纠正「进程隔离」一刀切——区分本地进程 CLI(tmux attach 可直连)与 API/云 Agent
  (mira / riff),加接入方式列。

每页含一句价值 / 适用不适用 / 最短操作 / 常见失败 / 下一步链接。锚点跳转全部用
构建产物的真实 id 核对(zh/en faq 路由表、adapters wrapper 段)。docs-site build 通过。
剩余 5 页(quickstart/pitfalls/env/bots-json/session-model)随本 Batch 后续提交。

Co-Authored-By: Riff <noreply@riff.dev>
deepcoldy added a commit that referenced this pull request Jul 28, 2026
…ge 恒 null

codex review PR #638 指出:#1/#3 改在 codex adapter/generic 路径,但 B 模式目标是
codex-app,两处在 codex-app 上都不生效:

1. #1 model/reasoningEffort:codex-app buildArgs 之前没解构 model/reasoningEffort,
   app-server 从没收到覆盖。修:codex-app buildArgs 透传 --model/--reasoning-effort
   给 runner;codex-app-runner 解析后注入 thread/start(model 走 thread-level、
   model_reasoning_effort 走 config;xhigh→codex high)。(codex.ts 的注入保留,覆盖
   纯 codex/RPC 路径。)

2. #3 usage:resolveSessionTranscriptPath 的 switch 无 codex-app case,
   getSessionTokenUsage 对 codex-app 恒 null(不是偶发 miss)。修:transcript-resolver
   加 codex-app case(与 codex 同路径——codex-app 驱动 codex app-server,rollout 同格式);
   cost-calculator usageKindForCli 加 codex-app→codex 映射。补回归单测(codex-app 解析
   codex rollout、usage 非 null)。

cost-calculator 43 测绿;build 绿。#2 的 turnId/answer 窗口 codex 仍在核,findings 出来再改。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Jul 30, 2026
…撤销 dead init 字段)

codex review 4817189259 #2:上一版给 forkAdoptWorker init 塞 readIsolation 是 dead
no-op——worker 的两条 adopt observe 分支(6413/6478)在 fs-policy 计算前直接 return,
init 里的 readIsolation 根本不包既有 pane。

正确修法(按 codex):撤销 adopt init 的 readIsolation;改在 daemon restore 侧
`adoptSandboxBlocked` 增加 no-transport 判定——apiOnly bot 或虚拟会话 chatId 时
fail-closed,转 cold-start(与 sandbox adopt 同处理)。覆盖「普通 adopt session
后续改 apiOnly 再重启会继续观察未隔离外部 CLI」这条可达路径。三个 adoptSandboxBlocked
调用点(session-manager restore / command-handler / worker-pool)全覆盖。

fresh/resume/restart 侧 forkWorker 的 readIsolation 门保留(那条不是 dead——fresh
spawn 会真跑 fs-policy)。

测试:wiring 改为验 fresh 门 + adopt restore 门两条不同机制;session-adopt/resume
回归绿。

注:codex #1(generic readIsolation 非 no-transport profile:放行 own lark-cli 凭证
+ workingDir=~ 重开 home RW)需显式 frozen no-Lark-transport fs-policy carve-out,
是更大 fs-policy 改动,另轮按 codex 最小修法做。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Jul 30, 2026
…losed(codex #1)

codex review 4817189259 #1(最后一条 blocker):通用 readIsolation 不是 no-transport
credential profile——它放行 own lark-cli 凭证,且 workingDir 默认 ~ 时把 $HOME
(含 bots.json + sibling BOT_HOME + lark-cli stores)重新按 RW 打开(codex 用
buildFsPolicy 跑出 4 条 readWrite 证据)。

修法(FsPolicyContext 加 larkTransportEnabled,worker 按 apiOnly||虚拟 chatId 传):
larkTransportEnabled=false 时——
- **抑制**所有飞书凭证授权:adapter authPaths、own .lark-cli-bots/<self>、macOS
  lark-cli keystore carve-out 都不再 push readWrite;
- **强制 mandatory deny**(最后 push、longest-prefix 胜过任何宽 readWrite 包括
  workingDir=~):bots.json、.lark-cli-bots(全)、macOS lark-cli store(全)、以及
  adapter authPaths 本身。
→ 即使 workingDir=~ 把 home 整个开成 RW,飞书凭证路径仍 deny;own BOT_HOME 工作目录
  仍可写(deny 只锁凭证路径)。

localBots fail-closed(P2):loadBotConfigs 读失败时不再 fail-open 把 apiOnly 误标
normal——该轮所有 bot federate transport=false(远端不邀请,safe-but-degraded)。

测试:fs-policy.test 新增 no-transport profile 3 用例(workingDir=~ 最坏情形下
bots.json/lark-cli/authPaths 全 deny;normal bot 不受影响;own BOT_HOME 仍可写,
负向对照)。全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Jul 30, 2026
…撤销 dead init 字段)

codex review 4817189259 #2:上一版给 forkAdoptWorker init 塞 readIsolation 是 dead
no-op——worker 的两条 adopt observe 分支(6413/6478)在 fs-policy 计算前直接 return,
init 里的 readIsolation 根本不包既有 pane。

正确修法(按 codex):撤销 adopt init 的 readIsolation;改在 daemon restore 侧
`adoptSandboxBlocked` 增加 no-transport 判定——apiOnly bot 或虚拟会话 chatId 时
fail-closed,转 cold-start(与 sandbox adopt 同处理)。覆盖「普通 adopt session
后续改 apiOnly 再重启会继续观察未隔离外部 CLI」这条可达路径。三个 adoptSandboxBlocked
调用点(session-manager restore / command-handler / worker-pool)全覆盖。

fresh/resume/restart 侧 forkWorker 的 readIsolation 门保留(那条不是 dead——fresh
spawn 会真跑 fs-policy)。

测试:wiring 改为验 fresh 门 + adopt restore 门两条不同机制;session-adopt/resume
回归绿。

注:codex #1(generic readIsolation 非 no-transport profile:放行 own lark-cli 凭证
+ workingDir=~ 重开 home RW)需显式 frozen no-Lark-transport fs-policy carve-out,
是更大 fs-policy 改动,另轮按 codex 最小修法做。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Jul 30, 2026
…losed(codex #1)

codex review 4817189259 #1(最后一条 blocker):通用 readIsolation 不是 no-transport
credential profile——它放行 own lark-cli 凭证,且 workingDir 默认 ~ 时把 $HOME
(含 bots.json + sibling BOT_HOME + lark-cli stores)重新按 RW 打开(codex 用
buildFsPolicy 跑出 4 条 readWrite 证据)。

修法(FsPolicyContext 加 larkTransportEnabled,worker 按 apiOnly||虚拟 chatId 传):
larkTransportEnabled=false 时——
- **抑制**所有飞书凭证授权:adapter authPaths、own .lark-cli-bots/<self>、macOS
  lark-cli keystore carve-out 都不再 push readWrite;
- **强制 mandatory deny**(最后 push、longest-prefix 胜过任何宽 readWrite 包括
  workingDir=~):bots.json、.lark-cli-bots(全)、macOS lark-cli store(全)、以及
  adapter authPaths 本身。
→ 即使 workingDir=~ 把 home 整个开成 RW,飞书凭证路径仍 deny;own BOT_HOME 工作目录
  仍可写(deny 只锁凭证路径)。

localBots fail-closed(P2):loadBotConfigs 读失败时不再 fail-open 把 apiOnly 误标
normal——该轮所有 bot federate transport=false(远端不邀请,safe-but-degraded)。

测试:fs-policy.test 新增 no-transport profile 3 用例(workingDir=~ 最坏情形下
bots.json/lark-cli/authPaths 全 deny;normal bot 不受影响;own BOT_HOME 仍可写,
负向对照)。全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Jul 31, 2026
…动) (#668)

* feat(bot-registry): core-only / API-only bot 模式(无飞书凭证,纯 HTTP 控制 API 驱动)

让 botmux 可作为 core-only 控制 Server:外部系统(如 riff Sandbox)纯 HTTP
控制 API 驱动 botmux → botmux 直接控 CLI Agent,全程无需真实飞书 Bot。

## 改了什么
- BotConfig 新增 `apiOnly?: boolean`(照 disableCliBypass/codexRpcInput 现有 idiom)
- bot-registry 校验:apiOnly 时豁免 larkAppSecret 必填(larkAppId 仍必填,用合成
  本地身份 local_<slug>);larkAppSecret 缺省回退空串保证下游 env 是 string
- daemon boot:apiOnly 跳过 3 个飞书耦合点——open_id 探测(/bot/v3/info)、
  required-scope 校验、WSClient 事件订阅;并 seed 合成 botOpenId/botName 避免
  下游读 undefined

## 为什么这样改
核心控制回路(trigger→spawn→CLI→trigger-result)走 asyncReturnSessionId 时
运行时本就不碰飞书:deliverFinalOutput 命中 async 分支后 recordCompleted 即
early-return,飞书投递代码全在其后不触达;auto-worktree 通知也早已 gate 在
!isHttpVirtualSession。所以只需 gate boot 层 3 个订阅/探测点,运行时零改动。

## 影响面
- 跨 CLI:apiOnly 与 cliId 正交;spawn 不依赖真 larkAppSecret 运行时值(仅图片
  上传用、已 credentials-missing 优雅降级)
- 跨后端:PTY/tmux 不受影响(只改 boot 的飞书订阅)
- 零回归红线:所有跳过都在 if (!cfg.apiOnly) / if (cfg.apiOnly) 分支内,普通飞书
  bot(apiOnly 缺省)boot 路径字节级不变

## 测试
- bot-registry.test:apiOnly 缺 secret 不 throw;普通 bot 缺 secret 仍 throw(护栏);
  larkAppId 仍必填;apiOnly 严格 ===true(76 测全绿,新增 6)
- api-only-mode-wiring.test(source-lock,照 daemon-codex-app-workflow-wiring idiom):
  锁死 3 个 boot 守卫,负向验证过(删任一守卫即红)
- pnpm build 绿;全量 11190 测通过(6 个 v3-worker-fence 失败为环境相关、
  master 同样失败、与本改动无关,已 stash 验证)

设计文档:docs/design/2026-07-30-api-only-core-only-bot-mode.md

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

* fix(api-only): 收口为中央 lark-transport 能力边界(修 codex #668 复审 4 组 blocker)

首版只 gate boot 三点、误判"运行时零飞书"。codex 复审指出 final_output 前仍有
多条飞书链路、apiOnly 只是 boot hint。本次按中央能力边界收口:

## 核心不变量
core/types.ts 新增 larkTransportEnabled(ds):apiOnly bot 或 HTTP virtual session
(http_async_*/http_wait_*) → false = 禁止一切飞书副作用。所有 seam fail-closed
于此,新增无飞书 surface 自动被覆盖。isHttpVirtualSession(chatId) 抽出复用。

## 运行时 gate(P1-1)
- sessionReply(daemon.ts 中央投递入口)fail-closed → 覆盖所有 worker 辅助 UI
  (ready/screen/tui/stuck/startup+exit),不再往 http_async_* 发 sendMessage
- getAvailableBots roster 探测:no-transport 会话跳过(trigger-session.ts)
- botmux ask (/api/asks):no-transport 会话返回 unsupported,不落 Lark dispatcher

## 请求形态 fail-closed(P1-2)
- /api/trigger apiOnly:拒真实 chatId/rootMessageId、必须带 HTTP response mode、
  sessionId 只能绑本 bot 的 virtual session
- boot:restoreDocSubscriptions + 5s pollWatchedDocComments 文档轮询 → apiOnly 跳过
- boot:allowedUsers 联系人解析(email/union_id → 飞书 contact API)→ apiOnly 跳过

## 跨 bot 回归修复(P1-3a)
getAllBotClients/strict resolver 过滤 apiOnly——否则普通飞书 bot 探 roster 会连带
探 apiOnly 合成 appId/空 secret,给健康 bot 引入认证失败+延迟

## 校验类型洞修复(P1-3b)
apiOnly secret 规则改为"可省略;若提供必须是 string"——42/{}/[]/false 不再穿进
string 字段(已复现并加回归测试)

## 测试(P1-4)
- 新增 api-only-transport-boundary.test:larkTransportEnabled 真值表 + apiOnly
  请求形态 fail-closed 行为测试(8 测)
- 扩展 api-only-mode-wiring.test:source-lock 锁 7 处 gate,负向验证删 gate 即红(11 测)
- bot-registry.test:新增类型洞回归(77 测)
- pnpm build 绿;全量 11239 过(6 个 v3-worker-fence/cancel 环境失败与本改无关,
  已 stash 核实 + 隔离复跑确认 flaky)

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

* fix(api-only): bot 级 lark-transport 原语边界(修 codex #668 第3轮 blocker)

会话级 larkTransportEnabled 只覆盖「知道自己在哪个 session」的调用方。codex 第3轮
指出 3 类旁路仍会碰飞书,本次下沉到 bot 级原语边界根治。

## codex 第3轮 finding → 修复
1. sessionReply 返回伪 messageId(http_async_*):被存进 streamCardId → 下条
   screen_update 走 scheduleCardPatch → updateMessage 仍直调飞书。
   → 改返回 ''(空 id),falsy guard 天然跳过 patch;且 scheduleCardPatch 加
     defense-in-depth transport gate;managedAuxUiSuppressed 也纳入 no-transport
     判定(一处兜住 ready/screen/tui/stuck/exit 全部辅助 UI)
2. agent 直接 botmux send / 非 session 全局路径(distillation/runtime-update/
   restart-report/overload DM) 绕过会话 gate:
   → **bot 级硬门**:im/lark/client.ts 所有出站原语(sendMessage/replyMessage/
     updateMessage/deleteMessage/addReaction/removeReaction/sendUserMessage/
     sendEphemeralCard)共同底座加 assertLarkTransport(larkAppId,op),apiOnly
     bot 抛 LarkTransportDisabledError。无论调用方是谁都 fail-closed
   → CLI 级 botmux send 早拒(currentBotIsApiOnly):给 agent 清晰反馈,不落深层 stack

## 分层防御
- bot 级原语 assertLarkTransport(client.ts):authoritative,覆盖全部出站写
- 会话级 larkTransportEnabled(worker-pool aux UI + scheduleCardPatch + trigger):
  原语抛错前静默 no-op 免噪音日志 + 覆盖「普通 bot 的 virtual session」
- CLI 级 botmux send 早拒

## 其它
- P2:设计文档首版误判段加醒目「已被推翻」标注 + 修订2段;wiring test EOF 空行清理

## 测试
- 新增 lark-transport-boundary.test:8 原语对 apiOnly 抛错 + 普通 bot 不受影响
- 新增 api-only-card-patch-suppression.test:真 scheduleCardPatch + FakeLarkClient
  记账,apiOnly/virtual 零 updateMessage、普通 bot 仍 patch(负向验证过)
- 扩展 wiring source-lock:锁 bot 级原语 + aux UI + scheduleCardPatch + CLI gate
- 全 api-only 105 测绿;client/card/trigger 回归 96 测绿;全量 11248 过
  (6 个 v3-worker-fence/cancel/goal 环境 flake 与本改无关,stash 核实)

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

* fix(api-only): 补齐 3 个漏网出站原语的 transport gate(codex 第4轮预防性)

codex 复审 ffac8b5 时提示核「client 8 原语是否覆盖所有真实出站实现/别名」。
自查发现遗漏 3 个出站写原语,补上 assertLarkTransport:
- deleteEphemeralCard(POST /ephemeral/v1/delete)
- uploadImage(im.v1.image.create)
- uploadFile(im.v1.file.create)

现共 11 个出站写原语全部 bot 级 fail-closed;其余 getBotClient 调用点均为只读
lookup(resolveUnionId/getUserProfile/getChatInfo/listChatMembers/getChatMode/
getMessageDetail/download/resolveAllowedUsers/list*Messages 等),按设计不 gate。
assert 在 uploadImage/File 的 readFileSync 之前,apiOnly 抛错不触磁盘。

测试:lark-transport-boundary 扩到 11 原语全覆盖;wiring source-lock 同步到 11。
全 api-only 相关测试绿。

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

* fix(api-only): getBotClient 下沉为唯一权威门 + apiOnly 跨重建传播(codex 第4轮)

codex 第4轮:11 原语手工打点不成立(groups-store/doc-comment/开放平台改名头像/
worker uploader 各有绕行),且只禁写不够——core-only 契约是零飞书网络。另外
worker/relay 重建配置丢 apiOnly,非空 secret 时 11 gate 可被恢复成普通 bot 绕过。

## 唯一权威门:getBotClient(bot-registry.ts)
所有飞书调用(client.ts 原语、doc-comment drive API、开放平台改名/头像、
identity-cache…)都在此解析 client → apiOnly 时抛 LarkTransportDisabledError,
**读+写都拦**,无调用方能绕过。LarkTransportDisabledError 移到 bot-registry
定义(避免与 getBotClient 的 import 循环),client.ts 再导出。

## 非 client 直连飞书路径同门
- doc-comment.ts driveApiCall(subscribe/reply/comment/reaction 自建 fetch)加
  assertLarkTransport
- worker screenshot uploader(utils/lark-upload 自建 client)经 init 消息 apiOnly
  标志 hard-disable

## apiOnly 跨重建传播(relay/worker 保真)
- init 消息 DaemonToWorker 加 apiOnly;forkWorker 两处传 botCfg.apiOnly
- worker 注入 BOTMUX_API_ONLY env + send-cred 文件写 apiOnly
- riffModeSession 从 BOTMUX_API_ONLY 重建 apiOnly;registerSelfFromCredFile 读
  cred.apiOnly(且 apiOnly 空 secret 不再 bail)
→ getBot().config.apiOnly 在 worker/沙箱/relay 均为真,getBotClient 门生效

## 测试
- lark-transport-boundary:11 原语抛错(用真 getBotClient 门);普通 bot 不受影响
- wiring source-lock:getBotClient 权威门 + doc-comment + worker + 传播链(cred/
  env/riff) 全锁
- 全量 11252 过(+4);6 个 v3-worker-fence/cancel/goal 环境 flake 与本改无关

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

* fix(api-only): botmux send 对普通 bot 的 HTTP virtual session 也早拒(codex 第5轮预防)

codex 第5轮专门追「普通 bot 的 http_async_/http_wait_ session 从 botmux send 漏到
飞书」:普通 bot 有真凭证 → getBotClient 不抛 → cmdSend 之前只拦 apiOnly,漏了
「普通 bot + 虚拟会话」这一格。

修复:cmdSend 早拒条件扩为 (apiOnly bot) OR (BOTMUX_CHAT_ID 是 http_async_*/
http_wait_*)。虚拟会话 chatId 不是真实飞书 chat,其输出经 trigger-result 返回调用
方、不发飞书。给 agent 清晰反馈,不落深层错误。

daemon 侧该场景本已兜住(sessionReply/scheduleCardPatch/ask/roster 都 gate 了
isHttpVirtualSession;aux UI 经 managedAuxUiSuppressed 的 larkTransportEnabled);
本次补的是 CLI 入口这一格。

测试:wiring source-lock 扩到验 cmdSend 的 apiOnly + virtual-turn 双重早拒。
全 api-only 109 测绿。

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

* fix(api-only): 冻结 no-transport 能力——不下发 secret + 补 download/screenshot 旁路(codex 第5轮)

codex 第5轮两组 blocker:
1. getBotClient 非唯一门:downloadMessageResource 吞掉 transport-disabled 错误后
   fallback 到 user-token raw fetch(绕过门)。
2. 能力放在 agent 可改的 env / BOT_HOME 可写 cred,no-transport worker 仍拿真实
   secret,普通 bot virtual 的 screenshot uploader 只看 apiOnly。

## 冻结能力(codex 建议的根治方向)
- **不下发 secret**:forkWorker 两处按 session 的 larkTransportEnabled 计算——
  no-transport(apiOnly bot 或 http_async_/http_wait_ 虚拟会话)时 larkAppSecret
  下发 ''。secret 根本不进 worker/沙箱,capability 无法靠改 flag 降级恢复。
  send-cred 随之写空 secret(仍带 apiOnly 身份)。
- **screenshot uploader**:worker 侧 apiOnlyForUpload 扩为 apiOnly OR 虚拟会话
  chatId——普通 bot 的 virtual session 也不再 upload。

## 补 raw 旁路
- downloadMessageResource 顶部加 assertLarkTransport——在 app→user-token
  fallback **之前**拒绝,不再吞错降级到 raw fetch。

## 开放平台 rename/avatar
console 自动化走浏览器 web-session(非 getBotClient),且是交互式 setup/
onboarding 路径——apiOnly bot 由 API 建、从不走该流程,运行时不可达。(如需
可另在 setBotRenamer/setBotAvatarChanger 注册处按 apiOnly 跳过,待 codex 确认。)

测试:wiring source-lock 扩到锁 download-gate + screenshot 双条件 + secret withhold;
全 api-only + card/trigger 回归 143 测绿。

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

* fix(api-only): 冻结 fork/riff env secret + VC listener 排除 + rename/avatar 注册门(codex 第6轮)

codex efc1258 复审 3 组 P1:

1. **fork 进程 env 仍传真实 secret**:forkWorker/forkAdoptWorker 的 CLI spawn env
   直接从 botCfg 注入 LARK_APP_SECRET(独立于 init IPC 字段)。→ 两处按
   larkTransportEnabled 冻结为 '',secret 不进 worker/沙箱进程。

2. **riff mergedEnv 覆盖冻结值**:mergedEnv = {...sessionEnv, ...perBotEnv,
   ...backendConfig.env},backendConfig.env 合并在最后,可覆盖冻结的
   BOTMUX_LARK_APP_SECRET/CHAT_ID/API_ONLY 恢复发送能力。→ 合并后**再冻结**
   no-transport 键(delete secret + 强制 API_ONLY=1 + host-owned chatId)。

3. **rename/avatar 是 dashboard runtime 路由 + apiOnly 可选作 VC listener**:
   - 开放平台 rename/avatar handler(走浏览器 console,非 getBotClient)在
     apiOnly 时跳过注册 → IPC 路由 fail-closed 到本地 displayName-only rename。
   - vcMeetingListenerBotOptions 过滤掉 apiOnly(VC listener 需真飞书连接);
     fetchGrantedScopesForBot 对 apiOnly 提前拒(不 raw-fetch token/application)。

P2:PR body 更新为最终四层架构,删除已推翻的「运行时零改动/只 gate boot 三点」。

测试:wiring source-lock 扩到锁 fork-env 冻结(×2)/riff 再冻结/VC 排除/rename 注册门;
全量 11258 过(6 个 v3-worker-fence/cancel/goal 环境 flake 与本改无关)。

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

* fix(api-only): CLI 中央 session-capability 硬门 + VC validator/sync 早拒(codex 第7轮)

codex e3ca7ff 复审 2 组 P1:

1. **VC listener 写边界只校验「bot 存在」**:手工 PUT 选 apiOnly →
   syncVcMeetingListenerBotConfig 先跑 automateOpenPlatformSetup 才到 scope guard,
   仍碰开放平台。→ validateVcMeetingListenerBotAppId + syncVcMeetingListenerBotConfig
   入口都对 apiOnly 早拒(api_only error),不再 raw-fetch token/application。

2. **send 单点早拒 ≠ 中央 session capability**:普通 bot 的 http_async_/http_wait_
   virtual session 仍可经 history/quoted/bots list(读)+ dispatch(写)触达飞书;
   非 sandbox 本地 CLI 会重载 bots.json 拿回真 client(普通 bot virtual 时
   getBotClient 不抛,唯一防线在 CLI 层)。
   → 新增中央 `assertTurnTransportOrExit`(cli.ts):键于 apiOnly bot OR 虚拟
     BOTMUX_CHAT_ID。send/dispatch(写)+ history/quoted/bots list(读)全部 consult
     同一硬门。thread 已下线、ask 走 daemon /api/asks 门。

P2:设计文档追加 canonical 最终架构段(6 层 + 5 条已证伪初稿判断)。

测试:wiring source-lock 改为验中央 gate helper + 5 个命令全 consult;
全量 11258 过(6 个 v3-worker-fence/cancel/goal 环境 flake 与本改无关,已核实失败集未变)。

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

* fix(api-only): CLI 门 target-aware + dashboard history/notice/create-group 接门(codex 第8轮)

codex e411ce3 复审两处绕过:

1. **中央 CLI 门只读当前进程 env,未绑 --session-id 目标**:从普通 turn 跑
   `botmux history --session-id <某虚拟会话>` 时 env 门放行、实际读了虚拟会话。
   → 新增 target-aware `assertSessionTransportOrExit(session, op)`,在
   resolveSessionAppId 解析目标后,按目标 session 的 chatId + 其 bot 的 apiOnly 再判。
   history/quoted/bots list 三个接 --session-id 的读命令都补上(env 门保留做无参兜底)。

2. **dashboard history 路由未接门**:daemon `/api/sessions/:id/history` 对普通 bot 的
   虚拟会话仍会 listChatMessages(synthetic chatId) 打飞书。
   → 该 IPC handler 加 larkTransportEnabled 门,no-transport 返回空 history。
   同类:postRestartNotice(raw send/reply,绕过 sessionReply 门)也加门;
   createTeamGroup 的 creator 资格排除 apiOnly(不能建飞书群)。

测试:wiring source-lock 扩到验 target-aware 门 + dashboard history/notice/create-group;
全量通过(失败集仍只有 v3-worker-fence/cancel/goal 环境 flake,已核实未变)。

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

* fix(api-only): send/dispatch target-aware + daemon session-write 路由集中 gate(codex 第9轮)

codex aec1692 复审(review 4816501089)剩余可达写洞:

1. **send/dispatch --session-id 仍只看 ambient env**:`dispatch --session-id <virtual>
   --chat-id oc_real` / `send --session-id <virtual>` 从虚拟源会话发起 Feishu 写。
   → send/dispatch 在解析出 source session 后也调 target-aware
   assertSessionTransportOrExit(按 resolved session 的 chatId+apiOnly),
   无参仍走 env 门。now 5 个 CLI 命令全 target-aware。

2. **daemon 侧 virtual session 写路由未 gate**(normal bot + http_async/http_wait 有真
   client,bot-level 门挡不住):新增中央 `sessionTransportDisabled(ds)` 助手,
   chat-rename / write-link-card / resume-notice / locate 四路由统一 consult
   (history / restart-notice 上轮已接,用同一 larkTransportEnabled 语义)。

behavioral:larkTransportEnabled 真值表已含 normal-bot + virtual chat(apiOnly:false
+ http_async → false),即 codex 关心的核心 case。

测试:wiring source-lock 扩到验 5 命令 target-aware + sessionTransportDisabled 助手
+ ≥5 路由 consult;全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)。

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

* fix(api-only): 根命令分派中央门 + create-group 成员过滤 + 真行为测试(codex 第10轮)

codex review 4816643087 三组 blocker:

1. **CLI origin 门非中央**:create-group / grant / vc-agent 等可重载 bots.json 绕过
   手工枚举的 5 命令门。→ 在**根命令分派层**(switch 前)加中央门:managed
   no-transport turn(有 BOTMUX_SESSION_ID marker + apiOnly bot 或虚拟 chatId)
   拒绝所有 LARK_FACING_COMMANDS(send/dispatch/create-group/history/quoted/bots/
   grant/react/thread)。裸 host operator(无 managed marker)不受影响、保持可用。
   per-command 门保留做纵深 + 友好提示。

2. **dashboard apiOnly 成员未过滤**:createTeamGroup 只排除 creator,成员 payload
   仍传原始 selectedIds。→ 成员 payload 也 filter(canCreateFeishuGroup),不再邀请
   合成 local_* 身份。

3. **测试没锁住 wiring**(全文件 contains/计数,删 seam 仍绿)→
   - wiring source-lock 改为**逐命令/逐路由 region-scoped**(删任一命令的门即红)
   - 新增 **api-only-cli-gate.behavior.test**:spawn 真实 dist/cli.js 子进程,
     managed 虚拟 turn 下每个 Lark-facing 命令断言 exit 2;两个负向对照(managed
     真实 chat 不拦 / 裸 operator 不 root-拦)。真零调用行为验证,非 source-lock。

测试:wiring + 行为 31 测绿;全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)。

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

* fix(api-only): 根门改 ancestry-tamper-resistant + 修 federation 成员回归 + 真 tamper 行为测试(codex 第10轮静态)

codex review 4816771859 两组 blocking:

1. **根门非硬边界(env 可改)**:managed/capability 全绑 BOTMUX_SESSION_ID/CHAT_ID/
   LARK_APP_ID env,`env -u ...` 即绕过。→ 新增 `managedOriginHasNoTransport()`:
   经 **pid-marker ancestry**(resolveSessionContext 走 process.ppid,非 env)解析
   origin session,再按其 session 记录的 chatId + bot apiOnly 配置判定。删/伪造
   env 无法脱掉 managed no-transport 身份。手验:父进程有 marker、子删三个 env 跑
   create-group → 仍 exit 2。LARK_FACING_COMMANDS 补 vc-agent / report(denylist
   漏洞)。

2. **成员过滤误伤 federation**:canCreateFeishuGroup 语义是「本机在线且非 apiOnly」,
   拿它过滤 member 会删掉 remote normal bot + 本地 offline normal bot。→ 拆成两个
   predicate:`isApiOnlyBot`(config-only,remote 也判得对)用于**成员**排除;
   `canBeCreator`(本机在线 + 非 apiOnly)用于**creator**。成员 payload 只按
   isApiOnlyBot 过滤 → federation remote normal bot 保留。

测试:新增**真 tamper-resistant 行为测试**(子进程 + pid-marker ancestry + 删/伪造
env + 双负向对照 real-chat/bare-operator);wiring source-lock 逐命令/逐路由 region
锁 + federation 拆分断言。全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)。

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

* fix(api-only): 凭证供给硬边界(no-transport 强制 read-isolation)+ remote apiOnly 联邦传播(codex 第11轮)

codex review 4816940951 拍板口径两条,按其最小修法实现:

1. **凭证供给硬边界**(真·零飞书 fail-closed,非 pid-marker 软早拒):
   forkWorker 的 readIsolation OR 上 `!larkTransportEnabled({chatId, apiOnly})`——
   no-transport session(apiOnly bot 或 HTTP 虚拟会话)强制走现有统一 fs-policy
   read-isolation:CLI 物理上读不到全量 bots.json / sibling BOT_HOME / send-cred /
   lark-cli store(mac+Linux fail-closed)。模型删/伪造 ancestry marker 或绕开 CLI
   直调代码也建不出任何 sibling client。复用现有 policy,不新造窄 cred-only 模式。

2. **remote apiOnly 联邦**:FederatedBot 加 `larkTransportEnabled?: boolean`(不用
   apiOnly 实现字段)。全链传播:localBots 按本地 apiOnly 配置打包 → sanitizeBots
   显式 boolean 保真(absent→undefined→按 legacy normal)→ store → AggregatedRoster
   → createTeamGroup 的 isNoTransportBot 同时查本地 config + roster 的
   larkTransportEnabled===false。creator=本机在线+有 transport;member 排除
   local+remote 两类 no-transport,保留 remote normal / legacy。

测试:
- 新增 api-only-federation-capability.test:真 register/sync/roster 往返,验 remote
  apiOnly→false 被排除、remote normal/legacy 保留(4 类矩阵的 remote 侧)
- wiring source-lock 扩到验 readIsolation 强制 + 联邦全链传播 + isNoTransportBot
- 全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)

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

* fix(api-only): forkAdoptWorker 也强制 no-transport readIsolation(堵第二 fork 入口)

codex 终审候选核查 "capability 是否冻结到所有 worker 生命周期" 时提示查 adopt/
persistent-pane 是否有绕过主 fork 的第二入口。自查确认:forkAdoptWorker 的 init
消息**没设** readIsolation(默认 undefined),是未 gate 的第二 spawn 入口。

修复:forkAdoptWorker 也加 `readIsolation: botCfg.readIsolation === true ||
!larkTransportEnabled({chatId, apiOnly})`,与 fresh-spawn fork 同一 gate。adopt
结构上是人类 pane 附着(今天不会是 apiOnly/virtual),但两处都 gate 使凭证边界
在**每个** worker-spawn 入口都可证 frozen,而非仅主 fork。

测试:wiring source-lock 改为断言**两个** fork 入口都 gate readIsolation(≥2 处
匹配)+ 无遗留未 gate 的 `readIsolation: botCfg.readIsolation === true,` 旧形式。
全 api-only 35 测绿。

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

* fix(api-only): adopt no-transport 在 daemon restore 侧 fail-closed(改对层,撤销 dead init 字段)

codex review 4817189259 #2:上一版给 forkAdoptWorker init 塞 readIsolation 是 dead
no-op——worker 的两条 adopt observe 分支(6413/6478)在 fs-policy 计算前直接 return,
init 里的 readIsolation 根本不包既有 pane。

正确修法(按 codex):撤销 adopt init 的 readIsolation;改在 daemon restore 侧
`adoptSandboxBlocked` 增加 no-transport 判定——apiOnly bot 或虚拟会话 chatId 时
fail-closed,转 cold-start(与 sandbox adopt 同处理)。覆盖「普通 adopt session
后续改 apiOnly 再重启会继续观察未隔离外部 CLI」这条可达路径。三个 adoptSandboxBlocked
调用点(session-manager restore / command-handler / worker-pool)全覆盖。

fresh/resume/restart 侧 forkWorker 的 readIsolation 门保留(那条不是 dead——fresh
spawn 会真跑 fs-policy)。

测试:wiring 改为验 fresh 门 + adopt restore 门两条不同机制;session-adopt/resume
回归绿。

注:codex #1(generic readIsolation 非 no-transport profile:放行 own lark-cli 凭证
+ workingDir=~ 重开 home RW)需显式 frozen no-Lark-transport fs-policy carve-out,
是更大 fs-policy 改动,另轮按 codex 最小修法做。

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

* fix(api-only): 显式 no-Lark-transport fs-policy 凭证边界 + localBots fail-closed(codex #1)

codex review 4817189259 #1(最后一条 blocker):通用 readIsolation 不是 no-transport
credential profile——它放行 own lark-cli 凭证,且 workingDir 默认 ~ 时把 $HOME
(含 bots.json + sibling BOT_HOME + lark-cli stores)重新按 RW 打开(codex 用
buildFsPolicy 跑出 4 条 readWrite 证据)。

修法(FsPolicyContext 加 larkTransportEnabled,worker 按 apiOnly||虚拟 chatId 传):
larkTransportEnabled=false 时——
- **抑制**所有飞书凭证授权:adapter authPaths、own .lark-cli-bots/<self>、macOS
  lark-cli keystore carve-out 都不再 push readWrite;
- **强制 mandatory deny**(最后 push、longest-prefix 胜过任何宽 readWrite 包括
  workingDir=~):bots.json、.lark-cli-bots(全)、macOS lark-cli store(全)、以及
  adapter authPaths 本身。
→ 即使 workingDir=~ 把 home 整个开成 RW,飞书凭证路径仍 deny;own BOT_HOME 工作目录
  仍可写(deny 只锁凭证路径)。

localBots fail-closed(P2):loadBotConfigs 读失败时不再 fail-open 把 apiOnly 误标
normal——该轮所有 bot federate transport=false(远端不邀请,safe-but-degraded)。

测试:fs-policy.test 新增 no-transport profile 3 用例(workingDir=~ 最坏情形下
bots.json/lark-cli/authPaths 全 deny;normal bot 不受影响;own BOT_HOME 仍可写,
负向对照)。全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)。

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

* fix(api-only): 修 no-transport fs-policy 误伤 CLI authPaths 的功能回归(codex P1)

codex 复审 7b305dd 抓到严重功能回归:我把 CliAdapter.authPaths 误当飞书凭证,
no-transport 时 suppress+deny 了它们——但 authPaths 是**模型 CLI 自己的登录/状态面**
(codex-app 的 ~/.codex 且不支持 BOT_HOME redirect、claude 的 ~/.claude credentials、
Seed/bytedcli SSO、Gemini OAuth、OpenCode DB)。这会直接让主功能里的 codex-app 读不到
~/.codex 鉴权失败——正是 riff 要连测的核心。

修正口径:no-Lark profile **只 deny 飞书 authority**,保留 CLI 自身 authPaths:
- authPaths 恢复无条件 readWrite(不再按 larkTransport gate)
- mandatory deny 移除 authPaths;保留飞书 authority:bots.json、.lark-cli-bots(全)、
  macOS lark-cli store(全)、sibling BOT_HOME(含 send-cred)、own send-cred.json
- own BOT_HOME 深路径 re-allow(工作目录可写),只 send-cred.json deny
- own .lark-cli-bots/<self> + macOS keystore carve-out 仍 no-transport 抑制(那是飞书凭证)

测试:改用**真 codex-app adapter 的 ~/.codex** 断言——no-transport 下 ~/.codex 仍
readWrite(核心功能不断)、飞书 authority(bots.json/lark-cli/sibling send-cred) 全 deny;
normal bot 全保留。你之前的虚构 ~/.lark_login 用例已换掉。

⚠️ canary.2 含此回归,riff 先别用;我发 canary.3。

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

* fix(api-only): 统一 no-transport host-authority profile(修 codex 提权 P1,三条最小修法)

codex review 定的三条口径全实现:

1. **authority 目录根 deny + 最小可信 carve-out**(不再补 exact 文件):no-transport 时
   deny 整个 botmuxHome + ~/.lark-cli-bots + macOS lark-cli store(+ 冻结的自定义
   BOTS_CONFIG root)。堵住之前漏的 .dashboard-secret(trusted-host HMAC 提权向量)、
   .dashboard-token、feishu-session.json、bots.json.bak/.tmp、dashboard-daemons 端口表。
   carve-out 只留:own BOT_HOME(RW,send-cred.json 除外)、own bots-info/sessions-self/
   bot-openids-self(RO)、own turn-sends、CLI 运行必需的 .data-dir/.dashboard-port/bin/
   claude-plugin/lark-scopes + install root。模型 CLI 自身 authPaths(~/.codex) 保留。

2. **deepest-prefix 重开修复**:workingDir / user sandboxPaths(RW,RO) / extraWritePaths /
   readonlyRoots 落在 authority root 内的(own BOT_HOME 除外)在进规则集**前** dropAuthority
   过滤掉——deeper user allow 不再能重开 authority deny。dashboard-daemons 也只在
   transport-enabled 时授予。

3. **BOTS_CONFIG 由 daemon 冻结**:worker 把 process.env.BOTS_CONFIG 的解析路径+其目录
   作为 larkAuthorityRoots 传入,no-transport 拒绝该布局(deny 整目录,覆盖未来 sidecar),
   不靠列举 .bak/.tmp。

测试:fs-policy.test 按 codex 验收矩阵扩展(53 测)——真 codex-app ~/.codex 保留、
workingDir=~、敌意 nested sandboxPaths 重开被拦、custom BOTS_CONFIG root deny、
.dashboard-secret/token/daemons deny、CLI runtime 仍可用、normal bot 负对照。
全量过(失败集仍只 v3-worker-fence/cancel/goal 环境 flake)。

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

* fix(api-only): no-transport fs-policy 冻结 default+configured 双权威根 + 外置 BOTS_CONFIG fail-closed(codex 双 P1)

修 codex 在 9e2261e 复审矩阵抓到的两组可复现 blocker:

1) custom SESSION_DATA_DIR 时 default ~/.botmux 漏出权威根(HMAC 提权仍可复现)。
   之前只把 configured 根(dirname(dataDir))传 policy;custom data-dir 布局下
   default ~/.botmux 仍存活的 .dashboard-secret/.dashboard-token/feishu-session/
   bots.json 全 readWrite。现 buildFsPolicy 通过新导出的纯函数
   computeNoTransportAuthorityRoots **始终冻结 configured + default 双根**(相等自动
   去重),并把 bare ~/.lark-cli 也纳入权威根——之前只 baseline-deny,会被更深的
   user sandboxPaths RW/RO 重开(deepest-prefix wins)。

2) 外置 BOTS_CONFIG 被静默 mask 整个 dirname(会隐藏 /tmp、/etc、项目根,废掉 core CLI)。
   现由 daemon 用 getLoadedConfigPath() **冻结实际 loaded config path** 经 init 消息传
   worker(不再让 worker 用 BOTS_CONFIG env 重猜)。落在冻结根内→parent mask 已覆盖;
   落在任何根外→抛 FsPolicyConfigError(fail-closed),worker 转成 hard spawn-abort +
   诊断,绝不静默 mask 父目录。workingDir 若 IS 权威根(own BOT_HOME 除外)同样抛错,不再
   dropAuthority 后 spawn 进未授权 cwd;workingDir=~(仅是祖先)保留,深层 parent deny 盖住。
   被抑制的 user allow 路径记录并由 worker 日志(不静默)。

模型 CLI authPaths(codex-app 的 ~/.codex)始终保留 RW——那是模型自己的登录态、非飞书
凭证。redirect→BOT_HOME CODEX_HOME 走 resolveRedirectedAdapterAuthPaths 单一真源不变。

影响面:仅 no-transport(apiOnly bot / http_async_·http_wait_ 虚拟会话)路径;buildFsPolicy
新增 context 字段全 optional,transport-enabled 普通 bot 权威根为空、行为字节不变(已验:
normal bot 91 规则、外置 config 不抛)。forkAdoptWorker 不传 loadedBotsConfigPath——其
observe 分支在 fs-policy 组装前 return(避免 dead no-op)。

测试:
- fs-policy.test 59 测(+7):双根冻结(custom SESSION_DATA_DIR)/ ~/.lark-cli 敌意 nested
  RW/RO 拦截 + 抑制项记录 / loaded config 在根内免额外规则 / 外置 config /tmp·/etc·project
  三形态 fail-closed + kind 断言 + normal bot 负对照不抛 / workingDir=权威根 抛错、~ 保留、
  own BOT_HOME 放行 / computeNoTransportAuthorityRoots 去重 / 真 codex-app adapter
  redirect→own CODEX_HOME 可用 + 宿主 ~/.codex 按 redirect drop/keep。
- api-only-mode-wiring +3 worker 真实装配 source-lock:worker 传双根 + frozen loaded
  config + FsPolicyConfigError→spawn-abort + 抑制日志;daemon 冻结 getLoadedConfigPath。
  负向验证:删 worker freeze 即红(关闭 codex「删 freeze 仍全绿」缺口)。
- session-lifecycle-start:补 bot-registry mock 的 getLoadedConfigPath(forkWorker 新依赖)。
- read-isolation source-lock anchor 跟随变量重命名(const fsPolicyCtx)。

验证:pnpm build + tsc --noEmit 绿;unit 项目 11237 passed,仅剩 6 个 v3-worker-fence/
v3-cancel-runtime/v3-goal-cli/group-join 的既有环境 flake(PID 扫描被本机 34 个 live
production worker.js 干扰,不引用本次改动符号)。canary.3 继续 HELD 到 codex 复跑矩阵双绿。

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

* fix(api-only): no-transport bots-config 落 carve-out fail-closed(codex 同层 P1)+ 2 项清理

修 codex 在 d26fcda 矩阵抓到的同层 P1:loadedBotsConfigPath「位于 authority root 内
即算已覆盖」在 white-in-black + deepest-prefix-wins 策略下**不成立**。root 内更深的可信
carve-out(own BOT_HOME RW / bin RO / attachments RW / outbox / install-root)会把落在
其下的 config 重新开放:

  ~/.botmux/bots.json                          → deny(正常)
  ~/.botmux/bots/<self>/bots.json              → readWrite(own BOT_HOME)
  ~/.botmux/bin/bots.json                      → readOnly(bin)
  ~/.botmux/data/attachments/<self>/bots.json  → readWrite(+ .bak 同样暴露)

BOTS_CONFIG 契约允许任意文件,no-transport agent 因此能读回全量 bot secrets、重获 sibling
Lark 能力。仅 exact-deny config 不够——sidecar 仍从 carve-out 暴露。

最小收口(codex 的自证方案):**完整 rules merge 后**用 accessForPath 自检——loaded config
**自身和 dirname 都必须 deny**,任一 RO/RW 即抛 FsPolicyConfigError('bots-config-in-carveout')。
dirname 检查顺带覆盖同目录 sidecar;own BOT_HOME/bin/attachments/outbox/install-root 及
**未来新增 carve-out** 全部自动 fail-closed,无需再枚举文件名。worker 侧 catch 既有 generic
FsPolicyConfigError→hard spawn-abort 分支天然覆盖新 kind。

2 项清理:
- 设计文档 EOF 去掉多余空行(diff --check)。
- FsPolicyContext.larkTransportEnabled JSDoc 更新:旧文案还写「suppress adapter authPaths /
  mandatory deny wins」,与实际实现(authority-root 整目录 deny + dropAuthority 深层过滤 +
  模型 authPaths 保留 RW)不符,改写为准确描述。

影响面:仅 no-transport 路径;self-check 只在 !larkTransport && loadedBotsConfigPath 时跑,
transport-enabled 普通 bot 不受影响(已验:normal bot 同样 carve-out 布局不抛)。default
~/.botmux/bots.json 与「plain denied 子目录(conf/、data/)」config 仍正常 build。

测试:fs-policy.test 60 测——carve-out 5 形态(BOT_HOME/bin/attachments/outbox/install)全
fail-closed + kind 断言 + normal 负对照不抛;denied 子目录 config + dirname + sidecar 全 deny
正向。负向验证:禁用 self-check → 恰好 carve-out 测变红(仅该条)。build + tsc 绿;208 定向测试通过。
canary.3 继续 HELD 到 codex 复跑收口矩阵双绿。

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

* docs(api-only): canonical 段同步 config-in-carveout 自检 + 60 测(codex 非阻塞文档 nit)

canonical 第 7 层还写「落在冻结根内 → parent mask 已覆盖」与「fs-policy.test 59 测」,
与实现不符:已加 post-merge accessForPath 自检(config + dirname 都必须 deny,否则抛
bots-config-in-carveout),测试也到 60 测。改写为准确描述:「落根内」必要非充分、carve-out
5 形态 fail-closed、denied 子目录正向、负向验证禁用自检即红。纯文档,不动代码结论。

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

* feat(api-only): core-only 启动入口(botmux serve --api-only)——单进程 headless HTTP 服务,供 riff sandbox 内嵌

riff 侧 core-only 接入的唯一 blocking 依赖。让 botmux 以单进程 headless 服务跑在 riff
sandbox 里:task-runner 通过 127.0.0.1 loopback 调它驱动 codex,输出走 task-runner 现有
push-logs 回投 riff(不需 botmux 出站 push)。

新增:
- `src/index-core-only.ts`:core-only 入口。设 BOTMUX_CORE_ONLY=1 后调 startDaemon(),
  单进程、无 pm2、无 dashboard、无 bots.json、无飞书凭证。校验 BOTMUX_API_PORT。
- `botmux serve --api-only [--port|--bot|--cli|--working-dir]`(cli.ts cmdServe):前台
  spawn 上面的入口(stdio inherit,进程生命周期=服务),转发 SIGTERM/SIGINT、透传退出码。
- bot-registry `maybeSynthesizeCoreOnlyConfig`:BOTMUX_CORE_ONLY=1 时从 env 合成**单个
  apiOnly bot**(larkAppId=local_<slug> 默认 local_riff、cliId 默认 codex-app、无 secret)。
  **AUTHORITATIVE:无视环境里已有的 ~/.botmux/bots.json**——否则 core-only 跑在有真 fleet
  配置的机器上会静默 boot 一个真·有飞书能力的 bot(WSClient+真凭证),与"零飞书"契约相反
  (本地 smoke 实测抓到并修正)。仅显式 BOTS_CONFIG 才 override。loadedConfigPath 钉在默认
  ~/.botmux/bots.json 路径(即使不存在),使 no-transport fs-policy 视其在默认权威根内、
  不触发 external/carve-out fail-closed。
- daemon.ts:core-only 时 IPC server 用**固定端口**(BOTMUX_API_PORT,maxProbe=0 bind-or-fail,
  不向上探测漂移——riff 拿到的是定死端口)+ **默认关 trusted-host HMAC**(authRequired:false;
  单租户 loopback、sandbox 内无 sibling daemon 要防;BOTMUX_API_REQUIRE_AUTH=1 可重开)+ stdout
  ready 行 `[core-only] listening on 127.0.0.1:PORT (bot …, cli …)`(锁定契约文本)。
- dashboard-ipc-server.ts:`startIpcServer` 加 maxProbe 透传;新增 `GET /healthz` 公开别名
  (= /__health,riff launcher 探活用)。

契约(给 riff,已锁):启动 `botmux serve --api-only`(或 node dist/index-core-only.js)+
BOTMUX_API_PORT;ready 行 / GET /healthz 探活;HTTP 面同 daemon IPC(POST /api/trigger
asyncReturnSessionId + GET /api/sessions/:id/trigger-result + insight?detail=conversation +
/answer),loopback 明文无需签名;~/.codex 凭证保留。

验证:
- 隔离 HOME 实跑 core-only:ready 行显示合成 local_smoke(非真 fleet bot)、apiOnly 门全触发
  (跳过 open_id/scope/WSClient)、无飞书连接;GET /healthz→200{ok:true} 无鉴权;POST
  /api/trigger 明文无签名→queued+sessionId+http_async_ chatId;trigger-result→running;
  insight conversation→200。全链 round-trip 通。
- bot-registry.test +6:合成/authoritative 无视 ambient bots.json/BOTS_CONFIG override/
  local_ slug 校验/默认值/CORE_ONLY 未设不改行为。
- tsc + build 绿;255 定向测试通过。live fleet 未受影响(隔离 SESSION_DATA_DIR+HOME 实测)。

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

* fix(api-only): core-only 入口收紧 4 组 P1(codex 复审)——auth allowlist / authoritative config / readiness barrier / loopback

修 codex 在 b5d5bfb core-only 入口复审抓到的 4 组可复现 P1(都实测证实):

P1-1 auth 裸开:`authRequired:false` 不是只放行自 trigger,而是把全部 96 条 Dashboard IPC
  裸开(co-resident model turn 可读/扰动全部会话、scheduler、mutations)。改为**保持 HMAC
  (authRequired:true)**,只精确 allowlist riff 面:`/api/trigger` + `/api/sessions/:id/
  trigger-result` + `/insight`(+ 一直公开的 /healthz)。新增 `routeIsCoreOnlyPublic` +
  startIpcServer `coreOnlyPublicRoutes` 开关;其余路由照常要 HMAC。**`/api/asks/answer` 不放行**
  ——它按全局 askId 提交、无 session/turn 绑定,裸开会让任一 co-resident turn 劫持其它 pending
  ask(codex);riff 主链走 asyncReturnSessionId 不需 awaiting_input。

P1-2 配置非真 authoritative:(a) startDaemon 在 load 前先跑 migrateSandboxConfigAtStartup,
  会读/备份/改写 ambient fleet bots.json——core-only 时**跳过**。(b) 合成配置之前遇到 ambient
  `BOTS_CONFIG` 会 defer 到该文件(可 boot 真飞书 bot、--bot 被忽略)——现在**无视一切 ambient
  配置**(bots.json + BOTS_CONFIG 都不读),冻结 exactly-one env 合成 apiOnly/local_/空 secret。

P1-3 无 readiness barrier:ready 行 + healthz 200 发在 restoreActiveSessions / v3 attach /
  scheduler 之前,riff 见 ready 即 trigger 会撞 durable restore 竞态(transient not_found /
  补发)。新增 armCoreOnlyReadinessGate/setCoreOnlyReady:bind 后立即 arm(/healthz → 503
  starting),restore + scheduler 全部就绪后(startDaemon 末尾 "Daemon is running" 之后)才
  setCoreOnlyReady + 打 ready 行。

P1-4 loopback 只约束了 IPC:terminal proxy + worker web 默认仍 bind 0.0.0.0。core-only 时
  terminal proxy 强制 127.0.0.1;入口 index-core-only 设 BOTMUX_WORKER_HTTP_HOST=127.0.0.1
  (worker 继承)——除非 operator 显式覆盖。

对普通 fleet daemon 零影响:所有分支 gated on BOTMUX_CORE_ONLY / coreOnlyPublicRoutes,未设
  时 auth/端口/迁移/proxy host 字节不变。

验证(隔离 HOME + 敌意 ambient bots.json + BOTS_CONFIG 实跑):
- 起来是合成 local_smoke(非 ambient 的 cli_REAL_FLEET);ambient bots.json 未被改写、无 .bak
  (迁移已跳过)。
- /api/trigger→400(bad-shape)/valid→queued+SID、trigger-result→200、insight→404:全非 401;
  /api/sessions、/api/asks/pending、/api/asks/answer→**全 401**(HMAC 生效)。
- /healthz ready 后 200;terminal-proxy 绑 127.0.0.1。
- 259 定向测试绿(bot-registry 合成 authoritative-ignore-BOTS_CONFIG + api-only-mode-wiring
  4 P1 source-lock:auth allowlist/迁移跳过/readiness barrier 顺序/loopback);tsc + build 绿。

canary.4 有这 4 洞,不可用于共享 HOME / 带 ambient BOTS_CONFIG 环境;本 SHA 起以 canary.5 为准。

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

* fix(api-only): core-only 3 组同层 P1(codex 二审)——readiness 全门/env 剥离/loopback 无条件冻结 + 真行为测试

修 codex 在 18824e4 二审抓到的 3 组同层 P1(都实测复现):

P1-1 readiness 只 gate /healthz:三条 public control route(trigger/trigger-result/insight)
  在 starting 阶段仍直接进 handler;且 armCoreOnlyReadinessGate 在 startIpcServer bind 之后
  跑,有"已 bind 未 arm"窗口。修:(a) arm 挪到 bind **之前**;(b) server 级 gate 里对 core-only
  public route 统一在 not-ready 时返 503(新 coreOnlyNotReady() helper),不再依赖调用方先查
  /healthz。healthz 自身仍 503 starting。

P1-2 BOTS_CONFIG env 泄漏:parser 虽忽略它做身份,但 raw env 被 fork worker 继承——agent 可
  `cat $BOTS_CONFIG` 读 workingDir 内真实 fleet.json 拿 sibling secrets。修:entrypoint 在
  dotenv 后、startDaemon 前 `delete process.env.BOTS_CONFIG`(workerForkEnv 展开的是删后的
  process.env,fork 拿不到);cmdServe 的 spawn env 也显式剥离。

P1-3 worker loopback 非强制:之前只在 host env 缺省时设 127,父/dotenv 的
  BOTMUX_WORKER_HTTP_HOST=0.0.0.0 会原样保留。修:entrypoint **无条件**冻结 127.0.0.1 +
  删除 legacy alias BOTMUX_WORKER_HOST;cmdServe spawn env 同样无条件设。

清理:entrypoint 顶部 "minus the trusted-host HMAC" 过时文案改为"HMAC 保持 ON,仅 riff 面
  allowlist";readiness inline 检查抽成 coreOnlyNotReady()。

真行为测试(codex 要的 real request/env/bind,非 source-lock):dashboard-ipc.test 新增
  core-only describe(真 startIpcServer + fetch):allowlist 只放行 trigger/trigger-result/
  insight(其余 + /api/asks/answer 全 401);readiness barrier——armed 未 ready 时直接打
  /api/trigger(不先查 healthz)返 503 starting,release 后 healthz 200 + 控制路由不再 503;
  非 core-only server /healthz 无条件 200。负向验证:删控制路由 503 分支 → 该测试变红。
  api-only-mode-wiring 补 P1-2 env-strip + arm-before-bind 顺序 source-lock。

普通 fleet daemon 零影响(全 gated on BOTMUX_CORE_ONLY / coreOnlyPublicRoutes)。349 定向
  测试 + tsc + build 绿;diff --check 干净。canary.5 有这 3 洞,本 SHA 起以 canary.6 为准。

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

* fix(api-only): core-only 冻结专用 state root + 跳过 host-wide maintenance(codex 三审 2 P1)

修 codex 用真 `serve --api-only` 冒烟在 5262f4c 抓到的 2 组 P1(前 3 项修复已确认成立):

P1-1 SESSION_DATA_DIR 未冻结:core-only 之前继承 ambient SESSION_DATA_DIR——managed turn
  spawn serve 时天然带宿主 env,daemon 就会读真实 fleet 的 sessions(codex 实测读到 31 个)、
  把 pid/descriptor/schedule-watcher/recovery/sandbox-sweep 全指向共享 fleet store。synthetic
  身份是 authoritative 了,但 STORAGE authority 仍由父进程控制。修:entrypoint 在任何 config
  模块读取前,用**专用 state root 覆盖 SESSION_DATA_DIR**——显式 BOTMUX_CORE_STATE_DIR
  (新 --state-dir 旗标)优先,否则 per-bot 默认 ~/.botmux/core-only/<botId>/data;ambient 值
  一律丢弃 + mkdir 保证存在。cmdServe spawn env 也剥离 ambient SESSION_DATA_DIR。

P1-2 host-wide maintenance:synthetic bot 是 idx=0,之前会跑 startMaintenance()(全局 botmux
  自动更新 + detached `botmux restart`)+ cli-update monitor + restart-report。core-only 不应
  承担 primary fleet daemon 的全局维护职责。修:门改成 `if (idx === 0 && !coreOnly)`——core-only
  整体跳过 maintenance/auto-restart/restart-report wiring。

验证(隔离 HOME + 敌意 ambient SESSION_DATA_DIR 指向植入哨兵 session 的假 fleet store 实跑):
- schedule watcher / daemon.pid / descriptor 全落 ~/.botmux/core-only/local_smoke/data(专用根),
  未读植入的 FLEET_SENTINEL session("No active sessions to restore");假 fleet store md5
  byte-identical、哨兵 daemon.pid 未动。
- 日志无 maintenance/cli-update/restart-report。
- 350 定向测试 + tsc + build + diff-check 绿。api-only-mode-wiring 补 state-root 冻结顺序 +
  maintenance !coreOnly 门 source-lock。

普通 fleet daemon 零影响(全 gated on coreOnly)。canary.6 有这 2 洞,本 SHA 起以 canary.7 为准。

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

* fix(api-only): core-only 不写 shared-HOME .data-dir / wrapper,落 dedicated bin(codex 四审 P1)

修 codex 用真 hostile shared-HOME 冒烟在 15f3177 抓到的最后 1 组 P1(前 2 项 state-root
冻结 + maintenance 跳过已确认成立):

writePidFile() 仍往共享 HOME 写两处:全局 `~/.botmux/.data-dir` breadcrumb 和
`~/.botmux/bin/botmux` wrapper。core-only 启动会把前者改成 core-only dataDir、后者改成当前
canary/review dist wrapper——同 HOME 的 host operator 会被 breadcrumb 导向 core store,现有
fleet worker 的 PATH 也会即时命中 canary wrapper,且退出不恢复。

修法:
- **breadcrumb**:core-only **跳过**写全局 `.data-dir`(它是 bare-shell CLI 的 shared-HOME
  signpost;core-only 自己的 worker 通过冻结的 SESSION_DATA_DIR env 拿 dataDir,不需要它)。
- **wrapper**:core-only 把 `botmux` wrapper 写进**专用** bin 目录 `<dataDir>/bin`(不再共享
  `~/.botmux/bin`);worker-pool 的 PATH 前置同一个 dedicated bin(否则 wrapper 不在 PATH 上、
  或被 fleet wrapper 遮蔽);fs-policy 给 `<dataDir>/bin` readOnly carve-out(沙箱内 `botmux
  send` hook 可 exec)。`botmuxWrapperBinDir()` 单一事实源,core-only-aware。

验证(真 shared HOME 预置 fleet 哨兵:`.data-dir` + `bin/botmux`):core-only 启动后两个哨兵
  **md5 byte-identical**(未改写);core-only 自己的 wrapper 落 `<dataDir>/bin/botmux`、指向
  review dist、功能正常;global `.data-dir` 仍是 fleet 原值。live fleet 未受影响。

普通 fleet daemon 零影响(全 gated on BOTMUX_CORE_ONLY;binDir/breadcrumb 未设时字节不变)。
371 定向测试 + tsc + build + diff-check 绿。api-only-mode-wiring 补 breadcrumb-skip + dedicated
binDir + worker PATH 匹配 + fs-policy grant 的 source-lock。canary.7 有此洞,本 SHA 起以
canary.8 为准。

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

* fix(api-only): core-only wrapper 消费链单一事实源——worker.ts×4 + tmux×2 全走 resolver(codex 五审 P1)

修 codex 在 923c1e0 抓到的 wrapper 消费链 P1(write 位置上一轮已修对,本轮收 consume):

canary.8 把 core-only 的 wrapper 写进专用 <dataDir>/bin,但 worker 真正拉起 CLI 时仍在 4 处
(worker.ts 通用 spawnCli 主链 / Codex RPC app-server engineEnv / native-title env /
child spawn 主 PATH)+ tmux backend 2 处(SHELL_WRAPPER_SCRIPT / debug-keep-shell)**硬编码
把 shared `$HOME/.botmux/bin` prepend 到 PATH 最前**。最终 PATH = shared:dedicated:… →
`command -v botmux` 在有 shared wrapper 的宿主上命中 shared(fleet/canary 污染)。

修法:**单一事实源** `resolveBotmuxWrapperBinDir(env)`(core/botmux-wrapper.ts)——core-only
(BOTMUX_CORE_ONLY=1 且有 SESSION_DATA_DIR)→ `<SESSION_DATA_DIR>/bin`,否则 shared
`~/.botmux/bin`。每个 PATH 消费点都改走它:
- daemon.ts:`BOTMUX_BIN_DIR = resolveBotmuxWrapperBinDir(process.env)`(替换本地函数)。
- worker-pool.ts:fork PATH 走 resolver。
- worker.ts:4 处 prepend 全改 `prependBotmuxBin(resolveBotmuxWrapperBinDir(process.env), X.PATH)`,
  删掉 4 处 `join(homedir(),'.botmux','bin')` 硬编码。
- tmux-backend.ts:2 处硬编码 `export PATH="$HOME/.botmux/bin:$PATH"` 换成
  `botmuxWrapperPathExportSh()` —— 一段在 pane shell 运行时按 BOTMUX_CORE_ONLY/SESSION_DATA_DIR
  解析的 POSIX 片段(pane 继承 daemon 这两个 env,与 JS resolver 同解)。

验证:
- 真跑(shared HOME 预置**可执行** HOST_WRAPPER_SENTINEL 于 ~/.botmux/bin/botmux):core-only
  的 dedicated wrapper 落 <dataDir>/bin、指 review dist;child PATH=dedicated:shared:… 下
  `command -v botmux` → dedicated(跑真 CLI,非 sentinel echo);global sentinel wrapper 未被改写。
- 真 /bin/sh 跑 botmuxWrapperPathExportSh():core-only→`<dataDir>/bin:…`、normal→`~/.botmux/bin:…`。
- 375 定向测试(botmux-wrapper 新增 resolver + 真 sh 片段行为;api-only-mode-wiring 锁
  worker.ts 4 处 + tmux 2 处 + worker-pool + daemon 全走 resolver、无残留硬编码;tmux wrapper
  smoke 仍绿)+ tsc + build + diff-check 绿。

普通 fleet 零影响(resolver 未设 core-only → 原 `~/.botmux/bin`,字节等价)。canary.8 有此洞,
本 SHA 起以 canary.9 为准。

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

* fix(api-only): tmux pane wrapper bin dir 用 host 冻结字面量(codex 六审 P1)

修 codex 在 23f0d3c 抓到的 tmux 消费链 P1:canary.9 的 tmux PATH 用一段**运行时** shell 片段
按 BOTMUX_CORE_ONLY/SESSION_DATA_DIR 解析 wrapper bin dir,但真实 tmux 顺序里这段执行前 pane env
已被 scrub:tmuxEnv 清掉全部 BOTMUX*、PANE_ENV_UNSET_CLAUSE 又 unset BOTMUX_INJECTED_ENV_KEYS
(含 SESSION_DATA_DIR);SESSION_DATA_DIR 要到更后的 `exec /usr/bin/env "$@"` 才经 argv 注入、
BOTMUX_CORE_ONLY 根本不在注入 allowlist。所以片段看到二者皆空 → 必选 $HOME/.botmux/bin,command
-v botmux 命中 shared/fleet wrapper。debug-keep-shell 同样中招。

修法:pane 脚本改用 **host 侧解析 + 单引号字面量 baked-in** 的 bin dir,不再在 pane 运行时解析:
- `SHELL_WRAPPER_SCRIPT` const → `shellWrapperScript(binDir)` 函数(binDir 单引号转义嵌入
  `export PATH='<binDir>':"$PATH"`);`buildDebugKeepShellScript(shell, binDir)` 同改。
- 三个持久后端调用点(tmux-backend spawn / tmux-pipe / zellij)都 `shellWrapperScript(
  resolveBotmuxWrapperBinDir(opts.env ?? process.env))`——opts.env 是 daemon 侧权威 per-session
  env(未被 pane scrub),core-only 解析出 <dataDir>/bin。
- 删掉 footgun `botmuxWrapperPathExportSh()`(看着能用实则被 pane scrub)。

验证:
- 新增真 /bin/sh 行为测试**复刻真实 scrub 顺序**:hostile 可执行 shared wrapper 在 PATH 首位 +
  pane env 带 BOTMUX_CORE_ONLY/SESSION_DATA_DIR(会被脚本 unset)→ shellWrapperScript(dedicated)
  跑出的 `command -v botmux` 命中 **dedicated**、跑 DEDICATED_WRAPPER、绝不 HOST_WRAPPER_SENTINEL;
  normal fleet 命中 ~/.botmux/bin。
- 432 定向测试(含 tmux-backend-env 58、botmux-wrapper resolver、wiring 锁三后端 host-resolve +
  无 SHELL_WRAPPER_SCRIPT import/call-site + footgun 已删、tmux wrapper smoke)+ tsc + build +
  diff-check 绿。

普通 fleet 零影响(resolver 未设 core-only → ~/.botmux/bin,字面量等价原硬编码)。canary.9 有此洞,
本 SHA 起以 canary.10 为准。

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

* fix(api-only): apiOnly bot 不构造 Lark Client(修 riff 真机 core-only boot fatal)

riff 首次真机 E2E:干净 sandbox(无 bots.json / 无 creds)装 canary.10,严格按契约
`BOTMUX_API_PORT=8930 BOTMUX_API_ONLY_BOT=local_riff botmux serve --api-only` 启动即 fatal:
`[core-only] fatal: appSecret or clientAssertionProvider is required`,/healthz 永不 ready。

根因:`registerBot`(core-only 合成 apiOnly bot 在 boot 时调用)**无条件** `new Lark.Client({
appSecret: cfg.larkAppSecret })`;apiOnly 的 secret 是 '',Lark SDK 的 Client 构造器对空 secret
直接抛 "appSecret or clientAssertionProvider is required" → 整个 daemon boot fatal。这条与
「apiOnly = 无 bots.json / 无飞书 creds / 零飞书网络」的核心契约冲突。

为何前几轮 review + 我的隔离 smoke 没抓到:单测 mock 的 FakeClient 空 secret **不抛**(掩盖了
真 SDK 行为),我早期隔离 smoke 跑在 SDK 容忍空 secret 的版本上;真机 SDK 版本对空 secret 在
ctor 即抛。

修法:apiOnly bot **完全不构造** Lark Client——它本就永不用(getBotClient 对 apiOnly 抛
LarkTransportDisabledError,getAllBotClients 过滤 apiOnly)。BotState.client 改 `Lark.Client |
null`,registerBot 对 apiOnly 置 null;getBotClient 保持 apiOnly 早抛,非 apiOnly 加防御性
null-guard(null=misconfig,fail loud 不 NPE)。

验证:
- **复刻 riff 精确 repro**(干净 HOME 无 bots.json,`serve --api-only --port 8930 --bot
  local_riff`,零 creds):不再 fatal——`Bot 0/1: local_riff` + `[core-only] listening on
  127.0.0.1:8930`;healthz→200;trigger(instruction)→queued+sessionId+http_async chatId。同
  vc-agent bootstrap skipped 警告(无害,优雅跳过)。
- bot-registry.test 补 apiOnly 回归:registerBot(apiOnly).client===null(不构造),getBotClient
  仍 fail-closed。279 定向测试 + tsc + build + diff-check 绿。

普通飞书 bot 零影响(非 apiOnly 照常构造 client)。canary.10 有此 fatal,本 SHA 起以 canary.11 为准。

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

* fix(api-only): VC-meeting-agent config 对 apiOnly 中央 fail-close(修 codex #668 二审 B2 blocker)

apiOnly(core-only)bot 无飞书连接,VC listener 会驱动 `lark-cli vc
+meeting-events --as bot`,违反 zero-Feishu-network 契约。dashboard 已禁止把
apiOnly bot 设为 VC listener,但挡不住迁移路径:普通 VC bot 改成 apiOnly 后重启、
dataDir 遗留 runtime record + bots.json 仍有 vcMeetingAgent.enabled:true。启动恢复
restoreVcMeetingRuntimeSessionsForBot 的调用点在 `!cfg.apiOnly` boot 块之外,
effectiveVcMeetingAgentConfig 又只看 enabled → 会 spawn lark-cli 打飞书。

修法(中央层,覆盖全部 ~24 个 VC 入口含 boot 恢复):
- bot-registry 新增纯谓词 vcMeetingAgentConfigActive(cfg):apiOnly 时先返回
  undefined,再判 enabled
- daemon effectiveVcMeetingAgentConfig 委托该谓词,一处 fail-close 覆盖所有消费者

测试:
- bot-registry.test 加 4 例行为回归(apiOnly+enabled→undefined / 正常 bot 不变 /
  字段序无关)
- api-only-mode-wiring.test 加 source-lock 锁委托 + 谓词内 apiOnly 早拒序
- 负向验证:删谓词 apiOnly 早拒 → 3 测试变红
- 全 api-only+vc+registry 9 文件 186 测试绿;pnpm build 绿

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Aug 3, 2026
Codex 复审 PR #710 抓 3 项,全部核实为真并修复:

## P1 #1:stop 恒为 terminal;仅 length 带 toolCall 才 mid-turn
读 pi-agent-core 的 agent-loop 确认:custom tool 返回 `terminate:true` 时
(公开扩展 API),一个 batch 全部 terminate → `hasMoreToolCalls=false` → agent_end,
不再写下一条 assistant。原「stop/length 带 toolCall 一律跳过」会漏掉这类真正
terminal 的 stop,导致 CodexBridgeQueue collecting head 与 durable receipt 永不关闭。
实测 255/255:toolCall 内容恒配 stopReason:"toolUse"(正常工具步是 toolUse 而非
stop),故 `stop` 恒 terminal(含带 toolCall 的 terminate-tool 收尾);仅 `length`
带 toolCall 属 mid-turn(failToolCallsFromTruncatedMessage → terminate:false 续跑)。
已在 drain 注明 reliableTurnTerminal 的已知边界:terminate:true 且末条为
toolUse(terminate 不落盘)时无磁盘边界——botmux 不发此类工具;真发生也仅该轮
fallback 回复/durable receipt 等下一轮 user 事件 HOL-drop,quiescence idle 照常标就绪。

## P1 #1(附带):aborted → ambiguous(原 failed)
采纳 codex 建议:Esc 可能发生在工具副作用已完成之后,`ambiguous` 保留「不知副作用
是否发生」审计语义、并允许同 generation 迟到 completed 结算(对齐 Codex/TraeX
turn_aborted);仍是 `!== completed`,照常 drop pending turn 释放队列头。

## P1 #2:补 Pi 同进程 session rotation 跟随
`/new`(botmux passthrough → runtimeHost.newSession())/`/resume`/fork 在同 pid
换新 UUID/JSONL,Pi writeInput 不回传 cliSessionId,原 worker 周期跟随只覆盖
Grok/TraeX → bridge continue 盯旧文件,rotation 后 user/final 全不可见、HOL 与
durable terminal 卡死。修:
- 新增 `maybeFollowPiSessionRotationViaPid()`(镜像 Grok,用 findPiTranscriptByPid),
  接进 bridge 定时器;
- `codexBridgeNotifyCliSessionId` 加 Pi rotation 分支(drain-before-detach + reattach,
  镜像 Grok);
- `findPiTranscriptByPid` 升级为「多命中取 mtime 最新」(原取首个 fd)——`/new`
  瞬时双开窗口下必须选新 session,否则 follower latch 旧 session 仍 wedge(Grok 已
  用 newestHit() 踩过同坑);
- 导出 piSessionIdFromPath 供 worker 复用。

## P2:拆分 structuredRateLimitAuthoritative 与 reliableTurnTerminal
`reliableTurnTerminal:true` 会令 `structuredRateLimitAuthoritative()` 抑制屏幕 rate
判定,但结构化限流 emit(maybeEmitStructuredRateLimit)只在 Claude bridge 存在。
Pi(及 codexBridgeQueue 家族 codex/grok/traex)error→failed/ambiguous 不发 limited
状态 → 真 429 丢 Dashboard「需要你」+退避。改 `structuredRateLimitAuthoritative()`
gate 于 `claudeDataDir`(真正有结构化 emit 的 Claude 家族),不再是宽泛的
reliableTurnTerminal——一并修正 codex/grok/traex 长期的静默过度抑制(它们本就无
结构化 emit,恢复屏幕扫描是安全方向;此前 usage 类仍走屏幕不受影响)。

## 影响面
- 共用路径 structuredRateLimitAuthoritative 改变 codex/grok/traex 行为(恢复屏幕
  rate 检测)——已在 PR 描述标注请 reviewer 确认。
- CodexBridgeQueue/其它 CLI drain 未改。

## 验证
- pnpm build 绿;pi-transcript 14/14(+rotation/pid-probe/stop-with-tool/length-with-tool)
  + 10 个受影响/相邻文件 607/607 隔离全绿。
deepcoldy added a commit that referenced this pull request Aug 3, 2026
…edicate 收敛 (#710)

* feat(pi): 重启用 type-ahead(原生 Message Queue)+ 补全 transcript turn 边界

## 背景
Pi CLI 自带 Message Queue,Agent 忙碌时用户可继续排队/steer 输入。botmux
曾于 b2c2ba6(2026-06-16) 开启 supportsTypeAhead,次日 b7dfa0c(2026-06-17)
撤回——当时 Pi 只有屏幕 marker `Working...`,没有可靠、会话级的 turn 完成信号,
忙碌期合并多条输入会把最终回复错归属 / 串飞书卡片。

## 为什么现在能重启用
撤回后 13 天,PR #327(2026-06-30) 给 Pi 加了 per-session JSONL transcript
bridge(src/services/pi-transcript.ts)——正是撤回时缺失的能力。

pi 0.80.6 真机实测确认:
- Pi 的 Message Queue 是 active-turn STEER(TUI 显示 "Steering:"),忙碌期提交
  的消息被拉进同一 turn,产出一条合并 final(user1→tools→user2(dequeue写入)→
  assistant_final)——与 Codex/Grok 完全同形,CodexBridgeQueue 的 HOL-block-drop
  + dequeue-time markTimeMs override 已能正确归属。
- StopReason 权威枚举(@earendil-works/pi-ai)= stop | length | toolUse | error
  | aborted。按 Esc 中断会持久化 stopReason:"aborted" + errorMessage + 空 content。

## 根因 bug(撤回真因,本次修复)
旧 drainPiTranscript 只在 stopReason==='stop' 且非空 content 时 emit
assistant_final。→ error/aborted turn(空 final)完全不 emit → type-ahead 下
CodexBridgeQueue 的 collecting head 永不关闭 → 队列头 wedge。这正是当年串卡片的机理。

## 改动
- pi-transcript.ts:drain 在每个 terminal stopReason 都 emit(含空 final)。
  toolUse=唯一 mid-turn 跳过;stop/length→completed(默认,保留 empty-final
  fallback);error→failed/pi_turn_error;aborted→failed/pi_turn_aborted(grok
  parity,reconciler 默认 failed_retryable)。关键细节:stop/length 仅在消息无
  toolCall 时才算 terminal(Pi agent-loop 对带 toolCall 的 length 会
  failToolCallsFromTruncatedMessage→terminate:false 继续 loop;error/aborted 是
  硬 terminal 无视 content)。新增 hasToolCall() helper。
- pi.ts:加 supportsTypeAhead:true + reliableTurnTerminal:true。故意不加
  mergeQueuedInput——每条飞书消息保持独立 turn/卡片,steer 合并交给 bridge queue
  收敛,而非预压队列(撤回期的 mergeQueuedInput 正是错的)。保留 busyPattern
  /Working.../(reattach idle probe 靠它,见 b5a4160)。
- 测试:新增 test/pi-transcript.test.ts(10 例,覆盖 stop/length/error/aborted/
  steer-merge/length-with-tools mid-turn/增量 offset/partial-line)+ 更新
  write-input.test.ts 三处断言(supportsTypeAhead/reliableTurnTerminal→true)。

## 影响面
- Pi 现符合 VC 会议 delivery consumer 资格(gate on reliableTurnTerminal)——新能力。
- structuredRateLimitAuthoritative 对 Pi 返 true → 屏幕扫描的 rate(瞬时限流)
  verdict 交给 transcript 权威;usage(配额)仍走屏幕(codex/grok 同款)。
- idle 检测:Pi 是纯 quiescence(无 readyPattern/injectsReadyHook),
  reliableTurnTerminal 只抑制冗余 post-submit busy-probe,quiescence idle +
  reattach probe 都还在。
- 共用层 CodexBridgeQueue / structured-bridge 逻辑一字未改,仅 Pi 侧 drain +
  两个 flag。

## 验证
- pnpm build 绿。
- pi 相关 6 个测试文件隔离全绿:pi-transcript(10) + write-input(118) +
  cli-adapters(313) + pi-initial-prompt + initial-prompt-arg-limit +
  structured-bridge-clis = 461/461。
- 全量回归:失败集与 clean master 逐一对齐(13 failed,全是 coco/codex/browser
  e2e + doc-comment/multi-bot-session mock,与本改动无关),未引入任何新失败。

* fix(pi): 收敛 codex 复审 3 blocker(turn 边界/session rotation/限流权威)

Codex 复审 PR #710 抓 3 项,全部核实为真并修复:

## P1 #1:stop 恒为 terminal;仅 length 带 toolCall 才 mid-turn
读 pi-agent-core 的 agent-loop 确认:custom tool 返回 `terminate:true` 时
(公开扩展 API),一个 batch 全部 terminate → `hasMoreToolCalls=false` → agent_end,
不再写下一条 assistant。原「stop/length 带 toolCall 一律跳过」会漏掉这类真正
terminal 的 stop,导致 CodexBridgeQueue collecting head 与 durable receipt 永不关闭。
实测 255/255:toolCall 内容恒配 stopReason:"toolUse"(正常工具步是 toolUse 而非
stop),故 `stop` 恒 terminal(含带 toolCall 的 terminate-tool 收尾);仅 `length`
带 toolCall 属 mid-turn(failToolCallsFromTruncatedMessage → terminate:false 续跑)。
已在 drain 注明 reliableTurnTerminal 的已知边界:terminate:true 且末条为
toolUse(terminate 不落盘)时无磁盘边界——botmux 不发此类工具;真发生也仅该轮
fallback 回复/durable receipt 等下一轮 user 事件 HOL-drop,quiescence idle 照常标就绪。

## P1 #1(附带):aborted → ambiguous(原 failed)
采纳 codex 建议:Esc 可能发生在工具副作用已完成之后,`ambiguous` 保留「不知副作用
是否发生」审计语义、并允许同 generation 迟到 completed 结算(对齐 Codex/TraeX
turn_aborted);仍是 `!== completed`,照常 drop pending turn 释放队列头。

## P1 #2:补 Pi 同进程 session rotation 跟随
`/new`(botmux passthrough → runtimeHost.newSession())/`/resume`/fork 在同 pid
换新 UUID/JSONL,Pi writeInput 不回传 cliSessionId,原 worker 周期跟随只覆盖
Grok/TraeX → bridge continue 盯旧文件,rotation 后 user/final 全不可见、HOL 与
durable terminal 卡死。修:
- 新增 `maybeFollowPiSessionRotationViaPid()`(镜像 Grok,用 findPiTranscriptByPid),
  接进 bridge 定时器;
- `codexBridgeNotifyCliSessionId` 加 Pi rotation 分支(drain-before-detach + reattach,
  镜像 Grok);
- `findPiTranscriptByPid` 升级为「多命中取 mtime 最新」(原取首个 fd)——`/new`
  瞬时双开窗口下必须选新 session,否则 follower latch 旧 session 仍 wedge(Grok 已
  用 newestHit() 踩过同坑);
- 导出 piSessionIdFromPath 供 worker 复用。

## P2:拆分 structuredRateLimitAuthoritative 与 reliableTurnTerminal
`reliableTurnTerminal:true` 会令 `structuredRateLimitAuthoritative()` 抑制屏幕 rate
判定,但结构化限流 emit(maybeEmitStructuredRateLimit)只在 Claude bridge 存在。
Pi(及 codexBridgeQueue 家族 codex/grok/traex)error→failed/ambiguous 不发 limited
状态 → 真 429 丢 Dashboard「需要你」+退避。改 `structuredRateLimitAuthoritative()`
gate 于 `claudeDataDir`(真正有结构化 emit 的 Claude 家族),不再是宽泛的
reliableTurnTerminal——一并修正 codex/grok/traex 长期的静默过度抑制(它们本就无
结构化 emit,恢复屏幕扫描是安全方向;此前 usage 类仍走屏幕不受影响)。

## 影响面
- 共用路径 structuredRateLimitAuthoritative 改变 codex/grok/traex 行为(恢复屏幕
  rate 检测)——已在 PR 描述标注请 reviewer 确认。
- CodexBridgeQueue/其它 CLI drain 未改。

## 验证
- pnpm build 绿;pi-transcript 14/14(+rotation/pid-probe/stop-with-tool/length-with-tool)
  + 10 个受影响/相邻文件 607/607 隔离全绿。

* fix(pi): 降级去掉 reliableTurnTerminal,只保留 type-ahead(收敛 codex 二轮)

Codex 二轮复审指出 reliableTurnTerminal 对 Pi 是过强承诺,两点已真机核实:
- Pi 的 SessionManager 用短命 appendFileSync(open→append→close)写 JSONL,
  进程**全程不持有 session fd**(实测:整轮里 /proc/<pid>/fd + lsof 都查不到
  ~/.pi/agent/sessions/*.jsonl,6s 紧密轮询也抓不到瞬时 append fd)→ 基于 pid 的
  session rotation 跟随根本不可用,durable 交付也没有可靠边界。
- custom tool 返回 terminate:true 时 agent 在 toolResult 后直接结束,末条 assistant
  是 toolUse(非 terminal stopReason)且 terminate 不落盘 → 该轮无磁盘结束标记。

关键:**type-ahead 不依赖 reliableTurnTerminal**(input-gate 只看 supportsTypeAhead;
回复归属走 structured-bridge 名单,Pi 已在内)。reliableTurnTerminal 只额外解锁
VC 会议 delivery 资格、抑制 busy-probe、限流权威——这三者才真需要「始终落盘的可靠
边界」,恰是 Pi 给不了的。故按申晗拍板走「降级」:

- pi.ts:去掉 reliableTurnTerminal,只留 supportsTypeAhead(+ busyPattern
  /Working.../ 的 idle 路径,Pi 本来就跑这条)。docstring 详述为何不能声明。
- worker.ts:删掉本轮加的(且非功能性的)Pi rotation 跟随——
  maybeFollowPiSessionRotationViaPid、codexBridgeNotifyCliSessionId 的 Pi 分支、
  定时器调用;findPiTranscriptByPid 回退首个匹配(rotation 用例已移除),
  piSessionIdFromPath 回退非导出。/new 轮转对 Pi 本就未支持(pre-existing),不 claim。
- structuredRateLimitAuthoritative():仍改 gate 于 claudeDataDir(codex 认可、
  建议留本 PR)——修正 codex/grok/traex/Pi 长期把屏幕 rate 判定误抑制而无结构化
  替代的 latent 问题。加聚焦回归测试(Claude family 有 claudeDataDir=抑制;
  codex/grok/traex/pi 无=保留屏幕扫描)。
- drain 保留每 terminal stopReason 都 emit(含 error→failed / aborted→ambiguous /
  stop 恒 terminal / length+toolCall mid-turn 跳过):这些是 CodexBridgeQueue 的
  正确归属 + 终态元数据,无 reliableTurnTerminal 也 harmless/useful。

## codex 已认可项
aborted→ambiguous、length+toolCall mid-turn、限流 gate 改 claudeDataDir(留本 PR)。

## 验证
pnpm build 绿;pi-transcript 11/11 + write-input 120/120(含 2 个新限流权威测试)+
9 个受影响/相邻文件 572/572 隔离全绿。

* fix(pi): 收敛 codex 三轮(stop+toolCall 回退 mid-turn + 限流权威抽纯函数测试)

Codex 三轮关闭二轮 2 个 P1(降级方案成立),提 1 P1 + 1 P2:

## P1:stop+toolCall 回退为 mid-turn(撤销上一轮「stop 恒 terminal」)
上一轮为 custom-terminate 把 stop 设为恒 terminal,但 codex 指正 + 我重读
agent-loop 确认:真实 terminate 是先落 assistant(toolUse)、工具执行后才得到
terminate,stop+toolCall 的合成形状实际会走 executeToolCalls;batch 不 terminate
时 hasMoreToolCalls=true → 继续 loop,真 final 在后面。当前 drain 会对 stop+toolCall
提前 emit final/fireIdle/发 partial fallback,后续真 final 变 unmatched → 破坏
type-ahead 归属。降级后安全策略:**stop/length 都仅在无 toolCall 时 terminal**;
真实 custom terminate 无终态就接受 quiescence + 下一普通 user HOL-drop(已注释声明为
accepted gap)。翻转对应 fabricated test(stop+toolCall 现断言被跳过、只末条
tool-call-free stop 收尾)+清理 pi-transcript.ts 陈旧注释(durable completion /
aborted=failed / stop+toolCall=terminate 全部改正)。

## P2:限流权威抽纯函数 + 真测 worker gate
上一轮的限流测试只断言 adapter.claudeDataDir 字段,即使
structuredRateLimitAuthoritative 改回 reliableTurnTerminal 也仍绿。抽纯函数
`isStructuredRateLimitAuthoritative(adapter)` 到 cli-usage-limit.ts(worker 的
structuredRateLimitAuthoritative() 现委托它),直接单测:Claude/Genius=true、
Codex/Grok/TraeX/Pi=false、null/undefined/{}=false。删掉 write-input.test.ts 里
的弱字段断言。

## 验证
pnpm build 绿;pi-transcript 11/11 + write-input 118/118 + cli-usage-limit 26/26
(含纯函数测试)+ 10 个受影响/相邻文件 607/607 隔离全绿。

* chore(pi): 收 codex 四轮 2 个非阻塞 nit(注释措辞 + EOF 空行)

Codex 四轮 APPROVED(e8eac39 无 blocker),顺手收两个 nit:
1. cli-usage-limit.ts 注释原写「codex/grok/traex/pi 也设 reliableTurnTerminal」,
   但 pi 现已不设——改为「多数(codex/grok/traex)设该 flag,pi 也是
   codexBridgeQueue 但不设;无论哪种都无 claudeDataDir,故都正确保留屏幕扫描」。
2. test/pi-transcript.test.ts 末尾多一空行(git diff --check 非零)→ 去掉。

无逻辑改动;build 绿 + pi-transcript 11/11 + cli-usage-limit 26/26。

* fix(ci): 修两个预先存在的 flaky 测试(codex-rpc 临期轮询 + v3-goal kill-9 竞态)

PR #710 的 CI build 两次跑挂在不同的 timing 测试上(首跑 codex-rpc-engine、重跑
v3-goal-cli),都不在本 PR 的 Pi 改动 diff 里、本地各自隔离全绿、在 master 上就存在
——是预先存在的真 flaky。申晗拍板在本 PR 顺手修掉,让 CI 能自然绿。

## codex-rpc-engine.ts(真 bug)
waitForThreadPreview / waitForThreadUpdatedAfter 的轮询循环:最后一圈 remaining
可能只剩 ~1ms,仍会拿 `Math.min(remaining,2000)`=1ms 作为 client 超时发一个
thread/read。readThreadMetadata 在请求超时时是 REJECT(非返回),该 reject 逃出
轮询循环、直接把整个 waitFor* 打成失败——而它本该降级为「没等到,返回 undefined」。
修:加 MIN_POLL_REQUEST_BUDGET_MS=50 下限,remaining 低于它即视为 deadline 已到,
返回 undefined / return,不再发注定超时的请求。localhost RPC 往返远低于 50ms,
对 200ms–10s 的调用方预算可忽略。

## test/v3-goal-cli.test.ts(测试计时太紧)
「kill -9 → 可重试 journal → 重跑成 attempt 002」用例:fixture 在 runNode 里同步写
`ready` 后挂起,父测试见 `ready` 即 SIGKILL。但 attempt 001 的 nodeDispatched
journal 追加可能尚未 flush 到盘,CI 高负载下 kill 抢在 001 落盘前 → 恢复无 001 可
re-drive → 断言 ['001','002'] flake。修:kill 前除等 `ready` 外,再轮询 journal
直到 001 的 nodeDispatched 真的在盘上(确定性前置条件,无固定计时猜测),poll 上限
提到 5s 仍在 15s 预算内。

## 验证
build 绿;两个测试各跑 3× 稳定绿;codex-rpc-engine 全套 + 消费方
(codex-effort-wiring/traex-worker-bridge-wiring/session-rename-worker) + v3-goal-cli
+ pi/限流测试共 215/215 隔离绿。
deepcoldy added a commit that referenced this pull request Aug 6, 2026
…locker)

codex 二轮 review(4878071011)7 blocker,按其拍定的 v3 设计重写。核心:lease 只管
「是否允许派发」,async-trigger-store 管「调用方看到的终态」——两者职责分离,不再靠
第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。

- #6(最核心,terminal 不接进 trigger-result):async-trigger-store 扩 status
  pending|completed|**failed**(failed 带 errorCode:no_output, reason:dispatch_unknown)。
  新增 recordFailedStrict(per-session withFileLockSync + atomicWriteFileSync durable + 抛错,
  与 recordCompleted 同锁串行,completed 更强证据恒胜)。resolveAsyncTriggerState 新增
  durable-failed 分支(优先级 completed > failed > closed > pending)——即使 reconcile 的
  closeSession 抛错、session 保持 open,trigger-result 也收敛 failed,不永久 running。
- #1(replace 非原子撕 tombstone):idempotency-store 全部改 atomicWriteFileSync(tmp+fsync
  +rename,失败保留旧文件),干掉 unlink→link。
- #2(takeover 非精确 CAS + 丢 won/existing):takeover 返回 {won|existing},锁内对完整
  immutable identity(owner+boot+session+trigger+requestHash+revision)精确校验;stale rev1
  不能覆盖 fresh winner rev1(新增回归测试)。lease 状态精简为 reserved|attempting(terminal
  移出到 async-store)。
- #3(reconcile 跨 bot):reconcileIdempotencyLeasesOnBoot(ownerLarkAppId, currentBootId)
  显式传 owner,读写/close 前 fail-closed 过滤 record.ownerLarkAppId,跳过 current boot。
- #4(reconcile 在 bind 之后):移到 setActiveSessionsRegistry 之后、startIpcServer 之前
  (daemon.ts)。返回 quarantine Set 传入 restoreActiveSessions,被 terminalize 的 session
  排除 re-attach(防状态/执行面分叉)。
- #5(本 boot 失败留坏 lease):barrier 前失败 compareAndRemove 释放 reserved(重试可全新);
  barrier 后 dispatch 同步 throw → recordFailedStrict + close(durable failed,不重派)。
- #7(HTTP 契约):trigger status mapper 加 idempotency_conflict→409;idempotent 的
  state:failed 视作 200(成功 HTTP 调用报终态,非请求错误)。
- 所有 claim/takeover/transition/compareAndRemove 走同一 per-key withFileLockSync(rename
  只原子替换≠CAS,必须锁内 read→校验→写)。withKeyLock/ensureDir 保证 .lock 父目录存在。

验证:pnpm build 通过。测试真穿状态机崩溃点——idempotency-store 16(含 stale-rev1 竞争 /
corrupt fail-closed / compareAndRemove CAS);trigger-session-idempotency 12(真 store:
attempting-orphan→async failed+close+quarantine / reserved-orphan→删+close / completed 留 /
current-boot 跳过 / **OTHER-owner 跨 bot 零触碰**);trigger-api 校验+范围拒绝;async-store/
state/api-only-wiring(readiness 序不变) 全绿。affected+shared-path 11 套件 327/327 绿。
docs-site build 绿。不带 key 的普通 trigger/webhook 行为零变化。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
四处收口,全部 fail-closed / owner 正向背书:

1. attempt-barrier 失败释放:compareAndRemove 改返回判别式结果
   (removed|absent|changed),不再吞 false/异常。干净移除→重试全新;
   changed→attempting(rename 落盘后 fsync 抛,即已跨越的 commit-unknown
   fence)→durable recordFailedStrict 并返回**可观测 state:failed**(非裸
   5xx);compareAndRemove 抛(EIO/损坏)→诚实 5xx,lease 留给下轮 reconcile。
   另:resolveIdempotencyHit 改以 LIVE-ness(而非 ownerBootId)判定"真正在飞":
   attempting/reserved + 同 boot + 无 live worker → terminal,杜绝同 boot 无限复用。

2. boot reconcile:compareAndRemoveByPath 返回判别式结果;对 changed→attempting
   重分类为已跨越 fence(durable terminalize,绝不删),changed→current boot 跳过
   (在飞),其余不可证明收敛→fail-closed 抛。store 侧锁内二次读取损坏由折成
   false 改为 THROW。

3. 跨 bot owner 校验:async 终态证据仅在 asyncRec.ownerLarkAppId === lease owner
   时采信(foreign completed/failed 一律忽略,修 A 采信 B 终态压制 A dispatch 的
   确定性复现);session 读取由 getSession 改 getOwnedSession(不再跨 bot 文件回退
   泄漏 chatId);terminalizeAttempting 遇 foreign-owned async 槽位跳过而非抛,避免
   把 finding #4 的跨 bot 启动 DoS 形状重新引入。

4. 存储布局 owner 分区:idempotency/<sha256(owner)>/<keyHash>.json;listAll 改
   listAllForOwner 只枚举本 owner 子目录。任一 foreign/未知 owner 坏文件不再阻断
   本 bot 启动;本 owner 坏文件仍 throwOnCorrupt fail-closed。该文件从未进过任何
   已发 tag、分支未并入 master,故无需迁移。

测试:idempotency-store 19、trigger-session-idempotency 20(补 #1 live-ness、
#2 CAS 重分类/损坏 abort/并发 takeover throw、#3 foreign-completed/failed、
#4 foreign-corrupt 不阻断)、e2e 9(补 #1 barrier pre-rename/post-rename/EIO
真穿 triggerSessionTurn 故障注入)。affected+shared 204/204 绿,pnpm build 绿,
unit project 13132/13133(唯一 1 例为并发满载下的既有 timing flake,孤立运行
32/32 绿,与本改动无关)。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit to xiaoxueSunn/botmux that referenced this pull request Aug 7, 2026
master 前进到 7ec6d4b(含 deepcoldy#775/deepcoldy#730/deepcoldy#750/deepcoldy#762),与 deepcoldy#597 二次冲突 6 文件。
本 worktree merge origin/master 解冲突(admin-squash 时拍平)。

6 文件逐点取舍:
- core/reply-target.ts: union 两套并存的 per-turn 记录——deepcoldy#597 turnReplyContexts(frozen dispatch target)+ deepcoldy#750 replyTargets(mention-back 参与者窗口)。dedupeParticipants/buildTurnParticipantsFrom/collectTurnWindowParticipants/frozenReplyContextForTurn 全保留。
- cli.ts: replyTargets 类型取 master 更全版 + 保 deepcoldy#597 codex-app 字段;flash footer 取 master 对象式(deepcoldy#762),删除会话用 result.mode(非 master 误用的 result.via);replyTargetSenderOpenId 回退链 union:VC → deepcoldy#597 frozenTurnDispatch → deepcoldy#750 turnReplyTarget.senderOpenId → legacy。
- daemon.ts: 三处 registration-race 取 deepcoldy#597 结构化 claimNewDaemonSession + routeToCanonicalOwner/handleThreadReplyAdmitted;destructure union routeToCanonicalOwner + senderIsBot。修真实回归:CAS-loser 经 handleThreadReplyAdmitted 重算 post @s 只读 data.message 漏 ctx.forwardSeedData → 双 race 丢种子转发 post @。两处重算补 forward-seed 参数(codex 合并不变量deepcoldy#1)。
- 3 测试文件: import union;initial-passthrough-ownership 取 deepcoldy#597 claim 断言;cli-send-hook-context + daemon-turn-reply-sender-wiring 迁写到 deepcoldy#597 模型(forwardSeed 重算)并保 deepcoldy#750 mention-back 断言并存。

验证: pnpm build✅; 受影响套件全绿(reply-target-fallback 41/daemon-turn-reply-sender-wiring 7/cli-send-hook-context 14/send-policy 46/initial-passthrough-ownership 8/daemon-rename-route 55/command-handler 235/transfer-session 71/dashboard-create-session 41/trigger-session-root-message 41/restore-zombie-close 33/scheduler-silent-execute 24/session-resume 39); deepcoldy#597 核心 steer worker-routing 8+bridge 66+dispatch 6+transfer-gate 2+initial-user-turn 28。codex-app-runner 1 项既有 bounded-history flake 无关。
deepcoldy added a commit that referenced this pull request Aug 7, 2026
…locker)

codex 二轮 review(4878071011)7 blocker,按其拍定的 v3 设计重写。核心:lease 只管
「是否允许派发」,async-trigger-store 管「调用方看到的终态」——两者职责分离,不再靠
第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。

- #6(最核心,terminal 不接进 trigger-result):async-trigger-store 扩 status
  pending|completed|**failed**(failed 带 errorCode:no_output, reason:dispatch_unknown)。
  新增 recordFailedStrict(per-session withFileLockSync + atomicWriteFileSync durable + 抛错,
  与 recordCompleted 同锁串行,completed 更强证据恒胜)。resolveAsyncTriggerState 新增
  durable-failed 分支(优先级 completed > failed > closed > pending)——即使 reconcile 的
  closeSession 抛错、session 保持 open,trigger-result 也收敛 failed,不永久 running。
- #1(replace 非原子撕 tombstone):idempotency-store 全部改 atomicWriteFileSync(tmp+fsync
  +rename,失败保留旧文件),干掉 unlink→link。
- #2(takeover 非精确 CAS + 丢 won/existing):takeover 返回 {won|existing},锁内对完整
  immutable identity(owner+boot+session+trigger+requestHash+revision)精确校验;stale rev1
  不能覆盖 fresh winner rev1(新增回归测试)。lease 状态精简为 reserved|attempting(terminal
  移出到 async-store)。
- #3(reconcile 跨 bot):reconcileIdempotencyLeasesOnBoot(ownerLarkAppId, currentBootId)
  显式传 owner,读写/close 前 fail-closed 过滤 record.ownerLarkAppId,跳过 current boot。
- #4(reconcile 在 bind 之后):移到 setActiveSessionsRegistry 之后、startIpcServer 之前
  (daemon.ts)。返回 quarantine Set 传入 restoreActiveSessions,被 terminalize 的 session
  排除 re-attach(防状态/执行面分叉)。
- #5(本 boot 失败留坏 lease):barrier 前失败 compareAndRemove 释放 reserved(重试可全新);
  barrier 后 dispatch 同步 throw → recordFailedStrict + close(durable failed,不重派)。
- #7(HTTP 契约):trigger status mapper 加 idempotency_conflict→409;idempotent 的
  state:failed 视作 200(成功 HTTP 调用报终态,非请求错误)。
- 所有 claim/takeover/transition/compareAndRemove 走同一 per-key withFileLockSync(rename
  只原子替换≠CAS,必须锁内 read→校验→写)。withKeyLock/ensureDir 保证 .lock 父目录存在。

验证:pnpm build 通过。测试真穿状态机崩溃点——idempotency-store 16(含 stale-rev1 竞争 /
corrupt fail-closed / compareAndRemove CAS);trigger-session-idempotency 12(真 store:
attempting-orphan→async failed+close+quarantine / reserved-orphan→删+close / completed 留 /
current-boot 跳过 / **OTHER-owner 跨 bot 零触碰**);trigger-api 校验+范围拒绝;async-store/
state/api-only-wiring(readiness 序不变) 全绿。affected+shared-path 11 套件 327/327 绿。
docs-site build 绿。不带 key 的普通 trigger/webhook 行为零变化。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
四处收口,全部 fail-closed / owner 正向背书:

1. attempt-barrier 失败释放:compareAndRemove 改返回判别式结果
   (removed|absent|changed),不再吞 false/异常。干净移除→重试全新;
   changed→attempting(rename 落盘后 fsync 抛,即已跨越的 commit-unknown
   fence)→durable recordFailedStrict 并返回**可观测 state:failed**(非裸
   5xx);compareAndRemove 抛(EIO/损坏)→诚实 5xx,lease 留给下轮 reconcile。
   另:resolveIdempotencyHit 改以 LIVE-ness(而非 ownerBootId)判定"真正在飞":
   attempting/reserved + 同 boot + 无 live worker → terminal,杜绝同 boot 无限复用。

2. boot reconcile:compareAndRemoveByPath 返回判别式结果;对 changed→attempting
   重分类为已跨越 fence(durable terminalize,绝不删),changed→current boot 跳过
   (在飞),其余不可证明收敛→fail-closed 抛。store 侧锁内二次读取损坏由折成
   false 改为 THROW。

3. 跨 bot owner 校验:async 终态证据仅在 asyncRec.ownerLarkAppId === lease owner
   时采信(foreign completed/failed 一律忽略,修 A 采信 B 终态压制 A dispatch 的
   确定性复现);session 读取由 getSession 改 getOwnedSession(不再跨 bot 文件回退
   泄漏 chatId);terminalizeAttempting 遇 foreign-owned async 槽位跳过而非抛,避免
   把 finding #4 的跨 bot 启动 DoS 形状重新引入。

4. 存储布局 owner 分区:idempotency/<sha256(owner)>/<keyHash>.json;listAll 改
   listAllForOwner 只枚举本 owner 子目录。任一 foreign/未知 owner 坏文件不再阻断
   本 bot 启动;本 owner 坏文件仍 throwOnCorrupt fail-closed。该文件从未进过任何
   已发 tag、分支未并入 master,故无需迁移。

测试:idempotency-store 19、trigger-session-idempotency 20(补 #1 live-ness、
#2 CAS 重分类/损坏 abort/并发 takeover throw、#3 foreign-completed/failed、
#4 foreign-corrupt 不阻断)、e2e 9(补 #1 barrier pre-rename/post-rename/EIO
真穿 triggerSessionTurn 故障注入)。affected+shared 204/204 绿,pnpm build 绿,
unit project 13132/13133(唯一 1 例为并发满载下的既有 timing flake,孤立运行
32/32 绿,与本改动无关)。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
round-6 finding #1 的 worker-exit 收敛只挂了 onWorkerExit,漏了 onCliExit:
persistent-pane / codex-app 的 managed CLI 可在 Node worker 仍存活时退出,此时
onWorkerExit 不触发。未完成的幂等 async turn 若其 CLI 无 final_output 退出,
trigger-result 仍 poll running、同键重试仍 reuse 坏 generation,直到下轮 reconcile。

改法:onCliExit 回调同样调 convergeIdempotentAsyncTurnOnWorkerExit(generation
门控 + completed 已清戳 → 幂等,且对 onCliExit/onWorkerExit 双回调竞态安全)。
回调签名 _ds→ds 后同步更新源码锁测试,并加一条断言两个回调都接了收敛。

验证:affected+shared+recovery 全绿(346/346 相关套件),pnpm build 绿。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
…locker)

codex 二轮 review(4878071011)7 blocker,按其拍定的 v3 设计重写。核心:lease 只管
「是否允许派发」,async-trigger-store 管「调用方看到的终态」——两者职责分离,不再靠
第三份 tombstone/index,也不靠 closeSession 成功来定义业务终态。

- #6(最核心,terminal 不接进 trigger-result):async-trigger-store 扩 status
  pending|completed|**failed**(failed 带 errorCode:no_output, reason:dispatch_unknown)。
  新增 recordFailedStrict(per-session withFileLockSync + atomicWriteFileSync durable + 抛错,
  与 recordCompleted 同锁串行,completed 更强证据恒胜)。resolveAsyncTriggerState 新增
  durable-failed 分支(优先级 completed > failed > closed > pending)——即使 reconcile 的
  closeSession 抛错、session 保持 open,trigger-result 也收敛 failed,不永久 running。
- #1(replace 非原子撕 tombstone):idempotency-store 全部改 atomicWriteFileSync(tmp+fsync
  +rename,失败保留旧文件),干掉 unlink→link。
- #2(takeover 非精确 CAS + 丢 won/existing):takeover 返回 {won|existing},锁内对完整
  immutable identity(owner+boot+session+trigger+requestHash+revision)精确校验;stale rev1
  不能覆盖 fresh winner rev1(新增回归测试)。lease 状态精简为 reserved|attempting(terminal
  移出到 async-store)。
- #3(reconcile 跨 bot):reconcileIdempotencyLeasesOnBoot(ownerLarkAppId, currentBootId)
  显式传 owner,读写/close 前 fail-closed 过滤 record.ownerLarkAppId,跳过 current boot。
- #4(reconcile 在 bind 之后):移到 setActiveSessionsRegistry 之后、startIpcServer 之前
  (daemon.ts)。返回 quarantine Set 传入 restoreActiveSessions,被 terminalize 的 session
  排除 re-attach(防状态/执行面分叉)。
- #5(本 boot 失败留坏 lease):barrier 前失败 compareAndRemove 释放 reserved(重试可全新);
  barrier 后 dispatch 同步 throw → recordFailedStrict + close(durable failed,不重派)。
- #7(HTTP 契约):trigger status mapper 加 idempotency_conflict→409;idempotent 的
  state:failed 视作 200(成功 HTTP 调用报终态,非请求错误)。
- 所有 claim/takeover/transition/compareAndRemove 走同一 per-key withFileLockSync(rename
  只原子替换≠CAS,必须锁内 read→校验→写)。withKeyLock/ensureDir 保证 .lock 父目录存在。

验证:pnpm build 通过。测试真穿状态机崩溃点——idempotency-store 16(含 stale-rev1 竞争 /
corrupt fail-closed / compareAndRemove CAS);trigger-session-idempotency 12(真 store:
attempting-orphan→async failed+close+quarantine / reserved-orphan→删+close / completed 留 /
current-boot 跳过 / **OTHER-owner 跨 bot 零触碰**);trigger-api 校验+范围拒绝;async-store/
state/api-only-wiring(readiness 序不变) 全绿。affected+shared-path 11 套件 327/327 绿。
docs-site build 绿。不带 key 的普通 trigger/webhook 行为零变化。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
四处收口,全部 fail-closed / owner 正向背书:

1. attempt-barrier 失败释放:compareAndRemove 改返回判别式结果
   (removed|absent|changed),不再吞 false/异常。干净移除→重试全新;
   changed→attempting(rename 落盘后 fsync 抛,即已跨越的 commit-unknown
   fence)→durable recordFailedStrict 并返回**可观测 state:failed**(非裸
   5xx);compareAndRemove 抛(EIO/损坏)→诚实 5xx,lease 留给下轮 reconcile。
   另:resolveIdempotencyHit 改以 LIVE-ness(而非 ownerBootId)判定"真正在飞":
   attempting/reserved + 同 boot + 无 live worker → terminal,杜绝同 boot 无限复用。

2. boot reconcile:compareAndRemoveByPath 返回判别式结果;对 changed→attempting
   重分类为已跨越 fence(durable terminalize,绝不删),changed→current boot 跳过
   (在飞),其余不可证明收敛→fail-closed 抛。store 侧锁内二次读取损坏由折成
   false 改为 THROW。

3. 跨 bot owner 校验:async 终态证据仅在 asyncRec.ownerLarkAppId === lease owner
   时采信(foreign completed/failed 一律忽略,修 A 采信 B 终态压制 A dispatch 的
   确定性复现);session 读取由 getSession 改 getOwnedSession(不再跨 bot 文件回退
   泄漏 chatId);terminalizeAttempting 遇 foreign-owned async 槽位跳过而非抛,避免
   把 finding #4 的跨 bot 启动 DoS 形状重新引入。

4. 存储布局 owner 分区:idempotency/<sha256(owner)>/<keyHash>.json;listAll 改
   listAllForOwner 只枚举本 owner 子目录。任一 foreign/未知 owner 坏文件不再阻断
   本 bot 启动;本 owner 坏文件仍 throwOnCorrupt fail-closed。该文件从未进过任何
   已发 tag、分支未并入 master,故无需迁移。

测试:idempotency-store 19、trigger-session-idempotency 20(补 #1 live-ness、
#2 CAS 重分类/损坏 abort/并发 takeover throw、#3 foreign-completed/failed、
#4 foreign-corrupt 不阻断)、e2e 9(补 #1 barrier pre-rename/post-rename/EIO
真穿 triggerSessionTurn 故障注入)。affected+shared 204/204 绿,pnpm build 绿,
unit project 13132/13133(唯一 1 例为并发满载下的既有 timing flake,孤立运行
32/32 绿,与本改动无关)。

Co-Authored-By: Claude <noreply@anthropic.com>
deepcoldy added a commit that referenced this pull request Aug 7, 2026
round-6 finding #1 的 worker-exit 收敛只挂了 onWorkerExit,漏了 onCliExit:
persistent-pane / codex-app 的 managed CLI 可在 Node worker 仍存活时退出,此时
onWorkerExit 不触发。未完成的幂等 async turn 若其 CLI 无 final_output 退出,
trigger-result 仍 poll running、同键重试仍 reuse 坏 generation,直到下轮 reconcile。

改法:onCliExit 回调同样调 convergeIdempotentAsyncTurnOnWorkerExit(generation
门控 + completed 已清戳 → 幂等,且对 onCliExit/onWorkerExit 双回调竞态安全)。
回调签名 _ds→ds 后同步更新源码锁测试,并加一条断言两个回调都接了收敛。

验证:affected+shared+recovery 全绿(346/346 相关套件),pnpm build 绿。

Co-Authored-By: Claude <noreply@anthropic.com>
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