From 199a688d40cf1c5fd90c85b889d7adaab01188b4 Mon Sep 17 00:00:00 2001 From: Oliver-Ysq <849087045@qq.com> Date: Fri, 9 Oct 2026 16:29:30 +0800 Subject: [PATCH 1/7] feat(core-only): expose safe session host facts --- docs-site/docs/en/api-core-only.md | 17 +++++- docs-site/docs/zh/api-core-only.md | 20 ++++++- src/bot-registry.ts | 8 +++ src/cli.ts | 8 ++- src/core/core-only-host-facts.ts | 86 ++++++++++++++++++++++++++++ src/core/dashboard-ipc-server.ts | 55 +++++++++++++++++- src/core/dispatch-report-binding.ts | 55 +++++++++++++++++- src/daemon.ts | 22 +++---- src/types/proxy-from-env.d.ts | 11 ++++ test/api-only-mode-wiring.test.ts | 13 +++++ test/bot-registry.test.ts | 19 +++++- test/core-only-host-facts.test.ts | 58 +++++++++++++++++++ test/dispatch-report-binding.test.ts | 43 ++++++++++++++ tsconfig.scripts.json | 2 +- 14 files changed, 391 insertions(+), 26 deletions(-) create mode 100644 src/core/core-only-host-facts.ts create mode 100644 src/types/proxy-from-env.d.ts create mode 100644 test/core-only-host-facts.test.ts diff --git a/docs-site/docs/en/api-core-only.md b/docs-site/docs/en/api-core-only.md index 23bf155ab4..87ab5c7397 100644 --- a/docs-site/docs/en/api-core-only.md +++ b/docs-site/docs/en/api-core-only.md @@ -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//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)). @@ -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.** --- @@ -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.) @@ -171,6 +173,18 @@ curl -s "http://127.0.0.1:8930/api/sessions//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//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 @@ -226,6 +240,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 | diff --git a/docs-site/docs/zh/api-core-only.md b/docs-site/docs/zh/api-core-only.md index fee2cfc677..be5df37304 100644 --- a/docs-site/docs/zh/api-core-only.md +++ b/docs-site/docs/zh/api-core-only.md @@ -42,6 +42,7 @@ BOTMUX_CORE_ONLY=1 BOTMUX_API_PORT=8930 node /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//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-终端))。 @@ -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。** --- @@ -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」,选不了别的会话。集成方通常不直接调这层。 @@ -170,6 +172,18 @@ curl -s "http://127.0.0.1:8930/api/sessions//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//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 终端 @@ -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//data`——一个把 host 的 `SESSION_DATA_DIR` 带进来的 managed turn 无法让 core-only 去读真实 fleet 的会话/pid/descriptor。 +- **宿主密钥隔离**:core-only 的 dispatch-report 签名密钥保存在专用 `SESSION_DATA_DIR` 内;显式 `--state-dir` 不会把密钥写入其父目录,也不会读取同 HOME fleet 的共享签名密钥。 - **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 就放行」。 @@ -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(第二层) | 取消/关闭会话 | diff --git a/src/bot-registry.ts b/src/bot-registry.ts index da0029afe0..462d33c81a 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -3129,6 +3129,14 @@ function maybeSynthesizeCoreOnlyConfig(): BotConfig[] | null { const cliId = process.env.BOTMUX_CORE_CLI || 'codex-app'; const entry: Record = { 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 diff --git a/src/cli.ts b/src/cli.ts index c0987d450b..c1a75b7c15 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -530,7 +530,7 @@ function ensureUniqueBotProcessNames(bots: any[]): void { async function cmdServe(args: string[]): Promise { const apiOnly = args.includes('--api-only'); if (!apiOnly) { - console.error('Usage: botmux serve --api-only [--port ] [--bot ] [--cli ] [--state-dir ]'); + console.error('Usage: botmux serve --api-only [--port ] [--bot ] [--cli ] [--backend ] [--state-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); @@ -548,6 +548,11 @@ async function cmdServe(args: string[]): Promise { 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 @@ -567,6 +572,7 @@ async function cmdServe(args: string[]): Promise { ...(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 diff --git a/src/core/core-only-host-facts.ts b/src/core/core-only-host-facts.ts new file mode 100644 index 0000000000..73b6485c5b --- /dev/null +++ b/src/core/core-only-host-facts.ts @@ -0,0 +1,86 @@ +import type { BackendType, SessionProbe } from '../adapters/backend/types.js'; + +/** core-only 对外暴露的最小会话输入;刻意不包含 token、socket、Prompt 或环境变量。 */ +export interface CoreOnlyHostFactsInput { + /** Botmux 逻辑会话 id。 */ + readonly sessionId: string; + /** 会话持久化状态。 */ + readonly sessionStatus: 'active' | 'closed'; + /** 本次会话冻结的 CLI id;历史会话无法确认时为 null。 */ + readonly cli: string | null; + /** 本次会话冻结的后端;历史会话无法确认时为 null。 */ + readonly backend: BackendType | null; + /** CLI 原生 session/thread id;尚未形成或无法确认时为 null。 */ + readonly nativeSessionId: string | null; + /** Botmux 当前逻辑 turn id;它不是 CLI native turn id。 */ + readonly activeTurnId: string | null; + /** 当前 daemon 是否仍持有本会话 worker。 */ + readonly workerPresent: boolean; + /** worker 是否已完成当前 generation 的 init。 */ + readonly workerReady: boolean; + /** 当前 worker generation;未形成时为 null。 */ + readonly workerGeneration: number | null; + /** 持久后端的权威三态探测;非持久后端或无坐标时为 null。 */ + readonly backingProbe: SessionProbe | null; +} + +/** + * core-only 会话宿主事实。 + * + * 该结构只描述 Botmux 已观察到的会话事实,不表示模型任务完成,也不授予终端写权限。 + */ +export interface CoreOnlyHostFacts { + /** 固定协议版本;新增字段必须保持向后兼容。 */ + readonly protocolVersion: 1; + /** Botmux 逻辑会话 id。 */ + readonly sessionId: string; + /** 当前 CLI id;历史数据缺失时为 null。 */ + readonly cli: string | null; + /** 当前后端;历史数据缺失时为 null。 */ + readonly backend: BackendType | null; + /** Botmux 会话行状态。 */ + readonly sessionStatus: 'active' | 'closed'; + /** 会话宿主可证明的存活状态;unknown 绝不能被调用方当作 missing。 */ + readonly liveness: SessionProbe; + /** worker 生命周期事实;不含 PID、端口或访问 token。 */ + readonly worker: { + readonly present: boolean; + readonly ready: boolean; + readonly generation: number | null; + }; + /** CLI 原生身份与 Botmux 逻辑 turn;nativeTurnId 尚未跨 worker 安全发布。 */ + readonly native: { + readonly sessionId: string | null; + readonly activeTurnId: string | null; + readonly nativeTurnId: null; + }; +} + +/** + * 将 daemon 内部状态投影为可公开的只读事实。 + * + * 持久后端只信任它自己的三态探测;PTY 等非持久后端仅在 live worker 已 ready 时报告 + * exists,其余情况一律 unknown,避免把“当前没有 worker 引用”误称为底层进程已消失。 + */ +export function projectCoreOnlyHostFacts(input: CoreOnlyHostFactsInput): CoreOnlyHostFacts { + const liveness: SessionProbe = input.backingProbe + ?? (input.workerPresent && input.workerReady ? 'exists' : 'unknown'); + return { + protocolVersion: 1, + sessionId: input.sessionId, + cli: input.cli, + backend: input.backend, + sessionStatus: input.sessionStatus, + liveness, + worker: { + present: input.workerPresent, + ready: input.workerReady, + generation: input.workerGeneration, + }, + native: { + sessionId: input.nativeSessionId, + activeTurnId: input.activeTurnId, + nativeTurnId: null, + }, + }; +} diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index ca9a72231d..671c22476f 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -409,8 +409,9 @@ import { clearSessionPreviewTarget } from './session-preview-registry.js'; import { ChatRenameCooldown, ChatRenameSerialQueue, normalizeLarkChatName } from './chat-rename.js'; import { executeChatRename } from './chat-rename-operation.js'; import type { DaemonToWorker, ScheduledTask, ParsedSchedule, ScheduleExecutionPosition, Session } from '../types.js'; -import { sessionAnchorId, larkTransportEnabled, type DaemonSession } from './types.js'; -import { isRemoteBackendSession } from './persistent-backend.js'; +import { sessionAnchorId, isHttpVirtualSession, larkTransportEnabled, type DaemonSession } from './types.js'; +import { isRemoteBackendSession, persistentBackendTargetForSession, probePersistentBackendTarget } from './persistent-backend.js'; +import { projectCoreOnlyHostFacts } from './core-only-host-facts.js'; import { attachSkillPolicy, detachSkillPolicy } from './skills/im-command.js'; import { readSkillRegistry } from '../services/skill-registry-store.js'; import { isSessionGroup } from '../services/session-groups-store.js'; @@ -864,6 +865,7 @@ function routeHasPublicAccess(method: string, pathname: string): boolean { * POST /api/sessions/:id/turns/:triggerId/interrupt (stop exact turn) * GET /api/sessions/:id/trigger-result (poll final) * GET /api/sessions/:id/insight (poll conversation/progress) + * GET /api/sessions/:id/host-facts (read safe session-host facts) * `/api/asks/answer` is deliberately EXCLUDED — it is askId-keyed with no * session/turn binding, so exposing it would let any co-resident turn hijack * another pending ask (codex). riff's async main-link needs no awaiting_input; @@ -874,7 +876,8 @@ function routeIsCoreOnlyPublic(method: string, pathname: string): boolean { if (method === 'POST' && /^\/api\/sessions\/[^/]+\/turns\/[^/]+\/interrupt$/.test(pathname)) return true; if (method === 'GET') { return /^\/api\/sessions\/[^/]+\/trigger-result$/.test(pathname) - || /^\/api\/sessions\/[^/]+\/insight$/.test(pathname); + || /^\/api\/sessions\/[^/]+\/insight$/.test(pathname) + || /^\/api\/sessions\/[^/]+\/host-facts$/.test(pathname); } return false; } @@ -3727,6 +3730,52 @@ ipcRoute('GET', '/api/sessions/:sessionId/trigger-result', (req, res, params) => jsonRes(res, result.ok ? 200 : 400, result); }); +/** + * core-only 会话宿主事实:供嵌入方读取会话、CLI、后端、worker 与 native session 状态。 + * + * 路由只在 startIpcServer({ coreOnlyPublicRoutes:true }) 时免 HMAC;普通 fleet 仍需可信 + * host 鉴权。即使在 core-only 下也只允许本 daemon 的 apiOnly HTTP virtual session,且 + * 永不返回 tmux socket、worker token、PID、环境变量或任意终端写 capability。 + */ +ipcRoute('GET', '/api/sessions/:sessionId/host-facts', (_req, res, params) => { + const ds = findActiveBySessionId(params.sessionId); + const session = ds?.session ?? sessionStore.getOwnedSession(params.sessionId); + if (!session) return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); + if (!cachedLarkAppId || session.larkAppId !== cachedLarkAppId) { + return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); + } + let apiOnly = false; + try { apiOnly = getBot(cachedLarkAppId).config.apiOnly === true; } catch { /* fail closed below */ } + if (!apiOnly || !isHttpVirtualSession(session.chatId)) { + return jsonRes(res, 404, { ok: false, error: 'session_not_found' }); + } + + const backend = ds?.initConfig?.backendType ?? session.backendType ?? null; + let backingProbe: import('../adapters/backend/types.js').SessionProbe | null = null; + if (ds) { + try { + const target = persistentBackendTargetForSession(ds); + if (target) backingProbe = probePersistentBackendTarget(target); + } catch { + backingProbe = 'unknown'; + } + } + const workerPresent = !!ds?.worker && !ds.worker.killed && ds.worker.exitCode === null; + const facts = projectCoreOnlyHostFacts({ + sessionId: session.sessionId, + sessionStatus: session.status, + cli: ds?.initConfig?.cliId ?? session.cliLaunchSnapshot?.cliId ?? session.cliId ?? null, + backend, + nativeSessionId: session.cliSessionId ?? null, + activeTurnId: ds?.activeInteractiveTurn?.turnId ?? null, + workerPresent, + workerReady: workerPresent && ds?.workerReady === true, + workerGeneration: ds?.workerGeneration ?? session.workerGeneration ?? null, + backingProbe, + }); + return jsonRes(res, 200, { ok: true, facts }); +}); + ipcRoute('POST', '/api/sessions/:sessionId/trigger-result/supersede', async (req, res, params) => { const session = findOwnedSessionRecord(params.sessionId); if (!session || !cachedLarkAppId || session.larkAppId !== cachedLarkAppId) { diff --git a/src/core/dispatch-report-binding.ts b/src/core/dispatch-report-binding.ts index 7b3a4d8c36..add1702e59 100644 --- a/src/core/dispatch-report-binding.ts +++ b/src/core/dispatch-report-binding.ts @@ -1,5 +1,6 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { dirname, join } from 'node:path'; +import { readSecureHostFileSync, withSecureHostParentSync } from '../platform/secure-host-file.js'; const DOMAIN = 'botmux.dispatch-report-binding.v1'; @@ -22,8 +23,56 @@ export interface SignedDispatchReportBinding { signature: string; } -export function dispatchReportBindingSecretPath(dataDir: string): string { - return join(dirname(dataDir), '.dashboard-secret.report-binding'); +/** + * dispatch report 的宿主密钥位置。 + * + * 普通 fleet 保持历史位置:`/.dashboard-secret.report-binding`,使同一 + * fleet 的各 bot dataDir 共享一个签名域。core-only 的 stateDir 可以是任意显式绝对目录 + * (例如 `/tmp/`);若仍取其 parent,会把密钥落到 `/tmp` 这类非当前 + * 用户目录,严格宿主凭证校验应当且会拒绝。因此 core-only 将密钥限定在自己的 stateDir, + * 不读取或修改同 HOME fleet 的共享密钥。 + */ +export function dispatchReportBindingSecretPath( + dataDir: string, + options: { coreOnly?: boolean } = {}, +): string { + const coreOnly = options.coreOnly ?? process.env.BOTMUX_CORE_ONLY === '1'; + return coreOnly + ? join(dataDir, '.dashboard-secret.report-binding') + : join(dirname(dataDir), '.dashboard-secret.report-binding'); +} + +/** + * 读取 dispatch-report 宿主密钥,必要时为旧 core-only state-dir 做一次安全迁移。 + * + * 历史 core-only 把密钥放在 `dirname(dataDir)`;新路径改为 dataDir 内,避免显式 + * `/tmp/` 时写到共享 `/tmp`。为了保留已经签发的 report binding,新路径缺失 + * 时只迁移通过 secure-host-file 校验的旧叶子。旧路径缺失则创建新密钥;旧路径不安全 + * 时抛错,绝不为了兼容读取、删除或覆盖可疑凭证。 + */ +export function loadOrCreateDispatchReportBindingSecret( + dataDir: string, + options: { coreOnly?: boolean } = {}, +): string { + const coreOnly = options.coreOnly ?? process.env.BOTMUX_CORE_ONLY === '1'; + const currentPath = dispatchReportBindingSecretPath(dataDir, { coreOnly }); + return withSecureHostParentSync(currentPath, (parent) => parent.withLeafLock(() => { + const current = parent.readLeaf(256)?.trim(); + if (current) return current; + + if (coreOnly) { + const legacyPath = join(dirname(dataDir), '.dashboard-secret.report-binding'); + const legacy = readSecureHostFileSync(legacyPath, 256)?.trim(); + if (legacy) { + parent.writeLeaf(legacy); + return legacy; + } + } + + const secret = randomBytes(32).toString('base64url'); + parent.writeLeaf(secret); + return secret; + })); } function validDispatchRoot(value: string): boolean { diff --git a/src/daemon.ts b/src/daemon.ts index 02a9fae2ca..9be07ddf27 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -358,7 +358,7 @@ import { import { createDispatchReportBinding, resolveVerifiedDispatchReportTarget, - dispatchReportBindingSecretPath, + loadOrCreateDispatchReportBindingSecret, DISPATCH_REPORT_REGISTER_MAX_BYTES, DISPATCH_REPORT_REGISTER_ROUTE, } from './core/dispatch-report-binding.js'; @@ -3914,7 +3914,7 @@ async function dispatchUserForTurn(ds: DaemonSession, turnId: string) { && caller.requestLarkAppId === ds.larkAppId && caller.requestUserOpenId) return undefined; return resolveDispatchUser({ dataDir: config.session.dataDir, - secret: loadOrCreateDashboardSecret(dispatchReportBindingSecretPath(config.session.dataDir)), + secret: loadOrCreateDispatchReportBindingSecret(config.session.dataDir), appId: ds.larkAppId, chatId: ds.chatId, turnId, rootId: reply.rootMessageId ?? (ds.scope !== 'chat' ? ds.session.rootMessageId ?? undefined : undefined), }); @@ -4085,7 +4085,7 @@ async function reportZeroPromptFinal(ds: DaemonSession, input: { try { registry = JSON.parse(readFileSync(join(config.session.dataDir, 'orchestrate-dispatch.json'), 'utf8')); } catch { return; } const decision = prepareAutomaticDispatchReport({ - registry, bindingSecret: loadOrCreateDashboardSecret(dispatchReportBindingSecretPath(config.session.dataDir)), + registry, bindingSecret: loadOrCreateDispatchReportBindingSecret(config.session.dataDir), dispatchRoot: input.dispatchRoot, sourceSessionId: ds.session.sessionId, sourceLarkAppId: ds.larkAppId, content: input.content, }); @@ -4113,7 +4113,7 @@ function zeroPromptTaskContent(content: string, larkAppId: string, dispatchRoot? try { const registry = JSON.parse(readFileSync(join(config.session.dataDir, 'orchestrate-dispatch.json'), 'utf8')); const bound = resolveVerifiedDispatchReportTarget({ registry, dispatchRoot, - secret: loadOrCreateDashboardSecret(dispatchReportBindingSecretPath(config.session.dataDir)) }); + secret: loadOrCreateDispatchReportBindingSecret(config.session.dataDir) }); return bound.ok ? stripDispatchCompletionProtocol(content, dispatchRoot) : content; } catch { return content; } } @@ -6817,9 +6817,7 @@ ipcRoute('POST', DISPATCH_REPORT_REGISTER_ROUTE, async (req, res) => { }); } - const bindingSecret = loadOrCreateDashboardSecret( - dispatchReportBindingSecretPath(config.session.dataDir), - ); + const bindingSecret = loadOrCreateDispatchReportBindingSecret(config.session.dataDir); const issuedAt = new Date().toISOString(); const reportBinding = createDispatchReportBinding(bindingSecret, { dispatchRoot, @@ -6988,7 +6986,7 @@ ipcRoute('POST', DISPATCH_USER_DELIVERY_ROUTE, async (req, res) => { const messageId = authority && turnId && targetAppIds.length ? await deliverDispatchWithUser({ dataDir: config.session.dataDir, - secret: loadOrCreateDashboardSecret(dispatchReportBindingSecretPath(config.session.dataDir)), + secret: loadOrCreateDispatchReportBindingSecret(config.session.dataDir), payload: { sourceAppId: ds.larkAppId, sourceSessionId: ds.session.sessionId, sourceTurnId: turnId, rootId, chatId, targetAppIds, authority, @@ -7381,9 +7379,7 @@ ipcRoute('POST', REPORT_SESSION_RELAY_ROUTE, async (req, res) => { : undefined, selfLarkAppId: selfDaemonLarkAppId, registry, - bindingSecret: loadOrCreateDashboardSecret( - dispatchReportBindingSecretPath(config.session.dataDir), - ), + bindingSecret: loadOrCreateDispatchReportBindingSecret(config.session.dataDir), }); if (!decision.ok) { return jsonRes(res, decision.status, { ok: false, error: decision.error }); @@ -28676,9 +28672,7 @@ export async function startDaemon(botIndex?: number): Promise { // Linux bwrap snapshots `.dashboard-secret.*` authority leaves at launch; // creating this later would leave the new key visible through its writable // host-root bind until the pane cold-started. - loadOrCreateDashboardSecret( - dispatchReportBindingSecretPath(config.session.dataDir), - ); + loadOrCreateDispatchReportBindingSecret(config.session.dataDir); let markIpcReady!: () => void; const ipcReady = new Promise((resolve) => { markIpcReady = resolve; }); const coreOnly = process.env.BOTMUX_CORE_ONLY === '1'; diff --git a/src/types/proxy-from-env.d.ts b/src/types/proxy-from-env.d.ts new file mode 100644 index 0000000000..b023b7a6c7 --- /dev/null +++ b/src/types/proxy-from-env.d.ts @@ -0,0 +1,11 @@ +/** + * 本仓库已在 devDependencies 声明 @types/proxy-from-env;部分共享开发依赖目录未安装该 + * 纯类型包时,仍需让生产构建保持可复现。此声明与 @types/proxy-from-env@1.0.4 的公开 + * `getProxyForUrl` 签名一致,不会改变 proxy-from-env 的任何运行时加载或行为。 + */ +declare module 'proxy-from-env' { + import { Url } from 'node:url'; + + /** 根据环境变量和 URL 返回代理地址;未配置代理时返回空字符串。 */ + export function getProxyForUrl(url: string | Url): string; +} diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index 6f73d582cd..e90aa43a72 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -767,6 +767,7 @@ describe('core-only entrypoint hardening (codex 4 P1s — source lock)', () => { expect(allow).toContain("pathname === '/api/trigger'"); expect(allow).toContain('trigger-result$'); expect(allow).toContain('insight$'); + expect(allow).toContain('host-facts$'); expect(allow).not.toContain('answer'); // And the gate consults it only under the core-only flag. expect(ipcSource).toContain('opts.coreOnlyPublicRoutes === true && routeIsCoreOnlyPublic(method, url.pathname)'); @@ -916,6 +917,18 @@ describe('core-only entrypoint hardening (codex 4 P1s — source lock)', () => { expect(serve).toContain('delete e.SESSION_DATA_DIR;'); }); + it('core-only backend is an explicit tmux/pty opt-in and never changes fleet defaults', () => { + const cliSource = readFileSync(resolve('src/cli.ts'), 'utf8'); + const serve = region(cliSource, 'async function cmdServe(', 'child.on('); + expect(serve).toContain("getOpt('--backend') ?? process.env.BOTMUX_CORE_BACKEND"); + expect(serve).toContain("backend !== 'tmux' && backend !== 'pty'"); + expect(serve).toContain('BOTMUX_CORE_BACKEND: backend'); + expect(registrySource).toContain("const coreBackend = process.env.BOTMUX_CORE_BACKEND;"); + expect(registrySource).toContain("coreBackend !== 'tmux' && coreBackend !== 'pty'"); + expect(registrySource).toContain('if (coreBackend) entry.backendType = coreBackend;'); + expect(readFileSync(resolve('src/config.ts'), 'utf8')).toContain("return 'tmux';"); + }); + it('P1(2nd round): core-only skips host-wide maintenance / auto-restart / restart-report', () => { // The synthetic bot is idx=0 but must NOT own fleet maintenance (global // botmux update + detached `botmux restart`). Gate the whole block on !coreOnly. diff --git a/test/bot-registry.test.ts b/test/bot-registry.test.ts index 8ba34cc7ec..9d62fe95d2 100644 --- a/test/bot-registry.test.ts +++ b/test/bot-registry.test.ts @@ -1612,7 +1612,7 @@ describe('loadBotConfigs — core-only synthesis (BOTMUX_CORE_ONLY=1)', () => { let mod: Awaited>; let fsMock: { existsSync: ReturnType; readFileSync: ReturnType; statSync: ReturnType }; const saved: Record = {}; - const CORE_KEYS = ['BOTMUX_CORE_ONLY', 'BOTS_CONFIG', 'BOTMUX_API_ONLY_BOT', 'BOTMUX_CORE_CLI', 'BOTMUX_CORE_WORKING_DIR', 'BOTMUX_CORE_MODEL']; + const CORE_KEYS = ['BOTMUX_CORE_ONLY', 'BOTS_CONFIG', 'BOTMUX_API_ONLY_BOT', 'BOTMUX_CORE_CLI', 'BOTMUX_CORE_WORKING_DIR', 'BOTMUX_CORE_MODEL', 'BOTMUX_CORE_BACKEND']; beforeEach(async () => { mod = await freshImport(); @@ -1675,6 +1675,23 @@ describe('loadBotConfigs — core-only synthesis (BOTMUX_CORE_ONLY=1)', () => { const [cfg] = mod.loadBotConfigs(); expect(cfg.larkAppId).toBe('local_riff'); expect(cfg.cliId).toBe('codex-app'); + expect(cfg.backendType).toBeUndefined(); + }); + + it('applies an explicit core-only PTY backend without changing the default', () => { + fsMock.existsSync.mockReturnValue(false); + process.env.BOTMUX_CORE_ONLY = '1'; + process.env.BOTMUX_CORE_BACKEND = 'pty'; + const [cfg] = mod.loadBotConfigs(); + expect(cfg.apiOnly).toBe(true); + expect(cfg.backendType).toBe('pty'); + }); + + it('rejects an invalid core-only backend instead of falling back to tmux', () => { + fsMock.existsSync.mockReturnValue(false); + process.env.BOTMUX_CORE_ONLY = '1'; + process.env.BOTMUX_CORE_BACKEND = 'zellij'; + expect(() => mod.loadBotConfigs()).toThrow('BOTMUX_CORE_BACKEND must be tmux or pty'); }); it('rejects a non-local_ synthetic id (identity must be a synthetic local slug)', () => { diff --git a/test/core-only-host-facts.test.ts b/test/core-only-host-facts.test.ts new file mode 100644 index 0000000000..213d9bea63 --- /dev/null +++ b/test/core-only-host-facts.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; +import { projectCoreOnlyHostFacts } from '../src/core/core-only-host-facts.js'; + +describe('core-only host facts', () => { + it('keeps the persistent backend tri-state authoritative', () => { + const facts = projectCoreOnlyHostFacts({ + sessionId: 'session-1', + sessionStatus: 'active', + cli: 'codex', + backend: 'tmux', + nativeSessionId: 'thread-1', + activeTurnId: 'turn-logical', + workerPresent: true, + workerReady: true, + workerGeneration: 3, + backingProbe: 'unknown', + }); + expect(facts.liveness).toBe('unknown'); + expect(facts.native).toEqual({ + sessionId: 'thread-1', + activeTurnId: 'turn-logical', + nativeTurnId: null, + }); + }); + + it('reports a ready live PTY worker as exists without inventing native ids', () => { + const facts = projectCoreOnlyHostFacts({ + sessionId: 'session-2', + sessionStatus: 'active', + cli: 'claude-code', + backend: 'pty', + nativeSessionId: null, + activeTurnId: null, + workerPresent: true, + workerReady: true, + workerGeneration: 1, + backingProbe: null, + }); + expect(facts.liveness).toBe('exists'); + expect(facts.native.nativeTurnId).toBeNull(); + }); + + it('never treats an absent worker reference as proof that a session is missing', () => { + const facts = projectCoreOnlyHostFacts({ + sessionId: 'session-3', + sessionStatus: 'closed', + cli: null, + backend: null, + nativeSessionId: null, + activeTurnId: null, + workerPresent: false, + workerReady: false, + workerGeneration: null, + backingProbe: null, + }); + expect(facts.liveness).toBe('unknown'); + }); +}); diff --git a/test/dispatch-report-binding.test.ts b/test/dispatch-report-binding.test.ts index 9b96661105..376de455a2 100644 --- a/test/dispatch-report-binding.test.ts +++ b/test/dispatch-report-binding.test.ts @@ -2,8 +2,12 @@ import { describe, expect, it } from 'vitest'; import { createDispatchReportBinding, + dispatchReportBindingSecretPath, + loadOrCreateDispatchReportBindingSecret, resolveVerifiedDispatchReportTarget, } from '../src/core/dispatch-report-binding.js'; +import { join } from 'node:path'; +import { chmodSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs'; const SECRET = 'host-only-binding-secret'; @@ -19,6 +23,45 @@ function binding(dispatchRoot = 'om_seed') { }); } +describe('dispatch report binding secret path', () => { + it('keeps the historical shared fleet path by default', () => { + expect(dispatchReportBindingSecretPath('/Users/example/.botmux/data', { coreOnly: false })) + .toBe('/Users/example/.botmux/.dashboard-secret.report-binding'); + }); + + it('keeps core-only authority inside its explicit state directory', () => { + const stateDir = '/tmp/core-only-state'; + expect(dispatchReportBindingSecretPath(stateDir, { coreOnly: true })) + .toBe(join(stateDir, '.dashboard-secret.report-binding')); + }); + + it('migrates a secure legacy core-only secret into the new state directory', () => { + const root = `/tmp/botmux-binding-migrate-${crypto.randomUUID()}`; + const stateDir = join(root, 'data'); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + chmodSync(root, 0o700); chmodSync(stateDir, 0o700); + const legacy = join(root, '.dashboard-secret.report-binding'); + writeFileSync(legacy, 'legacy-secret', { mode: 0o600 }); chmodSync(legacy, 0o600); + try { + expect(loadOrCreateDispatchReportBindingSecret(stateDir, { coreOnly: true })).toBe('legacy-secret'); + expect(readFileSync(join(stateDir, '.dashboard-secret.report-binding'), 'utf8')).toBe('legacy-secret'); + } finally { if (existsSync(join(stateDir, '.dashboard-secret.report-binding'))) rmSync(join(stateDir, '.dashboard-secret.report-binding')); rmSync(root, { recursive: true, force: true }); } + }); + + it('refuses an unsafe legacy core-only secret instead of silently rotating it', () => { + const root = `/tmp/botmux-binding-unsafe-${crypto.randomUUID()}`; + const stateDir = join(root, 'data'); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + chmodSync(root, 0o700); chmodSync(stateDir, 0o700); + const legacy = join(root, '.dashboard-secret.report-binding'); + writeFileSync(legacy, 'unsafe-secret', { mode: 0o644 }); chmodSync(legacy, 0o644); + try { + expect(() => loadOrCreateDispatchReportBindingSecret(stateDir, { coreOnly: true })).toThrow('权限必须严格为 0600'); + expect(existsSync(join(stateDir, '.dashboard-secret.report-binding'))).toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } + }); +}); + describe('dispatch report binding', () => { it('derives the target only from the host signature, never mutable entry fields', () => { expect(resolveVerifiedDispatchReportTarget({ diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index 34556d9ece..ed5e22e9db 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -18,6 +18,6 @@ "declarationMap": false, "sourceMap": false }, - "include": ["scripts/**/*.ts", "scripts/**/*.tsx"], + "include": ["scripts/**/*.ts", "scripts/**/*.tsx", "src/types/**/*.d.ts"], "exclude": ["node_modules", "dist"] } From f68539f5c9f65dfa4b28af5907afda2af7cd06cf Mon Sep 17 00:00:00 2001 From: Oliver-Ysq <849087045@qq.com> Date: Fri, 9 Oct 2026 17:01:03 +0800 Subject: [PATCH 2/7] fix(core-only): restore isolated test seams --- src/types/proxy-from-env.d.ts | 11 ----------- test/dispatch-user-route.test.ts | 2 +- test/turn-cli-identity.test.ts | 2 +- tsconfig.scripts.json | 2 +- 4 files changed, 3 insertions(+), 14 deletions(-) delete mode 100644 src/types/proxy-from-env.d.ts diff --git a/src/types/proxy-from-env.d.ts b/src/types/proxy-from-env.d.ts deleted file mode 100644 index b023b7a6c7..0000000000 --- a/src/types/proxy-from-env.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * 本仓库已在 devDependencies 声明 @types/proxy-from-env;部分共享开发依赖目录未安装该 - * 纯类型包时,仍需让生产构建保持可复现。此声明与 @types/proxy-from-env@1.0.4 的公开 - * `getProxyForUrl` 签名一致,不会改变 proxy-from-env 的任何运行时加载或行为。 - */ -declare module 'proxy-from-env' { - import { Url } from 'node:url'; - - /** 根据环境变量和 URL 返回代理地址;未配置代理时返回空字符串。 */ - export function getProxyForUrl(url: string | Url): string; -} diff --git a/test/dispatch-user-route.test.ts b/test/dispatch-user-route.test.ts index 4714f82976..0171557de8 100644 --- a/test/dispatch-user-route.test.ts +++ b/test/dispatch-user-route.test.ts @@ -54,7 +54,7 @@ function harness(overrides: Record = {}, isHost = false) { readGlobalConfig: () => ({ scheduleDelegation: { createEnabled: true } }), getDashboardAdminOpenIds: () => ['ou_alice'], resolveUnionIdFromOpenId, replyMessage: send, deliverDispatchWithUser, - config: { session: { dataDir } }, loadOrCreateDashboardSecret: () => secret, dispatchReportBindingSecretPath: () => '', + config: { session: { dataDir } }, loadOrCreateDispatchReportBindingSecret: () => secret, logger: { info: vi.fn() }, }; const run = new Function('scope', 'with (scope) { ' + code + '; return handler; }')(scope); diff --git a/test/turn-cli-identity.test.ts b/test/turn-cli-identity.test.ts index 3de274eb60..cb7634fa1a 100644 --- a/test/turn-cli-identity.test.ts +++ b/test/turn-cli-identity.test.ts @@ -415,7 +415,7 @@ describe('target daemon consumes signed message authority', () => { const scopeValues = { config: { session: { dataDir: dir } }, pickTurnReplyTarget, resolveDispatchUser, dispatchCallerFromReply, getBot: () => ({ config: botConfig({ enabled: true, tools: ['bytedcli'] }) }), - loadOrCreateDashboardSecret: () => secret, dispatchReportBindingSecretPath: () => '', + loadOrCreateDispatchReportBindingSecret: () => secret, resolveTargetAppOpenId, evaluateTalk: () => ({ allowed: options.talk !== false }), listChatMemberOpenIds: async () => { if (options.memberError) throw new Error('member list exceeds 2000 members'); diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json index ed5e22e9db..34556d9ece 100644 --- a/tsconfig.scripts.json +++ b/tsconfig.scripts.json @@ -18,6 +18,6 @@ "declarationMap": false, "sourceMap": false }, - "include": ["scripts/**/*.ts", "scripts/**/*.tsx", "src/types/**/*.d.ts"], + "include": ["scripts/**/*.ts", "scripts/**/*.tsx"], "exclude": ["node_modules", "dist"] } From 072561bc9d141393a1100c4bba82175cd3fe208d Mon Sep 17 00:00:00 2001 From: Oliver-Ysq <849087045@qq.com> Date: Fri, 9 Oct 2026 17:14:33 +0800 Subject: [PATCH 3/7] test(worker): cover codex sandbox pid resolution --- test/find-launched-cli-pid.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/find-launched-cli-pid.test.ts b/test/find-launched-cli-pid.test.ts index d0346bd0e3..a9ed446cff 100644 --- a/test/find-launched-cli-pid.test.ts +++ b/test/find-launched-cli-pid.test.ts @@ -81,7 +81,9 @@ describe('worker wrapper PID wiring', () => { it.each([false, true])('keeps the wrapper bridge resolver off under sandbox while the codex bwrap resolver runs (late=%s)', late => { const result = runWorkerWiring(late, { sandboxRequested: true }); expect(result.bridgeCliPid).toBeUndefined(); + expect(result.findLaunchedCliPid).toHaveBeenCalledWith(100, 'codex'); expect(result.backend.cliPid).toBe(200); + expect(result.publishLocalProcessAttestation).toHaveBeenLastCalledWith(200); }); }); From 5aa3b744d25ac2e6f549a78bc094a8e28ead2533 Mon Sep 17 00:00:00 2001 From: Oliver-Ysq <849087045@qq.com> Date: Sat, 10 Oct 2026 14:08:23 +0800 Subject: [PATCH 4/7] fix(core-only): handle absent legacy secrets --- src/core/dispatch-report-binding.ts | 24 +++++++++++++++---- test/api-only-mode-wiring.test.ts | 9 +++++++ test/core-only-host-facts.test.ts | 35 ++++++++++++++++++++++++---- test/dispatch-report-binding.test.ts | 30 ++++++++++++++++++++++++ 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/src/core/dispatch-report-binding.ts b/src/core/dispatch-report-binding.ts index add1702e59..3ae45da939 100644 --- a/src/core/dispatch-report-binding.ts +++ b/src/core/dispatch-report-binding.ts @@ -1,4 +1,5 @@ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { lstatSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { readSecureHostFileSync, withSecureHostParentSync } from '../platform/secure-host-file.js'; @@ -62,10 +63,25 @@ export function loadOrCreateDispatchReportBindingSecret( if (coreOnly) { const legacyPath = join(dirname(dataDir), '.dashboard-secret.report-binding'); - const legacy = readSecureHostFileSync(legacyPath, 256)?.trim(); - if (legacy) { - parent.writeLeaf(legacy); - return legacy; + // Do not run the strict legacy-parent check merely to discover a missing + // leaf. An explicit state dir may be a direct child of sticky /tmp: that + // parent is intentionally unsuitable for a credential, but its absence + // must not prevent a new credential from being created inside stateDir. + // If the legacy leaf exists, keep the strict reader: a symlink, unsafe + // mode, or untrusted parent is an ambiguous authority and fails closed. + let legacyPresent = false; + try { + lstatSync(legacyPath); + legacyPresent = true; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + } + if (legacyPresent) { + const legacy = readSecureHostFileSync(legacyPath, 256)?.trim(); + if (legacy) { + parent.writeLeaf(legacy); + return legacy; + } } } diff --git a/test/api-only-mode-wiring.test.ts b/test/api-only-mode-wiring.test.ts index e90aa43a72..a1a59fec7b 100644 --- a/test/api-only-mode-wiring.test.ts +++ b/test/api-only-mode-wiring.test.ts @@ -773,6 +773,15 @@ describe('core-only entrypoint hardening (codex 4 P1s — source lock)', () => { expect(ipcSource).toContain('opts.coreOnlyPublicRoutes === true && routeIsCoreOnlyPublic(method, url.pathname)'); }); + it('host-facts fails closed unless the owned session is apiOnly and HTTP-virtual', () => { + const handler = region(ipcSource, "ipcRoute('GET', '/api/sessions/:sessionId/host-facts'", "ipcRoute('POST', '/api/sessions/:sessionId/trigger-result/supersede'"); + expect(handler).toContain('session.larkAppId !== cachedLarkAppId'); + expect(handler).toContain('getBot(cachedLarkAppId).config.apiOnly === true'); + expect(handler).toContain('!apiOnly || !isHttpVirtualSession(session.chatId)'); + expect(handler.match(/jsonRes\(res, 404, \{ ok: false, error: 'session_not_found' \}\)/g)?.length).toBeGreaterThanOrEqual(2); + expect(handler).toContain("backingProbe = 'unknown'"); + }); + it('P1-2: core-only skips fleet sandbox migration + synthesis ignores ambient BOTS_CONFIG', () => { // Migration reads/backs-up/rewrites the on-disk fleet bots.json — must not run // for a headless core-only service. diff --git a/test/core-only-host-facts.test.ts b/test/core-only-host-facts.test.ts index 213d9bea63..f7112c6cf3 100644 --- a/test/core-only-host-facts.test.ts +++ b/test/core-only-host-facts.test.ts @@ -15,11 +15,19 @@ describe('core-only host facts', () => { workerGeneration: 3, backingProbe: 'unknown', }); - expect(facts.liveness).toBe('unknown'); - expect(facts.native).toEqual({ - sessionId: 'thread-1', - activeTurnId: 'turn-logical', - nativeTurnId: null, + expect(facts).toEqual({ + protocolVersion: 1, + sessionId: 'session-1', + cli: 'codex', + backend: 'tmux', + sessionStatus: 'active', + liveness: 'unknown', + worker: { present: true, ready: true, generation: 3 }, + native: { + sessionId: 'thread-1', + activeTurnId: 'turn-logical', + nativeTurnId: null, + }, }); }); @@ -55,4 +63,21 @@ describe('core-only host facts', () => { }); expect(facts.liveness).toBe('unknown'); }); + + it('does not call a present but unready worker alive', () => { + const facts = projectCoreOnlyHostFacts({ + sessionId: 'session-4', + sessionStatus: 'active', + cli: 'codex', + backend: 'pty', + nativeSessionId: null, + activeTurnId: null, + workerPresent: true, + workerReady: false, + workerGeneration: 4, + backingProbe: null, + }); + expect(facts.liveness).toBe('unknown'); + expect(facts.worker).toEqual({ present: true, ready: false, generation: 4 }); + }); }); diff --git a/test/dispatch-report-binding.test.ts b/test/dispatch-report-binding.test.ts index 376de455a2..af25a25929 100644 --- a/test/dispatch-report-binding.test.ts +++ b/test/dispatch-report-binding.test.ts @@ -35,6 +35,21 @@ describe('dispatch report binding secret path', () => { .toBe(join(stateDir, '.dashboard-secret.report-binding')); }); + it('creates the in-state secret when a legacy leaf is absent below a sticky shared parent', () => { + const root = `/tmp/botmux-binding-sticky-${crypto.randomUUID()}`; + const stateDir = join(root, 'state'); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + chmodSync(root, 0o1777); chmodSync(stateDir, 0o700); + const current = join(stateDir, '.dashboard-secret.report-binding'); + const legacy = join(root, '.dashboard-secret.report-binding'); + try { + const secret = loadOrCreateDispatchReportBindingSecret(stateDir, { coreOnly: true }); + expect(secret).toHaveLength(43); + expect(readFileSync(current, 'utf8')).toBe(secret); + expect(existsSync(legacy)).toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } + }); + it('migrates a secure legacy core-only secret into the new state directory', () => { const root = `/tmp/botmux-binding-migrate-${crypto.randomUUID()}`; const stateDir = join(root, 'data'); @@ -60,6 +75,21 @@ describe('dispatch report binding secret path', () => { expect(existsSync(join(stateDir, '.dashboard-secret.report-binding'))).toBe(false); } finally { rmSync(root, { recursive: true, force: true }); } }); + + it('refuses a present legacy secret below a sticky shared parent instead of switching signature domains', () => { + const root = `/tmp/botmux-binding-sticky-legacy-${crypto.randomUUID()}`; + const stateDir = join(root, 'state'); + mkdirSync(stateDir, { recursive: true, mode: 0o700 }); + chmodSync(root, 0o1777); chmodSync(stateDir, 0o700); + const legacy = join(root, '.dashboard-secret.report-binding'); + const current = join(stateDir, '.dashboard-secret.report-binding'); + writeFileSync(legacy, 'legacy-secret', { mode: 0o600 }); chmodSync(legacy, 0o600); + try { + expect(() => loadOrCreateDispatchReportBindingSecret(stateDir, { coreOnly: true })) + .toThrow('宿主凭证目录可被组内或其它用户写入'); + expect(existsSync(current)).toBe(false); + } finally { rmSync(root, { recursive: true, force: true }); } + }); }); describe('dispatch report binding', () => { From 5381daf2b23e3c5098b4adc6d5f8a6131943c353 Mon Sep 17 00:00:00 2001 From: Oliver-Ysq <849087045@qq.com> Date: Sat, 10 Oct 2026 14:21:01 +0800 Subject: [PATCH 5/7] test(worker): allow codex probe options --- test/find-launched-cli-pid.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/find-launched-cli-pid.test.ts b/test/find-launched-cli-pid.test.ts index a9ed446cff..9593b15862 100644 --- a/test/find-launched-cli-pid.test.ts +++ b/test/find-launched-cli-pid.test.ts @@ -81,7 +81,13 @@ describe('worker wrapper PID wiring', () => { it.each([false, true])('keeps the wrapper bridge resolver off under sandbox while the codex bwrap resolver runs (late=%s)', late => { const result = runWorkerWiring(late, { sandboxRequested: true }); expect(result.bridgeCliPid).toBeUndefined(); - expect(result.findLaunchedCliPid).toHaveBeenCalledWith(100, 'codex'); + // The production resolver supplies optional BFS probe/filter arguments; + // this boundary cares that every probe starts at the launcher and targets + // Codex, not about the internal optional-argument representation. + expect(result.findLaunchedCliPid.mock.calls).not.toHaveLength(0); + expect(result.findLaunchedCliPid.mock.calls.every((args: unknown[]) => + args[0] === 100 && args[1] === 'codex', + )).toBe(true); expect(result.backend.cliPid).toBe(200); expect(result.publishLocalProcessAttestation).toHaveBeenLastCalledWith(200); }); From 2293002c1e59f935a62df0b85cfea67e2342c4d8 Mon Sep 17 00:00:00 2001 From: Oliver-Ysq <849087045@qq.com> Date: Sat, 10 Oct 2026 14:29:52 +0800 Subject: [PATCH 6/7] docs(core-only): clarify secret migration --- docs-site/docs/en/api-core-only.md | 1 + docs-site/docs/zh/api-core-only.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs-site/docs/en/api-core-only.md b/docs-site/docs/en/api-core-only.md index 87ab5c7397..9358fb8fb8 100644 --- a/docs-site/docs/en/api-core-only.md +++ b/docs-site/docs/en/api-core-only.md @@ -226,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//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". diff --git a/docs-site/docs/zh/api-core-only.md b/docs-site/docs/zh/api-core-only.md index be5df37304..94e0f711b1 100644 --- a/docs-site/docs/zh/api-core-only.md +++ b/docs-site/docs/zh/api-core-only.md @@ -225,7 +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//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 的 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 就放行」。 From e5c64e7b0c11506d106c4a7bf4c609a2bd7bf6c8 Mon Sep 17 00:00:00 2001 From: Oliver-Ysq <849087045@qq.com> Date: Sat, 10 Oct 2026 14:51:38 +0800 Subject: [PATCH 7/7] test(send): align ordinary chat reply routing --- test/cli-send-doc-comment.test.ts | 6 +++--- test/fixtures/send-doc-comment-capture.ts | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/test/cli-send-doc-comment.test.ts b/test/cli-send-doc-comment.test.ts index 6b18e3c69f..c91bf72bce 100644 --- a/test/cli-send-doc-comment.test.ts +++ b/test/cli-send-doc-comment.test.ts @@ -273,13 +273,13 @@ describe('real CLI document-comment reply routing', () => { expect(result.sends).toEqual([]); }, 35_000); - it('leaves an ordinary chat on the chat interface even with an older pending comment', () => { + it('uses the ordinary chat turn reply interface even with an older pending comment', () => { const result = runSend({ markerTurn: 'om_chat_turn', mentionBack: false, session: { chatId: 'oc_chat' }, }); expect(result.status, result.stderr).toBe(0); expect(result.requests).toHaveLength(1); - expect(result.requests[0]?.path).toBe('/open-apis/im/v1/messages'); - expect(result.requests[0]?.body.receive_id).toBe('oc_chat'); + expect(result.requests[0]?.path).toBe('/open-apis/im/v1/messages/om_chat_turn/reply'); + expect(result.requests[0]?.body.receive_id).toBeUndefined(); }, 35_000); }); diff --git a/test/fixtures/send-doc-comment-capture.ts b/test/fixtures/send-doc-comment-capture.ts index 188d6e7126..00123210a2 100644 --- a/test/fixtures/send-doc-comment-capture.ts +++ b/test/fixtures/send-doc-comment-capture.ts @@ -19,7 +19,10 @@ import { existsSync, writeFileSync } from 'node:fs'; } else { data = { code: 0, data: { reply_id: 'reply_bot' } }; } - } else if (method === 'POST' && url.pathname === '/open-apis/im/v1/messages') { + } else if (method === 'POST' && ( + url.pathname === '/open-apis/im/v1/messages' + || url.pathname === '/open-apis/im/v1/messages/om_chat_turn/reply' + )) { data = { code: 0, data: { message_id: 'om_sent' } }; } else { throw new Error(`Unexpected test HTTP request: ${method} ${url.pathname}`);