fix: add shell fallback for Windows PowerShell ENOENT errors (#482)
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user