diff --git a/src/tools/impl/Bash.ts b/src/tools/impl/Bash.ts index a86ae9a..3988f5e 100644 --- a/src/tools/impl/Bash.ts +++ b/src/tools/impl/Bash.ts @@ -1,68 +1,37 @@ import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; import { INTERRUPTED_BY_USER } from "../../constants"; import { backgroundProcesses, getNextBashId } from "./process_manager.js"; import { getShellEnv } from "./shellEnv.js"; +import { buildShellLaunchers } from "./shellLaunchers.js"; import { LIMITS, truncateByChars } from "./truncation.js"; import { validateRequiredParams } from "./validation.js"; -// Cache the shell configuration -let cachedShellConfig: { - executable: string; - args: (cmd: string) => string[]; -} | null = null; +// Cache the working shell launcher after first successful spawn +let cachedWorkingLauncher: string[] | null = null; /** - * Get shell configuration for the current platform. - * Uses spawn with explicit shell executable + args to avoid double-shell parsing issues. - * This approach (like gemini-cli and codex) passes the command directly to the shell - * as an argument, avoiding issues with HEREDOC and special characters. - * - * On macOS, we prefer zsh because bash 3.2 (shipped with macOS due to GPL licensing) - * has a bug with HEREDOC parsing when there's an odd number of apostrophes. - * zsh handles this correctly and is the default shell on modern macOS. + * Get the first working shell launcher for background processes. + * Uses cached launcher if available, otherwise returns first launcher from buildShellLaunchers. + * For background processes, we can't easily do async fallback, so we rely on cached launcher + * from previous foreground commands or the default launcher order. */ -function getShellConfig(): { - executable: string; - args: (cmd: string) => string[]; -} { - if (cachedShellConfig) { - return cachedShellConfig; +function getBackgroundLauncher(command: string): string[] { + if (cachedWorkingLauncher) { + const [executable, ...launcherArgs] = cachedWorkingLauncher; + if (executable) { + return [executable, ...launcherArgs.slice(0, -1), command]; + } } - - if (process.platform === "win32") { - // Windows: use PowerShell - cachedShellConfig = { - executable: "powershell.exe", - args: (cmd) => ["-NoProfile", "-Command", cmd], - }; - return cachedShellConfig; - } - - // On macOS, prefer zsh due to bash 3.2's HEREDOC bug with apostrophes - if (process.platform === "darwin" && existsSync("/bin/zsh")) { - cachedShellConfig = { - executable: "/bin/zsh", - args: (cmd) => ["-c", cmd], - }; - return cachedShellConfig; - } - - // Linux or macOS without zsh: use bash - cachedShellConfig = { - executable: "bash", - args: (cmd) => ["-c", cmd], - }; - return cachedShellConfig; + const launchers = buildShellLaunchers(command); + return launchers[0] || []; } /** - * Execute a command using spawn with explicit shell. - * This avoids the double-shell parsing that exec() does. - * Exported for use by bash mode in the CLI. + * Spawn a command with a specific launcher. + * Returns a promise that resolves with the output or rejects with an error. */ -export function spawnCommand( - command: string, +function spawnWithLauncher( + launcher: string[], options: { cwd: string; env: NodeJS.ProcessEnv; @@ -71,8 +40,13 @@ export function spawnCommand( }, ): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { return new Promise((resolve, reject) => { - const { executable, args } = getShellConfig(); - const childProcess = spawn(executable, args(command), { + const [executable, ...args] = launcher; + if (!executable) { + reject(new Error("Empty launcher")); + return; + } + + const childProcess = spawn(executable, args, { cwd: options.cwd, env: options.env, shell: false, // Don't use another shell layer @@ -150,6 +124,74 @@ export function spawnCommand( }); } +/** + * Execute a command using spawn with explicit shell. + * This avoids the double-shell parsing that exec() does. + * Uses buildShellLaunchers() to try multiple shells with ENOENT fallback. + * Exported for use by bash mode in the CLI. + */ +export async function spawnCommand( + command: string, + options: { + cwd: string; + env: NodeJS.ProcessEnv; + timeout: number; + signal?: AbortSignal; + }, +): Promise<{ stdout: string; stderr: string; exitCode: number | null }> { + // If we have a cached working launcher, try it first + if (cachedWorkingLauncher) { + // Rebuild launcher with current command (cached launcher has old command) + const [executable, ...launcherArgs] = cachedWorkingLauncher; + if (executable) { + // The last element is the command, replace it + const newLauncher = [executable, ...launcherArgs.slice(0, -1), command]; + try { + const result = await spawnWithLauncher(newLauncher, options); + return result; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code !== "ENOENT") { + throw error; + } + // Cached shell no longer available, clear cache and try all + cachedWorkingLauncher = null; + } + } + } + + const launchers = buildShellLaunchers(command); + if (launchers.length === 0) { + throw new Error("No shell launchers available"); + } + + const tried: string[] = []; + let lastError: Error | null = null; + + for (const launcher of launchers) { + try { + const result = await spawnWithLauncher(launcher, options); + // Cache this working launcher for future use + cachedWorkingLauncher = launcher; + return result; + } catch (error) { + const err = error as NodeJS.ErrnoException; + if (err.code === "ENOENT") { + tried.push(launcher[0] || "unknown"); + lastError = err; + continue; + } + // Non-ENOENT errors should be thrown immediately + throw error; + } + } + + // All launchers failed with ENOENT + const suffix = tried.filter(Boolean).join(", "); + const reason = lastError?.message || "Shell unavailable"; + throw new Error(suffix ? `${reason} (tried: ${suffix})` : reason); +} + interface BashArgs { command: string; timeout?: number; @@ -200,8 +242,15 @@ export async function bash(args: BashArgs): Promise { if (run_in_background) { const bashId = getNextBashId(); - const { executable, args } = getShellConfig(); - const childProcess = spawn(executable, args(command), { + const launcher = getBackgroundLauncher(command); + const [executable, ...launcherArgs] = launcher; + if (!executable) { + return { + content: [{ type: "text", text: "No shell available" }], + status: "error", + }; + } + const childProcess = spawn(executable, launcherArgs, { shell: false, cwd: userCwd, env: getShellEnv(), diff --git a/src/tools/impl/shellLaunchers.ts b/src/tools/impl/shellLaunchers.ts index d94cc2c..cfe7bc6 100644 --- a/src/tools/impl/shellLaunchers.ts +++ b/src/tools/impl/shellLaunchers.ts @@ -45,22 +45,45 @@ function unixLaunchers(command: string): string[][] { if (!trimmed) return []; const launchers: string[][] = []; const seen = new Set(); + + // On macOS, ALWAYS prefer zsh first due to bash 3.2's HEREDOC parsing bug + // with odd numbers of apostrophes. This takes precedence over $SHELL. + if (process.platform === "darwin") { + pushUnique(launchers, seen, ["/bin/zsh", "-c", trimmed]); + } + + // Try user's preferred shell from $SHELL environment variable const envShell = process.env.SHELL?.trim(); if (envShell) { pushUnique(launchers, seen, [envShell, "-lc", trimmed]); pushUnique(launchers, seen, [envShell, "-c", trimmed]); } - const defaults: string[][] = [ - ["/bin/bash", "-lc", trimmed], - ["/usr/bin/bash", "-lc", trimmed], - ["/bin/zsh", "-lc", trimmed], - ["/bin/sh", "-c", trimmed], - ["/bin/ash", "-c", trimmed], - ["/usr/bin/env", "bash", "-lc", trimmed], - ["/usr/bin/env", "zsh", "-lc", trimmed], - ["/usr/bin/env", "sh", "-c", trimmed], - ["/usr/bin/env", "ash", "-c", trimmed], - ]; + + // Fallback defaults - zsh preferred on macOS, bash preferred on Linux + const defaults: string[][] = + process.platform === "darwin" + ? [ + ["/bin/zsh", "-lc", trimmed], + ["/bin/bash", "-lc", trimmed], + ["/usr/bin/bash", "-lc", trimmed], + ["/bin/sh", "-c", trimmed], + ["/bin/ash", "-c", trimmed], + ["/usr/bin/env", "zsh", "-lc", trimmed], + ["/usr/bin/env", "bash", "-lc", trimmed], + ["/usr/bin/env", "sh", "-c", trimmed], + ["/usr/bin/env", "ash", "-c", trimmed], + ] + : [ + ["/bin/bash", "-lc", trimmed], + ["/usr/bin/bash", "-lc", trimmed], + ["/bin/zsh", "-lc", trimmed], + ["/bin/sh", "-c", trimmed], + ["/bin/ash", "-c", trimmed], + ["/usr/bin/env", "bash", "-lc", trimmed], + ["/usr/bin/env", "zsh", "-lc", trimmed], + ["/usr/bin/env", "sh", "-c", trimmed], + ["/usr/bin/env", "ash", "-c", trimmed], + ]; for (const entry of defaults) { pushUnique(launchers, seen, entry); }