diff --git a/src/tests/tools/shell-command.test.ts b/src/tests/tools/shell-command.test.ts new file mode 100644 index 0000000..e07b5bd --- /dev/null +++ b/src/tests/tools/shell-command.test.ts @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { shell_command } from "../../tools/impl/ShellCommand.js"; + +test("shell_command executes basic echo", async () => { + const result = await shell_command({ command: "echo shell-basic" }); + expect(result.output).toContain("shell-basic"); +}); + +test("shell_command falls back when preferred shell is missing", async () => { + const marker = "shell-fallback"; + if (process.platform === "win32") { + const originalUpper = process.env.COMSPEC; + const originalLower = process.env.ComSpec; + process.env.COMSPEC = "C:/missing-shell.exe"; + process.env.ComSpec = "C:/missing-shell.exe"; + try { + const result = await shell_command({ command: `echo ${marker}` }); + expect(result.output).toContain(marker); + } finally { + if (originalUpper === undefined) delete process.env.COMSPEC; + else process.env.COMSPEC = originalUpper; + if (originalLower === undefined) delete process.env.ComSpec; + else process.env.ComSpec = originalLower; + } + } else { + const original = process.env.SHELL; + process.env.SHELL = "/nonexistent-shell"; + try { + const result = await shell_command({ command: `echo ${marker}` }); + expect(result.output).toContain(marker); + } finally { + if (original === undefined) delete process.env.SHELL; + else process.env.SHELL = original; + } + } +}); diff --git a/src/tools/impl/Shell.ts b/src/tools/impl/Shell.ts index f6a1521..8069e39 100644 --- a/src/tools/impl/Shell.ts +++ b/src/tools/impl/Shell.ts @@ -1,8 +1,14 @@ import { spawn } from "node:child_process"; import * as path from "node:path"; import { getShellEnv } from "./shellEnv.js"; +import { buildShellLaunchers } from "./shellLaunchers.js"; import { validateRequiredParams } from "./validation.js"; +export class ShellExecutionError extends Error { + code?: string; + executable?: string; +} + interface ShellArgs { command: string[]; workdir?: string; @@ -19,39 +25,28 @@ interface ShellResult { const DEFAULT_TIMEOUT = 120000; -/** - * Codex-style shell tool. - * Runs an array of shell arguments using execvp-style semantics. - * Typically called with ["bash", "-lc", "..."] for shell commands. - */ -export async function shell(args: ShellArgs): Promise { - validateRequiredParams(args, ["command"], "shell"); - - const { command, workdir, timeout_ms } = args; - if (!Array.isArray(command) || command.length === 0) { - throw new Error("command must be a non-empty array of strings"); - } - - const [executable, ...execArgs] = command; - if (!executable) { - throw new Error("command must be a non-empty array of strings"); - } - const timeout = timeout_ms ?? DEFAULT_TIMEOUT; - - // Determine working directory - const cwd = workdir - ? path.isAbsolute(workdir) - ? workdir - : path.resolve(process.env.USER_CWD || process.cwd(), workdir) - : process.env.USER_CWD || process.cwd(); +type SpawnContext = { + command: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + timeout: number; +}; +function runProcess(context: SpawnContext): Promise { return new Promise((resolve, reject) => { + const { command, cwd, env, timeout } = context; + const [executable, ...execArgs] = command; + if (!executable) { + reject(new ShellExecutionError("Executable is required")); + return; + } + const stdoutChunks: Buffer[] = []; const stderrChunks: Buffer[] = []; const child = spawn(executable, execArgs, { cwd, - env: getShellEnv(), + env, stdio: ["ignore", "pipe", "pipe"], }); @@ -68,9 +63,16 @@ export async function shell(args: ShellArgs): Promise { stderrChunks.push(chunk); }); - child.on("error", (err: Error) => { + child.on("error", (err: NodeJS.ErrnoException) => { clearTimeout(timeoutId); - reject(new Error(`Failed to execute command: ${err.message}`)); + const execError = new ShellExecutionError( + err?.code === "ENOENT" + ? `Executable not found: ${executable}` + : `Failed to execute command: ${err?.message || "unknown error"}`, + ); + execError.code = err?.code; + execError.executable = executable; + reject(execError); }); child.on("close", (code: number | null) => { @@ -86,11 +88,9 @@ export async function shell(args: ShellArgs): Promise { .split("\n") .filter((line) => line.length > 0); - // Combine stdout and stderr for output const output = [stdoutText, stderrText].filter(Boolean).join("\n").trim(); if (code !== 0 && code !== null) { - // Command failed but we still return the output resolve({ output: output || `Command exited with code ${code}`, stdout: stdoutLines, @@ -106,3 +106,106 @@ export async function shell(args: ShellArgs): Promise { }); }); } + +/** + * Codex-style shell tool. + * Runs an array of shell arguments using execvp-style semantics. + * Typically called with ["bash", "-lc", "..."] for shell commands. + */ +export async function shell(args: ShellArgs): Promise { + validateRequiredParams(args, ["command"], "shell"); + + const { command, workdir, timeout_ms } = args; + if (!Array.isArray(command) || command.length === 0) { + throw new Error("command must be a non-empty array of strings"); + } + + const timeout = timeout_ms ?? DEFAULT_TIMEOUT; + const cwd = workdir + ? path.isAbsolute(workdir) + ? workdir + : path.resolve(process.env.USER_CWD || process.cwd(), workdir) + : process.env.USER_CWD || process.cwd(); + + const context: SpawnContext = { + command, + cwd, + env: getShellEnv(), + timeout, + }; + + try { + return await runProcess(context); + } catch (error) { + if (error instanceof ShellExecutionError && error.code === "ENOENT") { + for (const fallback of buildFallbackCommands(command)) { + try { + return await runProcess({ ...context, command: fallback }); + } catch (retryError) { + if ( + retryError instanceof ShellExecutionError && + retryError.code === "ENOENT" + ) { + continue; + } + throw retryError; + } + } + } + throw error; + } +} + +function buildFallbackCommands(command: string[]): string[][] { + if (!command.length) return []; + const first = command[0]; + if (!first) return []; + if (!isShellExecutableName(first)) return []; + const script = extractShellScript(command); + if (!script) return []; + const launchers = buildShellLaunchers(script); + return launchers.filter((launcher) => !arraysEqual(launcher, command)); +} + +function arraysEqual(a: string[], b: string[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function isShellExecutableName(name: string): boolean { + const normalized = name.replace(/\\/g, "/").toLowerCase(); + if (/(^|\/)(ba|z|a|da)?sh$/.test(normalized)) { + return true; + } + if (normalized.endsWith("cmd.exe")) { + return true; + } + if (normalized.includes("powershell")) { + return true; + } + if (normalized.includes("pwsh")) { + return true; + } + return false; +} + +function extractShellScript(command: string[]): string | null { + for (let i = 1; i < command.length; i += 1) { + const token = command[i]; + if (!token) continue; + const normalized = token.toLowerCase(); + if ( + normalized === "-c" || + normalized === "-lc" || + normalized === "/c" || + ((normalized.startsWith("-") || normalized.startsWith("/")) && + normalized.endsWith("c")) + ) { + return command[i + 1] ?? null; + } + } + return null; +} diff --git a/src/tools/impl/ShellCommand.ts b/src/tools/impl/ShellCommand.ts index 48a70f3..8528e7e 100644 --- a/src/tools/impl/ShellCommand.ts +++ b/src/tools/impl/ShellCommand.ts @@ -1,4 +1,5 @@ -import { bash } from "./Bash.js"; +import { ShellExecutionError, shell } from "./Shell.js"; +import { buildShellLaunchers } from "./shellLaunchers.js"; import { validateRequiredParams } from "./validation.js"; interface ShellCommandArgs { @@ -24,47 +25,41 @@ export async function shell_command( ): Promise { validateRequiredParams(args, ["command"], "shell_command"); - const { command, workdir, timeout_ms, justification: description } = args; - - // Reuse Bash implementation for execution, but honor the requested workdir - const previousUserCwd = process.env.USER_CWD; - if (workdir) { - process.env.USER_CWD = workdir; + const { + command, + workdir, + timeout_ms, + with_escalated_permissions, + justification, + } = args; + const launchers = buildShellLaunchers(command); + if (launchers.length === 0) { + throw new Error("Command must be a non-empty string"); } - try { - const result = await bash({ - command, - timeout: timeout_ms ?? 120000, - description, - run_in_background: false, - }); + const tried: string[] = []; + let lastError: Error | null = null; - const text = (result.content ?? []) - .map((item) => - "text" in item && typeof item.text === "string" ? item.text : "", - ) - .filter(Boolean) - .join("\n"); - - const stdout = text ? text.split("\n") : []; - const stderr = - result.status === "error" - ? ["Command reported an error. See output for details."] - : []; - - return { - output: text, - stdout, - stderr, - }; - } finally { - if (workdir) { - if (previousUserCwd === undefined) { - delete process.env.USER_CWD; - } else { - process.env.USER_CWD = previousUserCwd; + for (const launcher of launchers) { + try { + return await shell({ + command: launcher, + workdir, + timeout_ms, + with_escalated_permissions, + justification, + }); + } catch (error) { + if (error instanceof ShellExecutionError && error.code === "ENOENT") { + tried.push(launcher[0] || ""); + lastError = error; + continue; } + throw error; } } + + const suffix = tried.filter(Boolean).join(", "); + const reason = lastError?.message || "Shell unavailable"; + throw new Error(suffix ? `${reason} (tried: ${suffix})` : reason); } diff --git a/src/tools/impl/shellLaunchers.ts b/src/tools/impl/shellLaunchers.ts new file mode 100644 index 0000000..c2b14b4 --- /dev/null +++ b/src/tools/impl/shellLaunchers.ts @@ -0,0 +1,67 @@ +const SEP = "\u0000"; + +function pushUnique( + list: string[][], + seen: Set, + entry: string[], +): void { + if (!entry.length || !entry[0]) return; + const key = entry.join(SEP); + if (seen.has(key)) return; + seen.add(key); + list.push(entry); +} + +function windowsLaunchers(command: string): string[][] { + const trimmed = command.trim(); + if (!trimmed) return []; + const launchers: string[][] = []; + const seen = new Set(); + const envComSpecRaw = process.env.ComSpec || process.env.COMSPEC; + const envComSpec = envComSpecRaw?.trim(); + if (envComSpec) { + pushUnique(launchers, seen, [envComSpec, "/d", "/s", "/c", trimmed]); + } + pushUnique(launchers, seen, ["cmd.exe", "/d", "/s", "/c", trimmed]); + pushUnique(launchers, seen, [ + "powershell.exe", + "-NoProfile", + "-Command", + trimmed, + ]); + pushUnique(launchers, seen, ["pwsh", "-NoProfile", "-Command", trimmed]); + return launchers; +} + +function unixLaunchers(command: string): string[][] { + const trimmed = command.trim(); + if (!trimmed) return []; + const launchers: string[][] = []; + const seen = new Set(); + 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], + ]; + for (const entry of defaults) { + pushUnique(launchers, seen, entry); + } + return launchers; +} + +export function buildShellLaunchers(command: string): string[][] { + return process.platform === "win32" + ? windowsLaunchers(command) + : unixLaunchers(command); +}