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
1 change: 1 addition & 0 deletions docs-site/docs/en/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ There are many fields, listed below grouped by purpose. The vast majority are **
| `cliPathOverride` | Absolute path to the CLI entry point, for wrapping a wrapper / router (ccr, claude-w, aiden-x-claude, etc.) |
| `disableCliBypass` | When `true`, the CLI's auto-approve / sandbox-bypass flags (`--yolo`, `--dangerously-*`) are not appended automatically; omitted / `false` keeps the original behavior |
| `backendType` | Session backend, one of `pty` / `tmux` / `herdr` / `zellij`. Leave empty to **auto-detect**: chooses `tmux` if tmux is available, otherwise `pty` (`herdr` and `zellij` are never auto-selected and must be specified explicitly). `tmux` / `herdr` / `zellij` are all persistent sessions and fall back to `pty` automatically if the corresponding binary probe fails (`zellij` requires ≥ 0.44); `pty` attaches directly to the process and does not persist across restarts. See [tmux backend](/en/tmux) |
| `launchShell` | Shell used to launch the CLI, overriding the daemon's `$SHELL`: a shell name (`zsh` / `bash` / `sh`) or an absolute path (e.g. `/usr/bin/zsh`). For when the login `$SHELL` (e.g. bash) has an rcfile that `exec`-trampolines into another shell (`exec zsh`), pre-empting the CLI under botmux's `bash -i` launch so the session never starts (bare-shell `parse error`) — pinning it launches under that shell directly, bypassing the skipped rcfile. **Note**: PATH / nvm / pnpm must then live in the chosen shell's rcfiles (e.g. `.zshrc` / `.zprofile`). Empty = use `$SHELL`. Takes effect next session; `tmux` / `zellij` backends only (`pty` execs the CLI directly and is unaffected). Also configurable in the dashboard ("Bot defaults → Launch shell") or via `/config launchShell <value>` |
| `lang` | The bot's UI language, `zh` / `en`; leave empty to fall back to the `BOTMUX_LANG` / `LANG` environment variable |
| `customPassthroughCommands` | On top of the fixed passthrough allowlist and the current CLI adapter's default-allowed commands, additionally pass through slash commands to the underlying CLI, e.g. `["/export"]` (Claude Code / Codex default-allow `/goal`). Auto-normalized (a missing `/` is added, lowercased, only `[a-z0-9:_-]` kept, deduplicated); entries that would shadow a botmux daemon command (e.g. `/status`) are dropped and have no effect even if configured. Use `/list-slash-command` to view the full allowlist. See [Slash commands](/en/slash-commands) |
| `env` | Per-bot process environment variables `{ "KEY": "value" }`, injected into this bot's CLI process. Most common use: run a bot on GLM / a third-party Anthropic·OpenAI-compatible provider (see example below); also handy for `HTTPS_PROXY` or a CLI feature flag. Values accept string / number / boolean; botmux-reserved keys (`BOTMUX_`, `LARK_APP_`, …) are ignored. Injected **per session** (effective from the next session), never written to the shared tmux server env, so it can't leak across bots. Also editable in the dashboard ("Bot defaults → Environment variables") |
Expand Down
1 change: 1 addition & 0 deletions docs-site/docs/zh/bots-json.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
| `cliPathOverride` | CLI 入口绝对路径,用于套 wrapper / router(ccr、claude-w、aiden-x-claude 等) |
| `disableCliBypass` | `true` 时不自动追加 CLI 的免审批 / 沙箱绕过参数(`--yolo`、`--dangerously-*`);缺省 / `false` 保持原行为 |
| `backendType` | 会话后端,可选 `pty` / `tmux` / `herdr` / `zellij`。留空**自动检测**:tmux 可用选 `tmux`,否则 `pty`(`herdr`、`zellij` 不会被自动选中,需显式指定)。`tmux` / `herdr` / `zellij` 都是持久会话,对应二进制探测失败时自动回落 `pty`(`zellij` 需 ≥ 0.44);`pty` 直连进程、不跨重启持久。见 [tmux 后端](/tmux) |
| `launchShell` | 启动 CLI 用的 shell,覆盖 daemon 的 `$SHELL`:填 shell 名(`zsh` / `bash` / `sh`)或绝对路径(如 `/usr/bin/zsh`)。用于登录 `$SHELL`(如 bash)的 rc 文件里有 `exec zsh` 之类跳转、在 botmux 的 `bash -i` 启动里把 CLI 顶掉、导致会话起不来(裸壳里 `parse error`)的场景——指定后直接用它启动、绕开被跳过的 rc。**注意**:PATH / nvm / pnpm 等要放进所选 shell 的 rc(如 `.zshrc` / `.zprofile`)。留空=用 `$SHELL`。下个会话生效;仅 `tmux` / `zellij` 后端(`pty` 直接 exec CLI,本就不受影响)。也可在 dashboard「机器人默认设置 → 启动 Shell」或 `/config launchShell <值>` 配置 |
| `lang` | 该 bot 的界面语言 `zh` / `en`;留空回落 `BOTMUX_LANG` / `LANG` 环境变量 |
| `customPassthroughCommands` | 在固定透传白名单和当前 CLI adapter 默认放行命令之上,额外放行透传给底层 CLI 的 slash 命令,如 `["/export"]`(Claude Code / Codex 的 `/goal` 已默认放行)。自动归一化(缺失的 `/` 自动补、转小写、仅留 `[a-z0-9:_-]`、去重);会遮蔽 botmux daemon 命令(如 `/status`)的项会被丢弃,配了也不生效。用 `/list-slash-command` 查看完整放行清单。见 [斜杠命令](/slash-commands) |
| `env` | 该 bot 的进程环境变量 `{ "KEY": "值" }`,注入到这个 bot 的 CLI 进程。最常见用途:让某个 bot 跑 GLM / 第三方 Anthropic·OpenAI 兼容服务商(见下方示例),也可设 `HTTPS_PROXY` 或 CLI 专属开关。值支持字符串 / 数字 / 布尔;`BOTMUX_` / `LARK_APP_` 等 botmux 保留键会被忽略。按**会话**注入(下个新会话生效),不写入共享 tmux server 全局、不会串到别的 bot。也可在 dashboard「机器人默认设置 → 环境变量」配置 |
Expand Down
48 changes: 45 additions & 3 deletions src/adapters/backend/tmux-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export class TmuxBackend implements SessionBackend {
// session env (visible to the shell), which means the user's rcfile
// could `unset` or `export` over it before the CLI sees it. env(1)
// injection happens after rcfile load and is authoritative.
const shellSpec = resolveUserShell();
const shellSpec = resolveUserShell(process.env, opts.launchShell);
const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv);
// Debug knob — when on, the wrapper does NOT `exec` the CLI; it runs the
// CLI as a child and then drops into an interactive `$shell -i` so the
Expand Down Expand Up @@ -738,9 +738,46 @@ function isExecutable(path: string): boolean {
}
}

/**
* Resolve a per-bot `launchShell` override (BotConfig.launchShell) to an
* absolute, executable, classifiable shell path. Accepts either an absolute
* path (`/usr/bin/zsh`) or a bare name (`zsh`) — the latter is searched in the
* conventional shell locations. Returns null when the override can't be honored
* (not found / not executable / unsupported syntax like fish), so the caller
* falls back to the normal `$SHELL` resolution with a warning.
*
* The override is the escape hatch for users whose login `$SHELL` (e.g. bash)
* has an rcfile that `exec`-trampolines into another shell: pinning
* `launchShell: zsh` makes botmux launch the CLI under zsh directly, sidestepping
* the bash `.bashrc` `exec zsh` entirely. (Caveat surfaced in docs: PATH/nvm/pnpm
* must then live in the pinned shell's rcfiles, not the bypassed one.)
*/
export function resolveShellOverride(override: string): ShellSpec | null {
const raw = override.trim();
if (!raw) return null;
const candidates = raw.includes('/')
? [raw]
: [`/bin/${raw}`, `/usr/bin/${raw}`, `/usr/local/bin/${raw}`, `/opt/homebrew/bin/${raw}`];
for (const candidate of candidates) {
if (!isExecutable(candidate)) continue;
const kind = classifyShell(candidate);
if (!kind) {
logger.warn(
`[tmux-backend] launchShell=${override} resolved to ${candidate} which is not bash/zsh/sh; ` +
`ignoring override (our POSIX wrapper would break under it).`,
);
return null;
}
return specForKind(candidate, kind);
}
logger.warn(`[tmux-backend] launchShell=${override} not found/executable; ignoring override.`);
return null;
}

/**
* Pick a shell to wrap the CLI launch in, returning the binary path plus the
* exact argv flags needed for its rcfiles to load. Tries `$SHELL` first, then
* exact argv flags needed for its rcfiles to load. A per-bot `launchShell`
* override wins when it resolves; otherwise tries `$SHELL` first, then
* `/bin/zsh` → `/bin/bash` → `/bin/sh`.
*
* If `$SHELL` is fish/nu/csh/etc., emits a warning and falls back to a POSIX
Expand All @@ -754,7 +791,12 @@ function isExecutable(path: string): boolean {
* than good. If `/bin/sh` is also missing, tmux's own spawn will fail with
* a clear message.
*/
export function resolveUserShell(env: NodeJS.ProcessEnv = process.env): ShellSpec {
export function resolveUserShell(env: NodeJS.ProcessEnv = process.env, override?: string): ShellSpec {
if (override) {
const spec = resolveShellOverride(override);
if (spec) return spec;
// override unusable (not found / unsupported) → fall through to $SHELL.
}
const userShell = env.SHELL;
if (userShell && isExecutable(userShell)) {
const kind = classifyShell(userShell);
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/backend/tmux-pipe-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,7 +562,7 @@ export class TmuxPipeBackend implements SessionBackend {
}

private createDetachedSession(bin: string, args: string[], opts: SpawnOpts): void {
const shellSpec = resolveUserShell();
const shellSpec = resolveUserShell(process.env, opts.launchShell);
const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv);
execFileSync('tmux', [
'new-session',
Expand Down
8 changes: 8 additions & 0 deletions src/adapters/backend/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ export interface SpawnOpts {
* merges them into the child env. Already sanitized (see sanitizePerBotEnv).
*/
injectEnv?: Record<string, string>;
/**
* Per-bot shell override (BotConfig.launchShell). When set, the persistent
* backends (tmux/zellij) launch the CLI under this shell instead of `$SHELL`
* — the escape hatch for a login `$SHELL` whose rcfile `exec`-trampolines into
* another shell. Bare name (`zsh`) or absolute path; see resolveUserShell.
* Ignored by the pty backend (no shell wrapper).
*/
launchShell?: string;
}

export interface SessionBackend {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/backend/zellij-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@ export function kdlString(s: string): string {
* }
*/
export function buildLayoutString(bin: string, args: string[], opts: SpawnOpts): string {
const shellSpec = resolveUserShell();
const shellSpec = resolveUserShell(process.env, opts.launchShell);
const envAssignments = buildBotmuxEnvAssignments(opts.env, opts.injectEnv);
const paneArgs = [
...shellSpec.flags, '-c', SHELL_WRAPPER_SCRIPT, '_',
Expand Down
17 changes: 17 additions & 0 deletions src/bot-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,20 @@ export interface BotConfig {
* `aiden x claude` 时自动剥掉 aiden 拒收的 --settings。见 src/setup/cli-selection.ts。
*/
wrapperCli?: string;
/**
* Per-bot launch-shell override for the persistent backends (tmux/zellij).
* When set, botmux launches the CLI under this shell instead of the daemon's
* `$SHELL`. Accepts a bare name (`zsh`/`bash`/`sh`) or an absolute path
* (`/usr/bin/zsh`). The escape hatch for a login `$SHELL` (e.g. bash) whose
* rcfile `exec`-trampolines into another shell: that trampoline replaces the
* launch shell before it can `exec` the CLI, leaving a bare shell the first
* prompt gets typed into (`zsh: parse error`). Pinning `launchShell: zsh`
* launches under zsh directly and bypasses the bash `.bashrc`. CAVEAT:
* PATH/nvm/pnpm shims must then live in the pinned shell's rcfiles (e.g.
* `.zshrc`/`.zprofile`), not the bypassed one. Ignored by the pty backend
* (which `exec`s the CLI directly, no shell wrapper, so it's trampoline-immune).
*/
launchShell?: string;
/**
* Optional model name passed to the CLI at spawn time (e.g. `claude --model
* opus`). Each adapter decides how to inject it — adapters whose CLI has no
Expand Down Expand Up @@ -863,6 +877,9 @@ export function parseBotConfigsFromText(jsonText: string): BotConfig[] {
wrapperCli: typeof entry.wrapperCli === 'string' && entry.wrapperCli.trim()
? entry.wrapperCli.trim()
: undefined,
launchShell: typeof entry.launchShell === 'string' && entry.launchShell.trim()
? entry.launchShell.trim()
: undefined,
model: typeof entry.model === 'string' && entry.model.trim()
? entry.model.trim()
: undefined,
Expand Down
26 changes: 26 additions & 0 deletions src/core/dashboard-ipc-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,7 @@ ipcRoute('GET', '/api/bot-default-oncall', async (_req, res) => {
p2pMode,
maxLiveWorkers,
startupCommands,
launchShell: getBot(cachedLarkAppId).config.launchShell ?? '',
env,
skills: getBot(cachedLarkAppId).config.skills ?? null,
});
Expand Down Expand Up @@ -1381,6 +1382,31 @@ ipcRoute('PUT', '/api/bot-startup-commands', async (req, res) => {
jsonRes(res, 200, { ok: true, startupCommands: (value ?? []).join('\n') });
});

// Per-bot launch-shell override launchShell。Body `{ launchShell: string }`:
// 空字符串=清除(回 $SHELL)。走 applyConfigField(与 /config launchShell 同一写盘
// + 内存热更新路径),next-session 生效(下个会话起用新 shell 启动 CLI)。
ipcRoute('PUT', '/api/bot-launch-shell', async (req, res) => {
if (!cachedLarkAppId) return jsonRes(res, 503, { error: 'larkAppId_not_set' });
let body: { launchShell?: unknown };
try { body = await readJsonBody<{ launchShell?: unknown }>(req); }
catch { return jsonRes(res, 400, { ok: false, error: 'bad_json' }); }

const spec = findConfigField('launchShell');
if (!spec) return jsonRes(res, 500, { ok: false, error: 'spec_missing' });
const raw = typeof body.launchShell === 'string' ? body.launchShell : '';
let value: string | null;
if (!raw.trim()) {
value = null; // 清除 → 回 $SHELL
} else {
const coerced = coerceConfigValue(spec, raw);
if (!coerced.ok) return jsonRes(res, 400, { ok: false, error: coerced.reason });
value = coerced.value as string;
}
const r = await applyConfigField(cachedLarkAppId, spec, value);
if (!r.ok) return jsonRes(res, 400, { ok: false, error: r.reason });
jsonRes(res, 200, { ok: true, launchShell: value ?? '' });
});

// Per-bot 环境变量 env。Body `{ env: string }`(原始 JSON 文本,如
// `{"ANTHROPIC_BASE_URL":"…","ANTHROPIC_AUTH_TOKEN":"…"}` 让本 bot 走 GLM/第三方
// 服务商):空白 → 清除;否则按 json kind 解析 + sanitizePerBotEnv 过滤后落盘。
Expand Down
26 changes: 26 additions & 0 deletions src/core/session-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,32 @@ const COMM_ARGV_LAUNCHERS = new Set([
'MainThread',
]);

/** Interactive-shell comms. When a pane's leaf process is one of these AFTER
* botmux is ready to type the first prompt, the CLI never actually launched —
* e.g. the shell wrapper's `exec <cli>` was pre-empted by a user rcfile that
* `exec`-trampolines into another shell. None of the supported CLIs runs AS a
* bare shell (they're rust/go binaries or node), so this set never collides
* with a healthy CLI leaf. Used by the worker's launch-failure detector. */
const BARE_SHELL_COMMS = new Set([
'sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'mksh', 'fish', 'tcsh', 'csh',
]);

/** True when `comm` names an interactive shell rather than an agent CLI. */
export function isBareShellComm(comm: string | undefined): boolean {
if (!comm) return false;
return BARE_SHELL_COMMS.has(comm.startsWith('.') ? comm.slice(1) : comm);
}

/** Classify a confirmed bare-shell launch for diagnostics: 'trampoline' when the
* observed leaf shell differs from the shell botmux launched with — the
* signature of an rcfile that `exec`-trampolines into another shell (e.g.
* `$SHELL`=bash but the pane leaf is zsh). Otherwise 'stuck' (slow/erroring rc,
* or the CLI binary not on PATH). `expectedShell` may be '' when the launch
* shell is unknown, which yields 'stuck' (no confident trampoline claim). */
export function bareShellLaunchKind(leafComm: string, expectedShell: string): 'trampoline' | 'stuck' {
return expectedShell && leafComm !== expectedShell ? 'trampoline' : 'stuck';
}

export function cliIdForComm(comm: string, filterCliId?: CliId): CliId | undefined {
const normalizedComm = comm.startsWith('.') ? comm.slice(1) : comm;
const direct = CLI_COMM_MAP[comm] ?? CLI_COMM_MAP[normalizedComm];
Expand Down
1 change: 1 addition & 0 deletions src/core/worker-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1686,6 +1686,7 @@ export function forkWorker(ds: DaemonSession, prompt: string, resume = false): v
cliId: botCfg.cliId,
cliPathOverride: botCfg.cliPathOverride,
wrapperCli: botCfg.wrapperCli,
launchShell: botCfg.launchShell,
model: botCfg.model,
disableCliBypass: botCfg.disableCliBypass === true,
// Startup commands run on every fresh spawn (incl. resume) so session-only
Expand Down
18 changes: 18 additions & 0 deletions src/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2080,6 +2080,24 @@ const server = createServer(async (req, res) => {
return;
}

// PUT /api/bots/:appId/launch-shell — proxy to that bot's daemon. Body
// `{ launchShell: string }` (shell name or absolute path; '' = clear → $SHELL).
let mBotLaunchShell: RegExpMatchArray | null;
if (req.method === 'PUT' && (mBotLaunchShell = url.pathname.match(/^\/api\/bots\/([^/]+)\/launch-shell$/))) {
const appId = decodeURIComponent(mBotLaunchShell[1]);
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const raw = Buffer.concat(chunks).toString('utf8') || '{}';
const upstream = await proxyToDaemon(appId, `/api/bot-launch-shell`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: raw,
});
res.writeHead(upstream.status, { 'content-type': 'application/json' });
res.end(await upstream.text());
return;
}

// PUT /api/bots/:appId/env — proxy to that bot's daemon. Body
// `{ env: string }` (raw JSON text; '' = clear).
let mBotEnv: RegExpMatchArray | null;
Expand Down
Loading