fix: add shell fallback for Windows PowerShell ENOENT errors (#482)

This commit is contained in:
Charles Packer
2026-01-06 21:13:40 -08:00
committed by GitHub
parent 2533df1df9
commit 807bd362f2
2 changed files with 137 additions and 65 deletions

View File

@@ -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<BashResult> {
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(),

View File

@@ -45,22 +45,45 @@ function unixLaunchers(command: string): string[][] {
if (!trimmed) return [];
const launchers: string[][] = [];
const seen = new Set<string>();
// 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);
}