fix: patch ci (#145)

This commit is contained in:
Charles Packer
2025-12-01 22:46:43 -08:00
committed by GitHub
parent bc558e5c5b
commit a30f170c30
4 changed files with 269 additions and 68 deletions

View File

@@ -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;
}
}
});

View File

@@ -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<ShellResult> {
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<ShellResult> {
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<ShellResult> {
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<ShellResult> {
.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<ShellResult> {
});
});
}
/**
* 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<ShellResult> {
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;
}

View File

@@ -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<ShellCommandResult> {
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);
}

View File

@@ -0,0 +1,67 @@
const SEP = "\u0000";
function pushUnique(
list: string[][],
seen: Set<string>,
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<string>();
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<string>();
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);
}