Core-only(apiOnly)API 控制
把 botmux 当作一个受控的 HTTP 服务嵌入你自己的系统:一个进程、固定端口、绑定
127.0.0.1、不需要任何飞书凭证 / bots.json / pm2 / dashboard。你的编排器(任务 runner、CI、后端服务、沙箱)通过 HTTP 驱动它跑 CLI(codex / claude-code 等)、轮询结果、按需拿到可写 Web 终端。
本文面向把 botmux 嵌入自己产品的集成方(例如在沙箱里内嵌一个 botmux 跑 codex)。如果你只是想让外部系统触发一个已在飞书群里运行的普通机器人,请看 API 编程式触发任务——那篇讲的是触发/轮询/取消的四态契约,本文与之互补:同一套 trigger/poll 契约在 core-only 下同样适用,本文只讲 core-only 特有的启动、鉴权、路由分界、可写终端与安全边界。
1. core-only 与普通 fleet 的区别
核心一句话:core-only 是一个 headless、单租户、纯 loopback 的 botmux。它保留完整的 daemon IPC 契约,但去掉了飞书这一侧。
2. 启动
两种等价启动方式:
可选环境变量 / 参数(--flag 优先于环境变量):
可视 TUI vs 结构化返回:
--cli codex-app走 app-server runner,结构化返回、无可视终端;--cli codex(或claude-code)在 tmux pane 里跑可视 TUI,可经 Web 终端围观/操作(见 §6)。
就绪契约
daemon 先 bind 端口、后完成 durable restore。所以「端口能连」不等于「可以触发」——必须等就绪:
- stdout ready line(锁定契约,正则
^\[core-only\] listening on):这一行只在 restore 完成后才打印。 GET /healthz(公共,免鉴权):- 未就绪 →
503 {"ok":false,"status":"starting"} - 就绪 →
200 {"ok":true}
- 未就绪 →
就绪屏障同时作用于公共控制路由:restore 未完成时,/api/trigger、/api/sessions/:id/trigger-result、/api/sessions/:id/insight 也返回 503 {status:'starting'}——所以即使你的客户端跳过 /healthz 探针,也不会触发进一条正在竞态恢复的路径。推荐 launcher 先轮询 /healthz 到 200 再发第一条 trigger。
3. 路由分界:三层鉴权
core-only 的 IPC 路由不是「公共 vs 全部 HMAC」二分,而是三层。集成方只需关心第一层;理解另两层能帮你正确判断威胁面。
第一层 · 无凭证的 integrator surface(core-only 专属,免 HMAC)——集成 botmux 你只用这几条:
(另有 /__health 也永久公开、任何模式都免鉴权,但它是 legacy liveness 探针、始终返 200——不是 /healthz 的等价物:/healthz 在 restore/attach/scheduler 完成前返 503 {status:'starting'},是 core-only 的 readiness barrier。判断「能不能开始 trigger」必须用 /healthz;用 /__health 会误判成已就绪、过早 trigger 进正在竞态恢复的路径。)
这三条控制路由的免签是 core-only 专属的紧致 allowlist——刻意收窄:早期「全部路由免鉴权」会让同机 co-resident 的模型 turn 读写会话/调度/发起变更。/api/asks/answer 刻意不在内(askId 为键、无会话绑定,暴露会让同机 turn 劫持别的待答 ask)。
第二层 · 内部 capability / 签名路由(绕外层 trusted-host HMAC,但各由 handler 自证)——这些不是 public,但也不要求本文 §4 那种 trusted-host HMAC;它们由会话内 rotating per-turn capability(绑定到 URL 里的 sessionId)或独立的强签名协议在 handler 内验证。典型:POST /api/session-ready、POST /api/asks、POST /api/sessions/:id/{slash,cd,close,chat-rename}、POST /api/hooks/emit、POST /api/attention、POST /api/vc-meetings/action-request、workflow v3 变更前缀。合法调用方是会话内的 CLI 自身(沙箱/读隔离下读不到 host secret),capability 只证明「我是这个会话当前这轮的 CLI」,选不了别的会话。集成方通常不直接调这层。
第三层 · host / operator 路由(需 §4 的 route+port-bound HMAC)——其余全部路由,包括你要的可写终端 GET /api/sessions/:id/write-link、GET /api/sessions/:id(会话元信息)等。下一节讲怎么正确签名。
注意
POST /api/sessions/:id/close:既可由外部 host caller 用 §4 的 HMAC 调用,也存在第二层的 per-session capability 通道(会话内 CLI 自关)——它不属于「其余全部只认 HMAC」那一类。
4. HMAC 签名(最容易踩的坑:bind)
需签名路由用 loopback HMAC。请求带三个 header:
密钥是 ~/.botmux/.dashboard-secret 里的原始 43 字符 base64url 字符串——直接当 HMAC key 用,不要再 base64 解码成 bytes。
⚠️ 关键:msg 必须带 bind
core-only 对第三层 host/operator 路由(§3 分层里的第三层,如 write-link)在 server 层先走一道带 bind 的校验,通过后才进 route 处理器。(第一层公共路由不验签;第二层内部路由绕过这道、由 handler 自己的 capability/独立签名验证——都不是这里说的 bind HMAC。)bind 把签名绑定到「方法 + 路径 + 端口」,防止一个签名被重放到别的路由/端口。
三个易错点:
port是服务实际 bound 的端口(如8930),不是 Host 头里的值——verifier 用它自己 bind 的端口重建 bind。pathname不含 query string(用URL().pathname)。secret用原始字符串当 key,别解码。
症状对照:如果你只签了 bare
ts:nonce(漏了 bind),会得到 HTTP 401{reason:"sig_mismatch"}。因为公共路由(trigger-result 等)压根不验签,这个 bind 问题往往第一次在 write-link 上才暴露——很容易误判成 secret/时窗错。
参考实现
仓库内 src/core/daemon-ipc-auth.ts 的 daemonIpcAuthHeaders() 就是权威实现,直接照搬其逻辑:
5. 触发与轮询
触发/轮询/取消的四态契约与普通 bot 完全一致,详见 API 编程式触发任务。core-only 下的差异只有:
- 必须用 HTTP 应答模式:apiOnly bot 的 trigger 请求必须带
options.waitForFinalOutput(同步)或options.asyncReturnSessionId(异步),否则400 bad_request(apiOnly 没有飞书群可回复,必须走 HTTP 拿结果)。 - 异步触发返回一个合成
http_async_*chatId + 真实sessionId;用该sessionId轮询trigger-result。 - 用户指令放在顶层
instruction字段(渲染成 trusted<botmux_task>)——不是prompt。 options.model/options.reasoningEffort对 codex 家族和 grok 的 fresh trigger 生效。- 轮询
trigger-result用完整 sessionId(UUID),不要用日志里的[worker:xxxxxxxx]短 tag——短 tag ≠ sessionId,会session_not_found。
completion 机制:trigger-result 翻
completed依赖 botmux 从 CLI transcript 里抽取 final_output。core-only claude-code 曾有一个「首轮 ready-gate 超时回落后落盘 user 行截头 → 完成信号接不上 → 永久 running」的 bug,已在 v3.9.0 修复(改用后缀锚定的内容证明绑定 durable mark)。用 v3.9.0 及以上。
6. 可写 Web 终端
core-only(tmux backend + 可视 CLI)可以对外提供一个可操作的 Web 终端。取链接走 GET /api/sessions/:id/write-link(需 HMAC 签名,见 §4):
- 返回的 URL 带写 token,打开即得可输入的 xterm(
readonly=false)。 - 只读版:
readableTerminalUrlFor/ 卡片里的只读链接带的是viewToken(只能看不能写)。 - backend 为
zmx时不支持 Web 终端(409 terminal_unsupported);tmux/pty 支持。
core-only 的 readOnlyUrl / viewToken 也随 trigger-result 下发
core-only 下,只要该 session 有存活的 worker 终端(workerPort 已绑 + view capability 已铸),公共的 GET /api/sessions/:id/trigger-result 响应会附带 readOnlyUrl + viewToken(只读入口,方便 riff 的 in-sandbox runner 直接打开可视 TUI)。这是 core-only 专属:普通/混合 fleet 的 trigger-result 不发射这两个字段(那边 trigger-result 是 HMAC 门、也不该把终端读能力塞进轮询响应);closed / 已恢复无 live worker 的 session 也不发射,所以不会广告出失效 URL。写 token 永远只经 §4 的 HMAC write-link 获取,绝不进 trigger-result。
⚠️ 建议「打开时现取」,而不是缓存 URL
token 本身是稳定的:production 的 view/write token 是 host dashboard secret + sessionId 的 domain-separated HMAC(deriveTerminalViewToken / deriveTerminalWriteToken,各用不同 domain 前缀),worker init 时 refreshTerminal*Token() 重算——同一 session 跨 worker / daemon restart 得到的是同一个 token(randomBytes 只是 secret 不可用时的 standalone/test fallback)。所以缓存的 token 不会因为 worker 换代就失效。
但仍建议每次「打开终端」时现调 write-link 拿当次 URL——理由不是 token 会变,而是 URL 的其它部分会变:代理端口 / 广告 host / 部署拓扑、以及 worker 当前是否可用(终端页要连上活着的 worker)。现取很轻(loopback + HMAC),能自动拿到与当前拓扑一致、且 worker 确实在线的 URL。
- ✅ 建议:只持久化稳定的 sessionId,「打开」时现取 URL。
- ⚠️ 若要缓存:token 部分可复用,但端口/host/worker 可用性变化会让旧 URL 连不上——现取更省心。
单个 core-only 会话不会被空闲回收(live-worker cap 默认 30、且无 idle 超时,单租户 1 个会话永远 ≤ cap),所以正常情况下 worker 常驻、终端持续可达。
7. 安全边界(no-transport)
core-only 是「无飞书出站通道 + 单租户 loopback」,围绕这点有一圈刻意的加固,集成时应知晓:
- 无飞书 Client:apiOnly bot 根本不构造
Lark.Client,larkAppSecret对 worker withheld(不注入子进程环境)。 - 配置权威:忽略
~/.botmux/bots.json与BOTS_CONFIG,且入口删除process.env.BOTS_CONFIG——避免 fork 出的 worker 里 agentcat $BOTS_CONFIG读到真实 fleet 的 sibling 凭证。 - 状态隔离:入口冻结
SESSION_DATA_DIR到专用~/.botmux/core-only/<botId>/data——一个把 host 的SESSION_DATA_DIR带进来的 managed turn 无法让 core-only 去读真实 fleet 的会话/pid/descriptor。 - loopback 冻结:
BOTMUX_WORKER_HTTP_HOST与WEB_EXTERNAL_HOST都被冻结成127.0.0.1(bind 与广告 host 一致),worker web server 不会暴露在所有网卡上。 - 跳过 host 维护:core-only 不跑 fleet 级的 auto-restart /
botmux restart/ 共享 HOME breadcrumb 写入,绝不触碰同机的全局 botmux 安装。 - 鉴权仍是硬门:loopback 只是连通性不是身份——同机(含 bwrap 沙箱,默认共享网络命名空间)的进程也能拨
127.0.0.1。所以除 §3 第一层的三条控制路由 +/healthz//__health外,其余路由都要鉴权:第二层由 handler 内的 per-session capability / 独立强签名验证,第三层 host/operator 路由要 §4 的 route+port-bound HMAC。没有任何一层是「裸 loopback 就放行」。
附:core-only 端点速查
- 三层鉴权模型见 §3;集成方只用第一层。
- 触发请求体结构、
errorCode、四态语义、重启存活保证:见 API 编程式触发任务。 - 参考签名实现:
src/core/daemon-ipc-auth.ts(daemonIpcAuthHeaders)。
