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
4 changes: 2 additions & 2 deletions docs-site/docs/en/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ Manage the daemon and sessions from the terminal.
| Command | Description |
|------|------|
| `botmux setup` | Interactive configuration (first run / add / edit / delete a bot) |
| `botmux start` | Start the daemon (managed by PM2) |
| `botmux start [--companion-secret-file <path> --companion-bot <appId>]` | Start the daemon. Supplying both companion options enables the closed local API for exactly one isolated test Bot; see [Local Companion API](/en/companion-api) |
| `botmux stop` | Stop the daemon |
| `botmux restart [--include-pm2]` | Restart the daemon (automatically restores active sessions); `--include-pm2` additionally retires botmux's PM2 God daemon after the fleet is safely shut down, so the whole process tree restarts from the invoking shell's clean environment (plugin services are gracefully stopped first; auto ones come back after the restart) |
| `botmux restart [--companion-secret-file <path> --companion-bot <appId>]` | Restart the daemon and restore active sessions; accepts the same closed Companion API options as `start` |
| `botmux logs [--lines N]` | View logs |
| `botmux status` | View daemon status |
| `botmux upgrade` | Upgrade to the latest version |
Expand Down
41 changes: 41 additions & 0 deletions docs-site/docs/en/companion-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Local Companion API

Botmux can expose a closed local management protocol for one explicitly bound isolated test Bot. It neither reuses Dashboard tokens or Dashboard/daemon HMAC keys nor proxies arbitrary Dashboard routes.

## Startup

```bash
botmux start \
--companion-secret-file /run/secrets/botmux/companion \
--companion-bot local_test_bot
```

`restart` accepts the same options. Both options are required together. `--companion-bot` selects one specific existing Bot; unrelated fleet entries are ignored, but that selected app id must match exactly one entry with `sandbox` enabled and `cliId` set to `codex` or `traex`. Duplicate entries, a missing entry, or any nonqualifying entry fail closed with a deterministic generic diagnostic and no app id disclosure.

Preprovision a dedicated test Bot before invoking the command: add one unique `larkAppId` entry with `sandbox: true` and `cliId: "codex"` or `"traex"`, and keep it separate from production Bots. Then pass that app id to `--companion-bot` and a separate canonical `0600` secret file to `--companion-secret-file`. `readIsolation` alone and `codex-app` are not accepted by this protocol. The secret must be a nonempty, non-symlink `0600` regular file owned by the current user at a canonical absolute path. Invalid configuration fails closed without including the path or contents in the error.

## Authentication

The surface uses the Dashboard's local listening port but authenticates independently before ordinary Dashboard auth/routing; it grants no Dashboard administrator identity. Requests must originate from loopback and carry the headers below. If an operator explicitly enables the platform tunnel, the tunnel is a trusted transport into the local Dashboard port; HMAC remains mandatory and is the effective boundary for that opt-in path.

- `X-Botmux-Companion-Timestamp`: epoch milliseconds, within 60 seconds;
- `X-Botmux-Companion-Nonce`: a one-time random value;
- `X-Botmux-Companion-Signature`: base64url HMAC-SHA256.

Signing material:

```text
timestamp\nnonce\nMETHOD\nexact-pathname\nsha256(raw-body)
```

Bodies are capped at 64 KiB. Replay, stale timestamps, and signature/method/path/body mismatches are rejected before the operation runs.

## Fixed routes

- `GET /__companion/v1/health`: protocol version and capabilities only;
- `GET /__companion/v1/role`: `{role, injectMode, revision:null}`, with role text capped at 32 KiB;
- `PUT /__companion/v1/role`: only `{requestId, role, injectMode}`; `injectMode` is `every|once`, and `role:""` clears it; returns the sanitized readback;
- `GET /__companion/v1/runtime`: `{provider, model?, reasoning?}`;
- `PUT /__companion/v1/runtime`: only `{requestId, provider, model?, reasoning?}`. `provider` is `codex|traecli` (mapped to Botmux `codex|traex`), model is at most 200 characters, and reasoning uses the existing provider/model-specific closed allowlist.

Successful writes are idempotent by `requestId` for a bounded process-local window. Failed or timed-out writes are not cached and may be retried; the underlying operation must still be treated as asynchronous when a timeout is reported. The API accepts no Bot ID, chat ID, arbitrary settings/env/URL/header/command and exposes no trigger/result surface; every operation targets the startup-bound Bot. Role text is returned only on this companion-HMAC route and is not added to Dashboard/public DTOs. Responses and errors contain no secret, file path, or native identifier.
2 changes: 2 additions & 0 deletions docs-site/docs/en/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ Most configuration goes through `bots.json` / the dashboard — you **usually do
| `BOTMUX_DAEMON_IPC_BASE_PORT` | `7892` | Each daemon's IPC port = base + botIndex |
| `BOTMUX_WORKFLOW_RUNS_DIR` | `~/.botmux/workflow-runs` | Workflow run storage directory |
| `BOTMUX_DASHBOARD_PUBLIC_READONLY` | `true` | Allow tokenless access to the Dashboard's allow-listed read-only APIs / SSE. Once this switch has been saved in Dashboard Settings, the value persisted in `~/.botmux/config.json` takes precedence over this environment variable |
| `BOTMUX_COMPANION_SECRET_FILE` | _(unset)_ | Dedicated HMAC secret-file path for the closed [Local Companion API](/en/companion-api), normally set by `start/restart --companion-secret-file`. Botmux strictly requires a canonical absolute path, current-user ownership, non-symlink regular file, `0600`, and nonempty content. It is not passed to session CLIs and never falls back to `.dashboard-secret`. |
| `BOTMUX_COMPANION_BOT_APP_ID` | _(unset)_ | The single isolated test Bot bound to the Companion API, normally set by `start/restart --companion-bot`. Callers cannot select a Bot. |

## File locations

Expand Down
4 changes: 2 additions & 2 deletions docs-site/docs/zh/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
| 命令 | 说明 |
|------|------|
| `botmux setup` | 交互式配置(首次 / 添加 / 编辑 / 删除机器人) |
| `botmux start` | 启动 daemon(PM2 管理) |
| `botmux start [--companion-secret-file <path> --companion-bot <appId>]` | 启动 daemon;两个 companion 参数同时提供时,为唯一绑定的隔离测试 Bot 开启封闭本机 API(见[本地 Companion API](/companion-api)) |
| `botmux stop` | 停止 daemon |
| `botmux restart [--include-pm2]` | 重启 daemon(自动恢复活跃会话);`--include-pm2` 会在 fleet 安全关停并验证后同时退役 botmux 专用 PM2 God daemon,让整棵进程树以当前 shell 的干净环境全新启动(插件 service 会先优雅停止,auto 的重启后自动恢复) |
| `botmux restart [--companion-secret-file <path> --companion-bot <appId>]` | 重启 daemon(自动恢复活跃会话);接受与 `start` 相同的封闭 Companion API 参数 |
| `botmux logs [--lines N]` | 查看日志 |
| `botmux status` | 查看 daemon 状态 |
| `botmux upgrade` | 升级到最新版本 |
Expand Down
41 changes: 41 additions & 0 deletions docs-site/docs/zh/companion-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 本地 Companion API

Botmux 可为一个显式绑定的隔离测试 Bot 开启封闭的本机管理协议。它不复用 Dashboard token、Dashboard/daemon HMAC 密钥,也不代理任意 Dashboard 路由。

## 启动

```bash
botmux start \
--companion-secret-file /run/secrets/botmux/companion \
--companion-bot local_test_bot
```

`restart` 接受相同选项。两个选项必须同时提供。`--companion-bot` 选择一个明确的现有 Bot;fleet 中其它条目不参与匹配,但该 app id 必须精确匹配一条启用 `sandbox` 且 `cliId` 为 `codex` 或 `traex` 的记录。重复记录、记录不存在或不满足 predicate 时 fail closed,并返回确定性的通用诊断,不泄露 app id。

调用前请预置专用测试 Bot:添加唯一的 `larkAppId`,设置 `sandbox: true`,并将 `cliId` 设为 `codex` 或 `traex`;与生产 Bot 分离。然后把该 app id 传给 `--companion-bot`,并传入独立的 canonical `0600` 密钥文件。仅有 `readIsolation` 或使用 `codex-app` 不满足本协议。密钥必须是 canonical 绝对路径上的当前用户所有、非符号链接、非空 `0600` 普通文件。校验失败时拒绝启动该配置,且错误不会包含路径或内容。

## 鉴权

接口复用 Dashboard 的本机监听端口,但在普通 Dashboard 鉴权与路由之外独立验签;它不会授予 Dashboard 管理身份。请求只能来自 loopback,并携带以下鉴权信息。若操作员显式开启平台 tunnel,tunnel 是进入本机 Dashboard 端口的受信传输;该路径仍强制 HMAC,HMAC 是此可选路径的实际边界。

- `X-Botmux-Companion-Timestamp`:epoch 毫秒;允许偏差 60 秒;
- `X-Botmux-Companion-Nonce`:一次性随机值;
- `X-Botmux-Companion-Signature`:base64url HMAC-SHA256。

签名材料为:

```text
timestamp\nnonce\nMETHOD\nexact-pathname\nsha256(raw-body)
```

body 上限 64 KiB。重放、过期、签名/方法/路径/body 不匹配均在执行操作前拒绝。

## 固定路由

- `GET /__companion/v1/health`:只返回协议版本和 capability;
- `GET /__companion/v1/role`:返回 `{role, injectMode, revision:null}`;role 最大 32 KiB;
- `PUT /__companion/v1/role`:仅接受 `{requestId, role, injectMode}`;`injectMode` 为 `every|once`,`role:""` 清除;返回 sanitize 后 readback;
- `GET /__companion/v1/runtime`:返回 `{provider, model?, reasoning?}`;
- `PUT /__companion/v1/runtime`:仅接受 `{requestId, provider, model?, reasoning?}`。`provider` 为 `codex|traecli`(分别映射 Botmux `codex|traex`),model 最长 200 字符,reasoning 使用对应 provider/model 的现有闭集。

成功写操作按 `requestId` 在有界的进程内窗口中幂等。失败或超时结果不会缓存,可以重试;收到超时后仍应把底层操作视为异步进行中。接口不接受 Bot ID、chat ID、任意 settings/env/URL/header/命令,也不提供 trigger/result;所有操作固定作用于启动绑定 Bot。角色文本只在通过 companion HMAC 的该路由返回,不加入 Dashboard/public DTO。响应和错误不包含密钥、文件路径或原生 ID。
2 changes: 2 additions & 0 deletions docs-site/docs/zh/env.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
| `BOTMUX_DAEMON_IPC_BASE_PORT` | `7892` | 每个 daemon 的 IPC 端口 = base + botIndex |
| `BOTMUX_WORKFLOW_RUNS_DIR` | `~/.botmux/workflow-runs` | workflow run 存储目录 |
| `BOTMUX_DASHBOARD_PUBLIC_READONLY` | `true` | 是否允许无 token 访问 Dashboard 白名单只读 API / SSE;一旦在 Dashboard 设置页保存过该开关,`~/.botmux/config.json` 中的值优先于本环境变量 |
| `BOTMUX_COMPANION_SECRET_FILE` | _(未设置)_ | 封闭[本地 Companion API](/companion-api) 的专用 HMAC 密钥文件路径;通常由 `start/restart --companion-secret-file` 设置。严格校验 canonical 绝对路径、当前用户 owner、非软链、普通文件、`0600`、非空;不下发给会话 CLI,绝不回退或复用 `.dashboard-secret`。 |
| `BOTMUX_COMPANION_BOT_APP_ID` | _(未设置)_ | Companion API 唯一绑定的隔离测试 Bot;通常由 `start/restart --companion-bot` 设置。不接受请求方选择 Bot。 |

## 文件位置

Expand Down
2 changes: 2 additions & 0 deletions docs-site/rspress.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ const zhSidebar = [
items: [
{ text: 'bots.json 配置', link: '/bots-json' },
{ text: '环境变量与文件位置', link: '/env' },
{ text: '本地 Companion API', link: '/companion-api' },
{ text: '多 CLI 适配器', link: '/adapters' },
],
},
Expand Down Expand Up @@ -160,6 +161,7 @@ const enSidebar = [
items: [
{ text: 'bots.json', link: '/en/bots-json' },
{ text: 'Environment & File Locations', link: '/en/env' },
{ text: 'Local Companion API', link: '/en/companion-api' },
{ text: 'CLI Adapters', link: '/en/adapters' },
],
},
Expand Down
42 changes: 34 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
* botmux setup --no-open-platform-auto — skip Feishu Open Platform automation
* botmux setup list|add|configure|edit|remove — scripted (non-TUI) bot management, see `botmux setup help`
* botmux clone <bot> [--name <name>] — create a new app, then copy an existing bot's configuration
* botmux start — start daemon and auto plugin services
* botmux start [--companion-secret-file <path> --companion-bot <appId>]
* — start daemon and optionally its closed local companion API
* botmux stop [--with-plugin] — stop daemon (optionally stop auto plugin services)
* botmux restart [--with-plugin] — restart daemon, then ensure auto plugin services
* botmux restart [--with-plugin] [--companion-secret-file <path> --companion-bot <appId>]
* — restart daemon, then ensure auto plugin services
* botmux logs [--lines] [--bot <i>] [--no-follow] — view/stream per-bot daemon logs
* botmux status — show daemon status
* botmux upgrade|update — upgrade to latest version (本地 checkout 则 git pull --ff-only + rebuild + restart)
Expand Down Expand Up @@ -2433,10 +2435,22 @@ function preflightNodeSanity(): void {
}
}

function applyCompanionOptions(argv: string[]): void {
applyCompanionStartupOptions({
argv,
env: process.env,
bots: loadBotsJson(),
// Validate without retaining or logging the value. The Dashboard performs
// the same fail-closed check before exposing any companion route.
validateSecret: loadCompanionSecret,
});
}

async function cmdStart(): Promise<void> {
// FIRST STATEMENT, before any await or dependency probe: those run as child
// processes and would inherit the marker. See consumeAutostartUnitMarker.
const bootHookStart = consumeAutostartUnitMarker();
applyCompanionOptions(process.argv.slice(3));
// `--systemd-service` and the PM2-God ownership gating that used to live here
// are gone with pm2 itself: the built-in supervisor owns single-owner exclusion
// via fleet-state (pid + kill-0 under the fleet mutation lock), so there is no
Expand Down Expand Up @@ -2515,6 +2529,9 @@ async function startConfiguredFleet(
const { startFleetViaSupervisor } = await import('./core/fleet-runtime.js');
const result = startFleetViaSupervisor();
if (result.action === 'already-running') {
if (process.env.BOTMUX_COMPANION_SECRET_FILE || process.env.BOTMUX_COMPANION_BOT_APP_ID) {
throw new Error('fleet is already running; use `botmux restart` to apply companion options');
}
console.log(`\n✅ fleet 已在运行 (supervisor pid ${result.supervisorPid}, ${result.botCount} 个机器人)`);
}
}, { maxWaitMs: 5_000 });
Expand Down Expand Up @@ -2637,6 +2654,7 @@ interface RestartLifecycleFlags {


async function cmdRestart(): Promise<void> {
applyCompanionOptions(process.argv.slice(3));
const { refreshPersistedEnv, readFailureFallback } = prepareRestartDriverContext();
if (!hasConfig()) {
console.error('❌ 未找到配置文件');
Expand Down Expand Up @@ -6102,8 +6120,9 @@ botmux v${getVersion()} — IM ↔ AI 编程 CLI 桥接
clone <机器人名> [--name <新名称>]
创建新应用并复制该机器人的行为配置;留空名称自动使用 源名称-copy-时间戳
start 启动 daemon,并启动 mode=auto 的插件 service
可用 --companion-secret-file <绝对路径> --companion-bot <appId> 开启封闭本机 Companion API
stop 停止 daemon(默认不停止插件 service;--with-plugin 显式停止 mode=auto 的插件 service)
restart 重启 daemon(默认不停止插件 service,core 启动后确保 mode=auto 正在运行;--with-plugin 显式先停再启动 auto service)
restart 重启 daemon(同样接受 --companion-secret-file / --companion-bot;--with-plugin 显式先停再启动 auto service)
logs 查看/跟随 daemon 日志(--lines N, --bot <0-based-index|name|appId>, --no-follow 只打印不跟随)
status 查看 daemon 状态
upgrade 升级到最新版本(别名:update)
Expand Down Expand Up @@ -7398,6 +7417,9 @@ import {
} from './bot-registry.js';
import { resolvePricingConfig, type ResolvedModelPricing } from './services/model-pricing.js';
import { config } from './config.js';
import { loadCompanionSecret } from './dashboard/companion-api.js';
import { applyCompanionStartupOptions } from './cli/companion-startup-options.js';
import { unknownFleetArgs } from './cli/fleet-args.js';
import { getSessionUsageSnapshot } from './core/cost-calculator.js';
import {
resolveQuoteTarget,
Expand Down Expand Up @@ -13439,12 +13461,13 @@ const ROOT_FLEET_MUTATION_COMMANDS = new Set(['start', 'stop', 'restart', 'upgra
// there — its meaning on stop/restart is "also tear the plugin service down",
// and start has no tear-down phase.
const FLEET_KNOWN_FLAGS: Record<string, readonly string[]> = {
start: [],
start: ['--companion-secret-file', '--companion-bot'],
stop: ['--with-plugin'],
restart: ['--with-plugin'],
restart: ['--with-plugin', '--companion-secret-file', '--companion-bot'],
upgrade: [],
update: [],
};
const FLEET_VALUE_FLAGS = new Set(['--companion-secret-file', '--companion-bot']);
if (ROOT_FLEET_MUTATION_COMMANDS.has(command ?? '')) {
const fleetArgs = process.argv.slice(3);
if (fleetArgs.some(arg => arg === '--help' || arg === '-h')) {
Expand All @@ -13462,9 +13485,12 @@ if (ROOT_FLEET_MUTATION_COMMANDS.has(command ?? '')) {
// of these commands takes a positional argument either, so anything outside
// the table above is unknown, flag-shaped or not.
const knownFleetFlags = FLEET_KNOWN_FLAGS[command ?? ''] ?? [];
const unknownFleetArgs = fleetArgs.filter(arg => !knownFleetFlags.includes(arg));
if (unknownFleetArgs.length > 0) {
console.error(`未知参数: ${unknownFleetArgs.join(' ')}`);
const unknownArgs = unknownFleetArgs(fleetArgs, {
boolFlags: knownFleetFlags.filter(flag => !FLEET_VALUE_FLAGS.has(flag)),
valueFlags: knownFleetFlags.filter(flag => FLEET_VALUE_FLAGS.has(flag)),
});
if (unknownArgs.length > 0) {
console.error(`未知参数: ${unknownArgs.join(' ')}`);
console.error(` \`botmux ${command}\` 只接受: ${['--help', ...knownFleetFlags].join(' ')}。`);
console.error(' 为避免把一个看起来像「只检查」的参数当成「执行」,这里直接中止,不做任何改动。');
process.exit(2);
Expand Down
41 changes: 41 additions & 0 deletions src/cli/companion-startup-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import {
COMPANION_BOT_APP_ID_ENV,
COMPANION_SECRET_FILE_ENV,
} from '../config.js';

export interface CompanionStartupBot {
larkAppId?: string;
sandbox?: boolean;
cliId?: string;
}

export function applyCompanionStartupOptions(input: {
argv: string[];
env: NodeJS.ProcessEnv;
bots: readonly CompanionStartupBot[];
validateSecret: (path: string) => unknown;
}): void {
let secretFile = input.env[COMPANION_SECRET_FILE_ENV]?.trim();
let botAppId = input.env[COMPANION_BOT_APP_ID_ENV]?.trim();
for (let i = 0; i < input.argv.length; i += 1) {
const arg = input.argv[i];
if (arg === '--companion-secret-file') secretFile = input.argv[++i]?.trim();
else if (arg.startsWith('--companion-secret-file=')) secretFile = arg.slice(arg.indexOf('=') + 1).trim();
else if (arg === '--companion-bot') botAppId = input.argv[++i]?.trim();
else if (arg.startsWith('--companion-bot=')) botAppId = arg.slice(arg.indexOf('=') + 1).trim();
}
if (!secretFile && !botAppId) return;
if (!secretFile || !botAppId) {
throw new Error('companion configuration requires both secret-file and bot selection');
}
input.validateSecret(secretFile);
const matches = input.bots.filter(bot => bot.larkAppId === botAppId);
if (matches.length !== 1 || matches[0].sandbox !== true
|| (matches[0].cliId !== 'codex' && matches[0].cliId !== 'traex')) {
// Deliberately omit the selected app id and all config details: this is a
// deterministic provisioning diagnostic, not a fleet inventory endpoint.
throw new Error('companion bot selection must match exactly one isolated codex/traex Bot');
}
input.env[COMPANION_SECRET_FILE_ENV] = secretFile;
input.env[COMPANION_BOT_APP_ID_ENV] = botAppId;
}
Loading
Loading