diff --git a/docs-site/docs/en/bots-json.md b/docs-site/docs/en/bots-json.md index 91ecb57d..57d82fb0 100644 --- a/docs-site/docs/en/bots-json.md +++ b/docs-site/docs/en/bots-json.md @@ -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 ` | | `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") | diff --git a/docs-site/docs/zh/bots-json.md b/docs-site/docs/zh/bots-json.md index 5c7701fc..1e7022f8 100644 --- a/docs-site/docs/zh/bots-json.md +++ b/docs-site/docs/zh/bots-json.md @@ -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「机器人默认设置 → 环境变量」配置 | diff --git a/src/adapters/backend/tmux-backend.ts b/src/adapters/backend/tmux-backend.ts index c0d72f6a..16f54c73 100644 --- a/src/adapters/backend/tmux-backend.ts +++ b/src/adapters/backend/tmux-backend.ts @@ -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 @@ -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 @@ -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); diff --git a/src/adapters/backend/tmux-pipe-backend.ts b/src/adapters/backend/tmux-pipe-backend.ts index b1b5ef63..675ce9e4 100644 --- a/src/adapters/backend/tmux-pipe-backend.ts +++ b/src/adapters/backend/tmux-pipe-backend.ts @@ -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', diff --git a/src/adapters/backend/types.ts b/src/adapters/backend/types.ts index d192dab4..0a181606 100644 --- a/src/adapters/backend/types.ts +++ b/src/adapters/backend/types.ts @@ -29,6 +29,14 @@ export interface SpawnOpts { * merges them into the child env. Already sanitized (see sanitizePerBotEnv). */ injectEnv?: Record; + /** + * 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 { diff --git a/src/adapters/backend/zellij-backend.ts b/src/adapters/backend/zellij-backend.ts index a3b335c5..18f963de 100644 --- a/src/adapters/backend/zellij-backend.ts +++ b/src/adapters/backend/zellij-backend.ts @@ -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, '_', diff --git a/src/bot-registry.ts b/src/bot-registry.ts index 443ffa30..ead9b6a7 100644 --- a/src/bot-registry.ts +++ b/src/bot-registry.ts @@ -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 @@ -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, diff --git a/src/core/dashboard-ipc-server.ts b/src/core/dashboard-ipc-server.ts index b77accb0..611346fe 100644 --- a/src/core/dashboard-ipc-server.ts +++ b/src/core/dashboard-ipc-server.ts @@ -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, }); @@ -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 过滤后落盘。 diff --git a/src/core/session-discovery.ts b/src/core/session-discovery.ts index 79cba203..9e422593 100644 --- a/src/core/session-discovery.ts +++ b/src/core/session-discovery.ts @@ -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 ` 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]; diff --git a/src/core/worker-pool.ts b/src/core/worker-pool.ts index 4873f82b..57fb48a9 100644 --- a/src/core/worker-pool.ts +++ b/src/core/worker-pool.ts @@ -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 diff --git a/src/dashboard.ts b/src/dashboard.ts index d7bb9c0f..b359f8c0 100644 --- a/src/dashboard.ts +++ b/src/dashboard.ts @@ -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; diff --git a/src/dashboard/web/bot-defaults.ts b/src/dashboard/web/bot-defaults.ts index e8c860f2..51368935 100644 --- a/src/dashboard/web/bot-defaults.ts +++ b/src/dashboard/web/bot-defaults.ts @@ -239,7 +239,7 @@ export async function renderBotDefaultsPage(root: HTMLElement) { ${renderSandboxSection(b)}
${renderRoleSection(b)}
-
${renderSessionModeSection(b)}${renderCrossBotSection(b)}${renderSessionCapSection(b)}${renderStartupCommandsSection(b)}${renderEnvSection(b)}
+
${renderSessionModeSection(b)}${renderCrossBotSection(b)}${renderSessionCapSection(b)}${renderStartupCommandsSection(b)}${renderLaunchShellSection(b)}${renderEnvSection(b)}
${renderCardBehaviorSection(b)}${renderBrandSection(b)}
${renderGrantSection(b)}
@@ -513,6 +513,25 @@ export async function renderBotDefaultsPage(root: HTMLElement) { `; } + // 启动 shell launchShell:启动 CLI 用的 shell(zsh|bash|sh 或绝对路径),覆盖 $SHELL。 + // 用于登录 $SHELL(如 bash)的 rc 文件里 `exec zsh` 跳转、导致 CLI 起不来的场景。 + // next-session 生效。PUT /api/bot-launch-shell 落 bots.json。 + function renderLaunchShellSection(b: any): string { + const val: string = typeof b.launchShell === 'string' ? b.launchShell : ''; + return `
+

${t('botDefaults.sectionLaunchShell')}

+

${t('botDefaults.launchShellHelp')}

+ +
+ + +
+
`; + } + // 环境变量 env:注入到本 bot CLI 进程的环境变量(JSON 对象),如让某个 bot 走 GLM/ // 第三方服务商(ANTHROPIC_BASE_URL+ANTHROPIC_AUTH_TOKEN)或设 HTTPS_PROXY。next-session // 生效(下个新会话起注入)。PUT /api/bots/:appId/env 落 bots.json,跨后端按会话注入 @@ -1341,6 +1360,43 @@ export async function renderBotDefaultsPage(root: HTMLElement) { }); } + // ── 启动 shell launchShell(shell 名或绝对路径;空=清除→回 $SHELL) ────── + const launchShellEl = card.querySelector('input[data-input=launchShell]'); + const launchShellSaveBtn = card.querySelector('button[data-action=save-launch-shell]'); + const launchShellStatusEl = card.querySelector('[data-launch-shell-status]'); + if (launchShellEl && launchShellSaveBtn) { + launchShellSaveBtn.addEventListener('click', async () => { + if (!launchShellStatusEl) return; + launchShellStatusEl.textContent = ''; + launchShellStatusEl.className = 'oncall-status'; + launchShellSaveBtn.disabled = true; + try { + const r = await fetch(`/api/bots/${encodeURIComponent(appId)}/launch-shell`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ launchShell: launchShellEl.value }), + }); + const body = await r.json().catch(() => ({})); + if (r.ok && body.ok) { + launchShellStatusEl.textContent = `✓ ${t('botDefaults.cardPrefSaved')}`; + launchShellStatusEl.classList.add('hint-ok'); + const next: string = typeof body.launchShell === 'string' ? body.launchShell : ''; + launchShellEl.value = next; + const cached = cache.bots.find((bb: any) => bb.larkAppId === appId); + if (cached) cached.launchShell = next; + } else { + launchShellStatusEl.textContent = `✗ ${body.error ?? r.status}`; + launchShellStatusEl.classList.add('hint-warn-inline'); + } + } catch (e: any) { + launchShellStatusEl.textContent = `✗ ${e?.message ?? e}`; + launchShellStatusEl.classList.add('hint-warn-inline'); + } finally { + launchShellSaveBtn.disabled = false; + } + }); + } + // ── 环境变量 env(JSON 对象;空=清除) ────────────────────────────── const envEl = card.querySelector('textarea[data-input=env]'); const envSaveBtn = card.querySelector('button[data-action=save-env]'); diff --git a/src/dashboard/web/i18n.ts b/src/dashboard/web/i18n.ts index 7e0067ca..e05c9ae8 100644 --- a/src/dashboard/web/i18n.ts +++ b/src/dashboard/web/i18n.ts @@ -1109,6 +1109,10 @@ const zh: DashboardMessages = { 'botDefaults.startupCommandsHelp': '开会话后、首条消息之前,自动按顺序发给 CLI 的命令(每条独立回车),如 /effort ultracode。逗号或换行分隔,每行一条,可带参数;留空=不发。下个新会话起效,且每次新会话(含 resume)都重放——所以 /effort ultracode 这类只对当前会话生效的设置不会因 resume 丢失。仅对原生 CLI 会话生效,adopt 接管的会话不发。', 'botDefaults.startupCommandsPlaceholder': '每行一条,如 /effort ultracode', 'botDefaults.startupCommandsSave': '保存启动命令', + 'botDefaults.sectionLaunchShell': '启动 Shell', + 'botDefaults.launchShellHelp': '启动 CLI 用的 shell,覆盖 daemon 的 $SHELL:填 shell 名(zsh|bash|sh)或绝对路径(如 /usr/bin/zsh)。用于登录 $SHELL(如 bash)的 rc 文件里有 `exec zsh` 之类跳转、把 CLI 启动命令顶掉、导致会话起不来(裸壳里报 parse error)的场景——指定 launchShell 直接用它启动、绕开被跳过的 rc。注意:PATH/nvm/pnpm 等要放进所选 shell 的 rc(如 .zshrc/.zprofile)。留空=回 $SHELL。下个新会话起效;仅 tmux/zellij 后端(pty 直接 exec CLI,本就不受影响)。', + 'botDefaults.launchShellPlaceholder': '如 zsh 或 /usr/bin/zsh;留空=用 $SHELL', + 'botDefaults.launchShellSave': '保存启动 Shell', 'botDefaults.sectionEnv': '环境变量', 'botDefaults.envHelp': '注入到本 bot CLI 进程的环境变量(JSON 对象)。典型用途:让某个 bot 走 GLM / 第三方 Anthropic·OpenAI 兼容服务商——填 ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN(GLM 国际站 https://api.z.ai/api/anthropic,国内站 https://open.bigmodel.cn/api/anthropic),或设 HTTPS_PROXY、CLI 专属开关。值仅接受字符串/数字/布尔;BOTMUX_ / LARK_APP_ 等 botmux 保留键会被忽略。下个新会话起效,按会话注入到 CLI 进程(不进共享 tmux server 全局,不会串到别的 bot)。留空=清除。注意:值以明文存于 bots.json,不是密钥保险箱。', 'botDefaults.envPlaceholder': '{\n "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",\n "ANTHROPIC_AUTH_TOKEN": "你的 GLM Coding Plan key"\n}', @@ -2450,6 +2454,10 @@ const en: DashboardMessages = { 'botDefaults.startupCommandsHelp': 'Commands auto-sent to the CLI in order after launch, before the first message (each its own Enter), e.g. /effort ultracode. Comma- or newline-separated, one per line, arguments allowed; empty = none. Takes effect from the next session, and replays on every new session (incl. resume) — so session-only settings like /effort ultracode survive a resume. Native CLI sessions only; adopted sessions are not driven.', 'botDefaults.startupCommandsPlaceholder': 'One per line, e.g. /effort ultracode', 'botDefaults.startupCommandsSave': 'Save startup commands', + 'botDefaults.sectionLaunchShell': 'Launch shell', + 'botDefaults.launchShellHelp': 'Shell used to launch the CLI, overriding the daemon $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 launch so the session never starts (bare-shell `parse error`) — pinning launchShell launches under it 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).', + 'botDefaults.launchShellPlaceholder': 'e.g. zsh or /usr/bin/zsh; empty = $SHELL', + 'botDefaults.launchShellSave': 'Save launch shell', 'botDefaults.sectionEnv': 'Environment variables', 'botDefaults.envHelp': 'Environment variables injected into THIS bot\'s CLI process (a JSON object). Typical use: run a bot on GLM / a third-party Anthropic·OpenAI-compatible provider — set ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN (GLM: https://api.z.ai/api/anthropic, or https://open.bigmodel.cn/api/anthropic in China), or set HTTPS_PROXY / a CLI feature flag. Values accept string/number/boolean only; botmux-reserved keys (BOTMUX_, LARK_APP_, …) are ignored. Takes effect from the next session, injected per-session into the CLI process (never into the shared tmux server env, so it can\'t leak across bots). Empty = clear. Note: values live in bots.json in plaintext — not a secret vault.', 'botDefaults.envPlaceholder': '{\n "ANTHROPIC_BASE_URL": "https://api.z.ai/api/anthropic",\n "ANTHROPIC_AUTH_TOKEN": "your GLM Coding Plan key"\n}', diff --git a/src/services/bot-config-store.ts b/src/services/bot-config-store.ts index d71d8858..bca3104d 100644 --- a/src/services/bot-config-store.ts +++ b/src/services/bot-config-store.ts @@ -58,6 +58,7 @@ export interface ConfigFieldSpec { export const CONFIG_FIELDS: readonly ConfigFieldSpec[] = [ { key: 'model', configKey: 'model', kind: 'string', effect: 'next-session', clearable: true, hint: 'CLI 模型名(如 opus);unset 回 CLI 默认' }, { key: 'cli', configKey: 'cliId', kind: 'cli', effect: 'next-session', clearable: false, hint: 'CLI 适配器(序号 1-16 或 id,如 claude-code)' }, + { key: 'launchShell', configKey: 'launchShell', kind: 'string', effect: 'next-session', clearable: true, hint: '启动 CLI 用的 shell(zsh|bash|sh 或绝对路径),覆盖 $SHELL;用于 .bashrc/.zshrc 里 exec 切到别的 shell 导致会话起不来的场景;注意 PATH/nvm 要放进所选 shell 的 rc;unset 回 $SHELL' }, { key: 'lang', configKey: 'lang', kind: 'enum', effect: 'immediate', clearable: true, enumValues: ['zh', 'en'], hint: '机器人 UI 语言 zh|en;unset 回全局默认' }, { key: 'defaultWorkingDir', configKey: 'defaultWorkingDir', kind: 'dir', effect: 'next-session', clearable: true, hint: '新话题默认工作目录(跳过仓库选择卡片)' }, { key: 'brandLabel', configKey: 'brandLabel', kind: 'string', effect: 'immediate', clearable: true, hint: '卡片页脚品牌文案;unset 回默认 botmux 链接' }, diff --git a/src/types.ts b/src/types.ts index 70628771..6ffcb963 100644 --- a/src/types.ts +++ b/src/types.ts @@ -270,7 +270,7 @@ export type TermActionKey = /** Messages sent from Daemon to Worker */ export type DaemonToWorker = - | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; model?: string; disableCliBypass?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxHidePaths?: string[]; backendType: BackendType; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } + | { type: 'init'; sessionId: string; chatId: string; rootMessageId: string; workingDir: string; cliId: string; cliPathOverride?: string; wrapperCli?: string; launchShell?: string; model?: string; disableCliBypass?: boolean; startupCommands?: string[]; env?: Record; sandbox?: boolean; sandboxHidePaths?: string[]; backendType: BackendType; prompt: string; resume?: boolean; cliSessionId?: string; originalSessionId?: string; ownerOpenId?: string; webPort?: number; larkAppId: string; larkAppSecret: string; brand?: 'feishu' | 'lark'; botName?: string; botOpenId?: string; locale?: 'zh' | 'en'; turnId?: string; skillPluginDir?: string; skillReadonlyRoots?: string[]; adoptMode?: boolean; adoptSource?: 'tmux' | 'herdr' | 'zellij'; adoptTmuxTarget?: string; adoptZellijSession?: string; adoptZellijPaneId?: string; adoptHerdrSessionName?: string; adoptHerdrTarget?: string; adoptHerdrPaneId?: string; adoptPaneCols?: number; adoptPaneRows?: number; bridgeJsonlPath?: string; adoptCliPid?: number; adoptCwd?: string; adoptRestoredFromMetadata?: boolean } | { type: 'message'; content: string; turnId?: string } /** Literal slash-command passthrough. `followUpContent` rides along so the * worker enqueues it strictly AFTER the slash command's Enter — two separate diff --git a/src/worker.ts b/src/worker.ts index f3e7e008..5babc6b8 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -15,7 +15,7 @@ import { randomBytes } from 'node:crypto'; import { mkdirSync, writeFileSync, unlinkSync, existsSync, statSync, readdirSync, readlinkSync, readFileSync, watch as fsWatch, createWriteStream, type FSWatcher, type WriteStream } from 'node:fs'; import { atomicWriteFileSync } from './utils/atomic-write.js'; -import { isAbsolute, join } from 'node:path'; +import { isAbsolute, join, basename } from 'node:path'; import { drainTranscript, joinAssistantText, trailingAssistantText, findJsonlContainingFingerprint, findJsonlsContainingExactContent, findLatestJsonl, extractLastAssistantTurn, stringifyUserContent, extractTurnStartText, splitTranscriptEventsByCutoff, type TranscriptEvent } from './services/claude-transcript.js'; import { BridgeTurnQueue, makeFingerprint, normaliseForFingerprint } from './services/bridge-turn-queue.js'; import { shouldSuppressBridgeEmit, type BridgeSendMarker } from './services/bridge-fallback-gate.js'; @@ -65,7 +65,7 @@ import { } from './utils/render-dimensions.js'; import { createCliAdapterSync, locateOnPath } from './adapters/cli/registry.js'; import { buildWrappedLaunch, parseWrapperCli, isTtadkWrapper } from './setup/cli-selection.js'; -import { findLaunchedCliPid, scheduleWrapperRealCliPid } from './core/session-discovery.js'; +import { findLaunchedCliPid, scheduleWrapperRealCliPid, readComm, isBareShellComm, bareShellLaunchKind } from './core/session-discovery.js'; import { claudeJsonlPathForSession, resolveJsonlFromPid, findOpenClaudeSessionIds, DEFAULT_CLAUDE_DATA_DIR } from './adapters/cli/claude-code.js'; import { mtrSessionIdForBotmuxSession } from './adapters/cli/mtr.js'; import type { CliAdapter, PtyHandle, SubmitRecheckResult, CliId } from './adapters/cli/types.js'; @@ -205,6 +205,19 @@ let isFlushing = false; * when the CLI restarts. Consumed inside flushPending right before the first * user prompt is drained, so the commands always precede it (see runStartupCommands). */ let hasRunStartupCommands = false; +/** Per-spawn latch: set once the launch-failure detector has decided the pane + * leaf is a bare shell (the CLI never launched — e.g. a user rcfile that + * `exec`-trampolines into another shell pre-empted the wrapper's `exec `). + * Once set, flushPending refuses to type prompts into the bare shell (which + * would just produce `zsh: parse error`) and the user gets one diagnostic + * instead. Reset per spawn in spawnCli. */ +let bareShellLaunchBlocked = false; +/** Per-spawn one-shot: has the bare-shell launch check already run for this + * spawn? Gates detectBareShellLaunch() to the FIRST flush only (the + * "about to type the first prompt" moment), independent of the startup-commands + * one-shot so it also covers a reattach onto a pane that degraded to a bare + * shell. Reset per spawn in spawnCli. */ +let bareShellChecked = false; /** Ready-gate (Claude-family): holds the first prompt until the SessionStart * hook fires a true-ready signal, so a cjadk-style startup selector's ❯ (which * falsely matches readyPattern) can't eat the first message. Recreated + armed @@ -3180,6 +3193,66 @@ function scheduleSubmitFailureNotify( }, SUBMIT_DEFERRED_RECHECK_MS); } +/** + * Launch-failure guard. Right before the FIRST prompt is typed, confirm the + * pane's leaf process is the agent CLI — not a bare interactive shell. The + * failure this catches: a user's login `$SHELL` (e.g. bash) whose rcfile + * `exec`-trampolines into another shell (`[ -t 1 ] && exec zsh`). botmux's + * tmux wrapper launches ` -i -c '… exec /usr/bin/env '`; the `-i` + * sources the rcfile, the `exec zsh` replaces the shell BEFORE the `-c` body + * runs, and the pane is left at a bare shell. Typing the multi-line prompt into + * it just yields `zsh: parse error near '\n'` and the user is stuck (the exact + * bug this guards). Instead of typing into the shell we surface ONE actionable + * diagnostic and latch the session so no further prompt is mis-typed. + * + * Why this is the right moment / low false-positive: the first prompt is held + * until the CLI signals ready OR the 15s/45s first-prompt timeout fires, so by + * the time we get here a healthy CLI has long since `exec`'d (leaf comm = + * codex/node/…) — only a trampolined/failed launch is still a bare shell. We + * skip wrapperCli/adopt (their leaf is legitimately a launcher/observed pane) + * and the pty/herdr backends (which `exec` the CLI directly — getChildPid is the + * CLI itself, never a shell). + * + * Returns true when a bare-shell launch was detected (caller must NOT flush). + */ +function detectBareShellLaunch(): boolean { + if (bareShellLaunchBlocked) return true; + if (lastInitConfig?.adoptMode) return false; // observing an existing pane, not launching + if (lastInitConfig?.wrapperCli) return false; // launcher legitimately wraps the CLI (transient shell shim) + const pid = backend?.getChildPid?.(); + if (!pid) return false; + const comm = readComm(pid); + if (!isBareShellComm(comm)) return false; // CLI (rust/go/node) is running — healthy launch + + // Bare shell is the pane leaf → the CLI never launched. Tier the message on + // whether the leaf shell differs from the one botmux launched with: a + // mismatch is the unmistakable signature of an rcfile `exec`-trampoline. + const launchShell = (lastInitConfig?.launchShell || process.env.SHELL || '').trim(); + const expectedShell = launchShell ? basename(launchShell) : ''; + const trampolined = bareShellLaunchKind(comm!, expectedShell) === 'trampoline'; + bareShellLaunchBlocked = true; + log(`Bare-shell launch detected: pane leaf comm=${comm}, expected launch shell=${expectedShell || '?'}, ` + + `cli=${lastInitConfig?.cliId}; suppressing first-prompt write (${trampolined ? 'rc trampoline' : 'CLI did not start'})`); + + const cli = cliName(); + let message: string; + if (trampolined) { + message = + `⚠️ 会话没能启动:pane 里现在是裸 \`${comm}\`,${cli} 没真正跑起来——所以我没把你的消息打进去(否则会被当 shell 命令执行,报 \`parse error\`)。\n\n` + + `最可能原因:botmux 用 \`${expectedShell}\` 启动 CLI,但 pane 落到了 \`${comm}\`。通常是 rc 文件(如 \`~/.${expectedShell}rc\`)里有 \`exec ${comm}\` 这类跳转——\`${expectedShell} -i\` 会 source rc,于是 shell 被顶替,CLI 的启动命令没机会跑。\n\n` + + `两种修法(任选其一,改完重启 daemon 再发一条消息):\n` + + `① 给那行加守卫,只在手动开终端时切:\`[ -z "$BASH_EXECUTION_STRING" ] && [ -t 1 ] && exec ${comm}\`(注意 PATH/nvm 等导出放在它之前)\n` + + `② 给这个 bot 配 \`launchShell: ${comm}\`(dashboard 机器人配置,或 \`/config launchShell ${comm}\`),直接用 \`${comm}\` 启动绕开 \`${expectedShell}\` 的 rc——但要确保 PATH/nvm 在 \`${comm}\` 的 rc 里。`; + } else { + message = + `⚠️ 会话没能启动:pane 里还停在 \`${comm}\`,${cli} 没真正跑起来——我没把消息打进去(否则会被当 shell 命令执行)。\n\n` + + `可能原因:rc 文件启动过慢/报错,或 \`${cli}\` 的可执行文件不在 PATH 上(CLI 没找到)。\n` + + `建议:在 web 终端里手动敲一下启动命令看报什么错;确认 CLI 二进制能在 PATH 上找到;或精简 rc 启动逻辑后重启 daemon 再试。`; + } + send({ type: 'user_notify', turnId: currentBotmuxTurnId, message }); + return true; +} + /** * Drain the pending message queue sequentially. * Async with isFlushing mutex: awaits each writeInput, then immediately @@ -3190,6 +3263,7 @@ async function flushPending(): Promise { if (isFlushing) return; // while loop in active flush will pick up new messages if (!backend || !cliAdapter) return; if (pendingMessages.length === 0) return; // nothing to flush — keep isPromptReady + if (bareShellLaunchBlocked) return; // launch failed into a bare shell — don't type prompts into it // Ready-gate: hold the FIRST prompt until the SessionStart hook fires a true- // ready signal. A cjadk-style startup selector's ❯ falsely matches readyPattern // and would otherwise eat this message. releaseReadyGate() re-invokes us once @@ -3240,6 +3314,21 @@ async function flushPending(): Promise { } try { + // Launch-failure guard, run ONCE per spawn on the first flush, BEFORE startup + // commands or any user prompt: if the pane leaf is a bare shell (the CLI never + // launched — e.g. a user rcfile that `exec`-trampolines into another shell, or + // a reattached persistent pane that has dropped back to a shell), don't type + // anything into it (it would just be `zsh: parse error`); surface one + // diagnostic and bail. Gated by its own one-shot (NOT hasRunStartupCommands) + // so it also covers reattach, where startup commands are intentionally + // skipped. Must precede runStartupCommands so a bot with startupCommands + // doesn't get them typed into the bare shell first. + if (!bareShellChecked) { + bareShellChecked = true; + if (detectBareShellLaunch()) { + return; // finally{} releases the mutex; pendingMessages stay queued, untouched + } + } // One-shot per spawn: type the bot's startup commands (e.g. `/effort // ultracode`) into the CLI before the first user prompt drains. Both ready // paths funnel through flushPending — the ready-gate settle for Claude-family @@ -3885,6 +3974,13 @@ function spawnCli(cfg: Extract): void { // arm it. spawnCli is synchronous up to backend spawn, so this lands before // any flushPending consumes the flag. hasRunStartupCommands = !shouldRunStartupCommandsOnSpawn({ willReattachPersistent }); + // Re-arm the bare-shell launch detector for this spawn (fresh OR reattach). It + // runs once on the first flush and only fires when the pane leaf is actually a + // bare shell, so a healthy reattach (leaf = the live CLI) self-excludes while a + // reattach onto a pane that has degraded to a bare shell still gets the + // diagnostic instead of having the prompt typed into it. + bareShellLaunchBlocked = false; + bareShellChecked = false; // ── Resume pre-flight check + two-tier fallback ────────────────────────── // Tier 1 (adapter probe): adapter.checkResumeTargetExists returns false @@ -4236,6 +4332,7 @@ function spawnCli(cfg: Extract): void { rows: PTY_ROWS, env: childEnv as Record, injectEnv: perBotInjectKeys.length ? perBotInjectEnv : undefined, + launchShell: lastInitConfig?.launchShell, }); // Write CLI PID marker so agent-facing subcommands (`botmux send`, etc.) diff --git a/test/session-discovery.test.ts b/test/session-discovery.test.ts index c57be421..22db5039 100644 --- a/test/session-discovery.test.ts +++ b/test/session-discovery.test.ts @@ -31,9 +31,44 @@ vi.mock('node:os', () => ({ import { execSync } from 'node:child_process'; import { readFileSync, readlinkSync, existsSync, readdirSync } from 'node:fs'; -import { discoverAdoptableSessions, validateAdoptTarget } from '../src/core/session-discovery.js'; +import { discoverAdoptableSessions, validateAdoptTarget, isBareShellComm, bareShellLaunchKind } from '../src/core/session-discovery.js'; import type { CliId } from '../src/adapters/cli/types.js'; +describe('isBareShellComm()', () => { + it('classifies interactive shells as bare shells', () => { + for (const sh of ['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'fish', 'tcsh', 'csh']) { + expect(isBareShellComm(sh)).toBe(true); + } + }); + it('tolerates the leading-dot login-shell form (e.g. -zsh → .zsh on some ps)', () => { + expect(isBareShellComm('.zsh')).toBe(true); + }); + it('does NOT classify agent CLIs or launchers as bare shells', () => { + for (const comm of ['codex', 'claude', 'node', 'python', 'relay', 'seed', 'coco']) { + expect(isBareShellComm(comm)).toBe(false); + } + }); + it('returns false for undefined/empty', () => { + expect(isBareShellComm(undefined)).toBe(false); + expect(isBareShellComm('')).toBe(false); + }); +}); + +describe('bareShellLaunchKind()', () => { + it('reports trampoline when leaf shell differs from the launch shell', () => { + // The exact user case: $SHELL=bash, .bashrc `exec zsh` → leaf is zsh. + expect(bareShellLaunchKind('zsh', 'bash')).toBe('trampoline'); + expect(bareShellLaunchKind('bash', 'zsh')).toBe('trampoline'); + }); + it('reports stuck when leaf matches the launch shell (slow/erroring rc, or CLI not on PATH)', () => { + expect(bareShellLaunchKind('bash', 'bash')).toBe('stuck'); + expect(bareShellLaunchKind('zsh', 'zsh')).toBe('stuck'); + }); + it('reports stuck (no confident trampoline claim) when the launch shell is unknown', () => { + expect(bareShellLaunchKind('zsh', '')).toBe('stuck'); + }); +}); + const mockExecSync = vi.mocked(execSync); const mockReadFileSync = vi.mocked(readFileSync); const mockReadlinkSync = vi.mocked(readlinkSync); diff --git a/test/tmux-backend-env.test.ts b/test/tmux-backend-env.test.ts index 06043c6d..2f3ff961 100644 --- a/test/tmux-backend-env.test.ts +++ b/test/tmux-backend-env.test.ts @@ -27,6 +27,7 @@ import { buildDebugKeepShellScript, DIAGNOSTIC_SHELL_SCRIPT, resolveUserShell, + resolveShellOverride, SHELL_WRAPPER_SCRIPT, } from '../src/adapters/backend/tmux-backend.js'; @@ -275,6 +276,65 @@ describe('resolveUserShell()', () => { expect(spec.shell).not.toBe(bogus); expect(['/bin/zsh', '/bin/bash', '/bin/sh']).toContain(spec.shell); }); + + it('launchShell override (absolute path) wins over $SHELL', () => { + // The escape hatch for a login $SHELL whose rcfile exec-trampolines into + // another shell: pinning launchShell launches the CLI under it directly. + tmpDir = mkdtempSync(join(tmpdir(), 'bmx-shell-')); + const bash = join(tmpDir, 'bash'); + const zsh = join(tmpDir, 'zsh'); + writeFileSync(bash, '#!/bin/sh\nexec "$@"\n'); chmodSync(bash, 0o755); + writeFileSync(zsh, '#!/bin/sh\nexec "$@"\n'); chmodSync(zsh, 0o755); + const spec = resolveUserShell({ SHELL: bash }, zsh); + expect(spec.shell).toBe(zsh); + expect(spec.flags).toEqual(['-l', '-i']); // zsh-flavoured, not bash's ['-i'] + }); + + it('falls back to $SHELL when the launchShell override is not found/executable', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'bmx-shell-')); + const bash = join(tmpDir, 'bash'); + writeFileSync(bash, '#!/bin/sh\nexec "$@"\n'); chmodSync(bash, 0o755); + const spec = resolveUserShell({ SHELL: bash }, '/no/such/zsh'); + expect(spec.shell).toBe(bash); + expect(spec.flags).toEqual(['-i']); + }); +}); + +describe('resolveShellOverride()', () => { + let tmpDir: string | undefined; + afterEach(() => { + if (tmpDir) { rmSync(tmpDir, { recursive: true, force: true }); tmpDir = undefined; } + }); + + it('resolves an absolute path and classifies its flags', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'bmx-shell-')); + const zsh = join(tmpDir, 'zsh'); + writeFileSync(zsh, '#!/bin/sh\nexec "$@"\n'); chmodSync(zsh, 0o755); + const spec = resolveShellOverride(zsh); + expect(spec?.shell).toBe(zsh); + expect(spec?.flags).toEqual(['-l', '-i']); + }); + + it('resolves a bare name from the conventional locations (or null if absent)', () => { + // bash/sh exist on essentially every CI image; assert the spec is sane when found. + const spec = resolveShellOverride('sh'); + if (spec) { + expect(spec.shell.endsWith('/sh')).toBe(true); + expect(spec.flags).toEqual([]); + } + }); + + it('returns null for a non-existent override and for a blank string', () => { + expect(resolveShellOverride('/no/such/shell-xyz')).toBeNull(); + expect(resolveShellOverride(' ')).toBeNull(); + }); + + it('returns null (ignored) for an unsupported shell like fish', () => { + tmpDir = mkdtempSync(join(tmpdir(), 'bmx-shell-')); + const fish = join(tmpDir, 'fish'); + writeFileSync(fish, '#!/bin/sh\nexec "$@"\n'); chmodSync(fish, 0o755); + expect(resolveShellOverride(fish)).toBeNull(); + }); }); describe('buildDebugKeepShellScript()', () => {