Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion docs-site/docs/en/api-core-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ Optional env vars / flags (`--flag` wins over the env var):
| `--cli` | `BOTMUX_CORE_CLI` | `codex-app` | Which CLI to run (`codex` / `claude-code` / …) |
| `--working-dir` | `BOTMUX_CORE_WORKING_DIR` | cwd | CLI working directory |
| `--state-dir` | `BOTMUX_CORE_STATE_DIR` | `~/.botmux/core-only/<botId>/data` | Dedicated state root |
| `--backend` | `BOTMUX_CORE_BACKEND` | `tmux` | `tmux` or `pty`; PTY has no cross-daemon pane recovery |
| (no flag) | `BOTMUX_CORE_MODEL` | unset | Override the synthetic bot's default model (env-only; no matching flag) |

> **Visible TUI vs structured return**: `--cli codex-app` uses the app-server runner — structured return, no visible terminal; `--cli codex` (or `claude-code`) runs a visible TUI in a tmux pane you can watch/operate via the web terminal (see [§6](#6-writable-web-terminal)).
Expand All @@ -60,7 +61,7 @@ The daemon **binds the port first, then completes durable restore**. So "port ac
- not ready → `503 {"ok":false,"status":"starting"}`
- ready → `200 {"ok":true}`

The readiness barrier **also gates the public control routes**: while restore is incomplete, `/api/trigger`, `/api/sessions/:id/trigger-result`, and `/api/sessions/:id/insight` also return `503 {status:'starting'}` — so even a client that skips the `/healthz` probe cannot trigger into a racing restore. **Recommended: poll `/healthz` until 200 before the first trigger.**
The readiness barrier **also gates the public control routes**: while restore is incomplete, `/api/trigger`, `/api/sessions/:id/trigger-result`, `/api/sessions/:id/insight`, and `/api/sessions/:id/host-facts` also return `503 {status:'starting'}` — so even a client that skips the `/healthz` probe cannot trigger into a racing restore. **Recommended: poll `/healthz` until 200 before the first trigger.**

---

Expand All @@ -75,6 +76,7 @@ core-only's IPC routes are not a "public vs everything-HMAC" split — there are
| `/api/trigger` | POST | Start a turn |
| `/api/sessions/:id/trigger-result` | GET | Poll the final result (four states) |
| `/api/sessions/:id/insight` | GET | Poll conversation / progress |
| `/api/sessions/:id/host-facts` | GET | Read safe session-host facts |
| `/healthz` | GET | Readiness probe (core-only alias) |

(`/__health` is also **permanently public** in every mode, but it is a **legacy liveness probe that always returns 200** — it is **not** equivalent to `/healthz`: `/healthz` returns `503 {status:'starting'}` until restore/attach/scheduler finish, i.e. it is the core-only **readiness barrier**. To decide "may I start triggering", you **must** use `/healthz`; using `/__health` would read as ready-when-not and trigger too early into a racing restore.)
Expand Down Expand Up @@ -171,6 +173,18 @@ curl -s "http://127.0.0.1:8930/api/sessions/<uuid>/trigger-result"

> **Completion mechanism**: `trigger-result` flips to `completed` when botmux extracts final_output from the CLI transcript. core-only claude-code once had a bug where, after the first-turn ready-gate timed out and fell back, the persisted user line was head-truncated → the completion signal never bound → permanent `running`. Fixed in **v3.9.0** (suffix-anchored content proof to bind the durable mark). Use **v3.9.0 or later**.

### Session-host facts

An embedding application that treats Botmux as a real CLI session host can read:

```bash
curl -s "http://127.0.0.1:8930/api/sessions/<uuid>/host-facts"
```

This core-only route exposes the session id, CLI, backend, `exists / missing / unknown` liveness, worker generation, the CLI native session/thread id, and Botmux's current logical turn id. It never exposes a tmux socket, PID, worker token, environment, terminal-write capability, or CLI native turn id.

A CLI turn ending and an embedding application's business completion are separate facts. A Workflow caller must still validate and persist its own result before advancing its state machine. Never interpret `unknown` as `missing` or create a replacement session from it.

---

## 6. Writable web terminal
Expand Down Expand Up @@ -212,6 +226,7 @@ core-only is "no Feishu outbound + single-tenant loopback", with a ring of delib
- **No Feishu Client**: an apiOnly bot never constructs a `Lark.Client`; `larkAppSecret` is **withheld** from workers (not injected into the child env).
- **Config authority**: ignores `~/.botmux/bots.json` and `BOTS_CONFIG`, and the entrypoint **deletes** `process.env.BOTS_CONFIG` — so a forked worker can't `cat $BOTS_CONFIG` to read a real fleet's sibling credentials.
- **State isolation**: the entrypoint **freezes** `SESSION_DATA_DIR` to a dedicated `~/.botmux/core-only/<botId>/data` — a managed turn carrying the host's `SESSION_DATA_DIR` can't point core-only at the real fleet's sessions/pid/descriptor.
- **Host-secret isolation**: the core-only dispatch-report signing secret lives inside its dedicated `SESSION_DATA_DIR`; an explicit `--state-dir` never writes a new secret into its parent or reads the same-HOME fleet's shared secret. For historical core-only state only, the old leaf is migrated when it exists and passes strict host-credential validation; an absent old leaf creates a new in-state secret, while an unsafe old leaf fails closed.
- **Loopback freeze**: `BOTMUX_WORKER_HTTP_HOST` and `WEB_EXTERNAL_HOST` are both frozen to `127.0.0.1` (bind and advertised host agree), so the worker web server is never exposed on all interfaces.
- **Skips host maintenance**: core-only does not run fleet-level auto-restart / `botmux restart` / shared-HOME breadcrumb writes; it never touches the global botmux install on the same machine.
- **Auth is still the hard gate**: loopback is connectivity, not identity — same-machine processes (incl. bwrap sandboxes, which normally share the network namespace) can also dial `127.0.0.1`. So beyond §3's Layer-1 three control routes + `/healthz`/`/__health`, every route is authenticated: Layer 2 by a per-session capability / independent strong signature verified in the handler, and Layer-3 host/operator routes by the §4 route+port-bound HMAC. No layer is "bare loopback is enough".
Expand All @@ -226,6 +241,7 @@ core-only is "no Feishu outbound + single-tenant loopback", with a ring of delib
| `/api/trigger` | POST | public (layer 1) | Start a turn (must set an HTTP response mode) |
| `/api/sessions/:id/trigger-result` | GET | public (layer 1) | Poll the final result (four states); core-only live worker also carries `readOnlyUrl`+`viewToken` |
| `/api/sessions/:id/insight` | GET | public (layer 1) | Poll conversation / progress |
| `/api/sessions/:id/host-facts` | GET | public (layer 1) | Read safe session/CLI/backend/liveness facts; grants no terminal write access |
| `/api/sessions/:id/write-link` | GET | **HMAC + bind** (layer 3) | Get the writable terminal URL (fetch on open) |
| `/api/sessions/:id` | GET | **HMAC + bind** (layer 3) | Session metadata |
| `/api/sessions/:id/close` | POST | HMAC + bind (layer 3) **or** in-session capability (layer 2) | Cancel / close the session |
Expand Down
20 changes: 18 additions & 2 deletions docs-site/docs/zh/api-core-only.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ BOTMUX_CORE_ONLY=1 BOTMUX_API_PORT=8930 node <pkg>/dist/index-core-only.js
| `--cli` | `BOTMUX_CORE_CLI` | `codex-app` | 跑哪个 CLI(`codex` / `claude-code` / …) |
| `--working-dir` | `BOTMUX_CORE_WORKING_DIR` | 当前目录 | CLI 工作目录 |
| `--state-dir` | `BOTMUX_CORE_STATE_DIR` | `~/.botmux/core-only/<botId>/data` | 专用状态根 |
| `--backend` | `BOTMUX_CORE_BACKEND` | `tmux` | `tmux` 或 `pty`;PTY 不提供跨 daemon pane 恢复 |
| (无 flag) | `BOTMUX_CORE_MODEL` | 未设 | 覆盖合成 bot 的默认模型(仅环境变量,无对应 flag) |

> **可视 TUI vs 结构化返回**:`--cli codex-app` 走 app-server runner,结构化返回、无可视终端;`--cli codex`(或 `claude-code`)在 tmux pane 里跑可视 TUI,可经 Web 终端围观/操作(见 [§6](#6-可写-web-终端))。
Expand All @@ -59,7 +60,7 @@ daemon **先 bind 端口、后完成 durable restore**。所以「端口能连
- 未就绪 → `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。**
就绪屏障同时作用于**公共控制路由**:restore 未完成时,`/api/trigger`、`/api/sessions/:id/trigger-result`、`/api/sessions/:id/insight`、`/api/sessions/:id/host-facts` 也返回 `503 {status:'starting'}`——所以即使你的客户端跳过 `/healthz` 探针,也不会触发进一条正在竞态恢复的路径。**推荐 launcher 先轮询 `/healthz` 到 200 再发第一条 trigger。**

---

Expand All @@ -74,11 +75,12 @@ core-only 的 IPC 路由不是「公共 vs 全部 HMAC」二分,而是**三层
| `/api/trigger` | POST | 发起一轮任务 |
| `/api/sessions/:id/trigger-result` | GET | 轮询最终结果(四态) |
| `/api/sessions/:id/insight` | GET | 轮询对话/进度 |
| `/api/sessions/:id/host-facts` | GET | 读取安全的会话宿主事实 |
| `/healthz` | GET | 就绪探针(core-only 别名) |

(另有 `/__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)。
这些控制与只读路由的免签是 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」,选不了别的会话。集成方通常不直接调这层。

Expand Down Expand Up @@ -170,6 +172,18 @@ curl -s "http://127.0.0.1:8930/api/sessions/<uuid>/trigger-result"

> **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 及以上**。

### 会话宿主事实

把 Botmux 当作真实 CLI 会话宿主的集成方,可以读取:

```bash
curl -s "http://127.0.0.1:8930/api/sessions/<uuid>/host-facts"
```

它只对 core-only 的 HTTP virtual session 公开,返回 session id、CLI、后端、`exists / missing / unknown` 三态存活结论、worker generation、CLI 原生 session/thread id 与 Botmux 当前逻辑 turn id。它不返回 tmux socket、PID、worker token、环境变量、终端写 capability 或 CLI native turn id。

CLI 回合结束和集成方的业务完成是不同事实;Workflow 调用方仍应自行校验结果、持久化证据并推进状态机。`unknown` 不能当作 `missing`,也不能据此创建替代会话。

---

## 6. 可写 Web 终端
Expand Down Expand Up @@ -211,6 +225,7 @@ core-only 是「无飞书出站通道 + 单租户 loopback」,围绕这点有
- **无飞书 Client**:apiOnly bot 根本不构造 `Lark.Client`,`larkAppSecret` 对 worker **withheld**(不注入子进程环境)。
- **配置权威**:忽略 `~/.botmux/bots.json` 与 `BOTS_CONFIG`,且入口**删除** `process.env.BOTS_CONFIG`——避免 fork 出的 worker 里 agent `cat $BOTS_CONFIG` 读到真实 fleet 的 sibling 凭证。
- **状态隔离**:入口**冻结** `SESSION_DATA_DIR` 到专用 `~/.botmux/core-only/<botId>/data`——一个把 host 的 `SESSION_DATA_DIR` 带进来的 managed turn 无法让 core-only 去读真实 fleet 的会话/pid/descriptor。
- **宿主密钥隔离**:core-only 的 dispatch-report 签名密钥保存在专用 `SESSION_DATA_DIR` 内;显式 `--state-dir` 不会把新密钥写入其父目录,也不会读取同 HOME fleet 的共享签名密钥。为兼容历史 core-only 状态,仅当旧位置的叶子存在且通过严格宿主凭证校验时才迁移;缺失时直接在 state-dir 创建新密钥,不安全旧叶子则 fail closed。
- **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 就放行」。
Expand All @@ -225,6 +240,7 @@ core-only 是「无飞书出站通道 + 单租户 loopback」,围绕这点有
| `/api/trigger` | POST | 公共(第一层) | 发起一轮任务(须带 HTTP 应答模式) |
| `/api/sessions/:id/trigger-result` | GET | 公共(第一层) | 轮询最终结果(四态);core-only live worker 附 `readOnlyUrl`+`viewToken` |
| `/api/sessions/:id/insight` | GET | 公共(第一层) | 轮询对话/进度 |
| `/api/sessions/:id/host-facts` | GET | 公共(第一层) | 读取安全的会话、CLI、后端与三态存活事实;不授予终端写入 |
| `/api/sessions/:id/write-link` | GET | **HMAC + bind**(第三层) | 取可写终端 URL(建议打开时现取) |
| `/api/sessions/:id` | GET | **HMAC + bind**(第三层) | 会话元信息 |
| `/api/sessions/:id/close` | POST | HMAC + bind(第三层)**或**会话内 capability(第二层) | 取消/关闭会话 |
Expand Down
8 changes: 8 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3129,6 +3129,14 @@ function maybeSynthesizeCoreOnlyConfig(): BotConfig[] | null {
const cliId = process.env.BOTMUX_CORE_CLI || 'codex-app';
const entry: Record<string, unknown> = { larkAppId, apiOnly: true, cliId };
if (process.env.BOTMUX_CORE_WORKING_DIR) entry.workingDir = process.env.BOTMUX_CORE_WORKING_DIR;
// Core-only keeps tmux as the default. PTY is an explicit opt-in for embedded
// hosts that cannot establish a reliable tmux server; it is intentionally
// limited to this synthetic apiOnly Bot and never changes fleet defaults.
const coreBackend = process.env.BOTMUX_CORE_BACKEND;
if (coreBackend !== undefined && coreBackend !== 'tmux' && coreBackend !== 'pty') {
throw new Error(`Core-only BOTMUX_CORE_BACKEND must be tmux or pty, got: ${coreBackend}`);
}
if (coreBackend) entry.backendType = coreBackend;
if (process.env.BOTMUX_CORE_MODEL) entry.model = process.env.BOTMUX_CORE_MODEL;
if (process.env.BOTMUX_CORE_CODEX_AUTH_SYNC === 'isolated') entry.codexAuthSync = 'isolated';
// Route through the normal parser so the synthesized entry gets identical
Expand Down
8 changes: 7 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,7 @@ function ensureUniqueBotProcessNames(bots: any[]): void {
async function cmdServe(args: string[]): Promise<void> {
const apiOnly = args.includes('--api-only');
if (!apiOnly) {
console.error('Usage: botmux serve --api-only [--port <PORT>] [--bot <local_slug>] [--cli <cliId>] [--state-dir <DIR>]');
console.error('Usage: botmux serve --api-only [--port <PORT>] [--bot <local_slug>] [--cli <cliId>] [--backend <tmux|pty>] [--state-dir <DIR>]');
console.error(' Only core-only (--api-only) serving is supported. It runs a headless HTTP');
console.error(' control-API service with no Feishu credentials (for riff sandbox / embedding).');
process.exit(2);
Expand All @@ -550,6 +550,11 @@ async function cmdServe(args: string[]): Promise<void> {
const cli = getOpt('--cli') ?? process.env.BOTMUX_CORE_CLI;
const workingDir = getOpt('--working-dir') ?? process.env.BOTMUX_CORE_WORKING_DIR;
const stateDir = getOpt('--state-dir') ?? process.env.BOTMUX_CORE_STATE_DIR;
const backend = getOpt('--backend') ?? process.env.BOTMUX_CORE_BACKEND;
if (backend !== undefined && backend !== 'tmux' && backend !== 'pty') {
console.error(`botmux serve --api-only: --backend must be tmux or pty; got: ${backend}`);
process.exit(2);
}

// Node: spawn `node dist/index-core-only.js`. Standalone binary: re-exec THIS
// binary with the hidden `__core-only` subcommand (no dist/ on disk). Same env
Expand All @@ -569,6 +574,7 @@ async function cmdServe(args: string[]): Promise<void> {
...(cli ? { BOTMUX_CORE_CLI: cli } : {}),
...(workingDir ? { BOTMUX_CORE_WORKING_DIR: workingDir } : {}),
...(stateDir ? { BOTMUX_CORE_STATE_DIR: stateDir } : {}),
...(backend ? { BOTMUX_CORE_BACKEND: backend } : {}),
};
// Never hand an ambient BOTS_CONFIG / legacy worker-host alias / ambient
// SESSION_DATA_DIR to the core-only child — the entrypoint freezes/strips
Expand Down
Loading
Loading