fix: patch ci (#145)
This commit is contained in:
36
src/tests/tools/shell-command.test.ts
Normal file
36
src/tests/tools/shell-command.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
67
src/tools/impl/shellLaunchers.ts
Normal file
67
src/tools/impl/shellLaunchers.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user