Project Files
src / executor.ts
import * as child_process from "child_process";
import * as fs from "fs";
import * as os from "os";
import { EXEC_DEFAULT_TIMEOUT_MS, EXEC_MAX_TIMEOUT_MS } from "./constants";
import { resolveCwd, resolvePwshPath, normalizeLineEndings } from "./utils";
import { sanitizeCommand, validateExecutionParams } from "./securityEnhanced";
export type Platform = "windows" | "macos" | "linux";
export interface ShellInfo {
path: string;
args: string[];
platform: Platform;
isPowerShell: boolean;
}
export interface ExecResult {
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
truncated: boolean;
shell: string;
platform: Platform;
}
export interface ExecOptions {
cwd?: string;
timeoutMs?: number;
shellPath?: string;
windowsShell?: "powershell" | "cmd";
env?: Record<string, string>;
stdin?: string;
maxOutputBytes?: number;
}
const MAX_TIMER_MS = Number.MAX_SAFE_INTEGER;
// --- Retry helpers ---
export interface RetryOptions {
maxRetries?: number;
retryDelayMs?: number;
retryOnCodes?: string[];
}
const DEFAULT_RETRY_OPTIONS: RetryOptions = {
maxRetries: 2,
retryDelayMs: 500,
retryOnCodes: ["ETIMEDOUT", "ECONNRESET", "EPIPE"],
};
/**
* Retry a promise-producing function with exponential back-off.
* Returns the first successful result, or the last error after all retries.
*/
export async function withRetry<T>(
fn: () => Promise<T>,
options: RetryOptions = DEFAULT_RETRY_OPTIONS,
): Promise<T> {
const { maxRetries = 2, retryDelayMs = 500, retryOnCodes = DEFAULT_RETRY_OPTIONS.retryOnCodes! } = options;
let lastError: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
const code = (err as NodeJS.ErrnoException)?.code;
if (attempt < maxRetries && (code == null || retryOnCodes.includes(String(code)))) {
await new Promise((r) => setTimeout(r, retryDelayMs * (attempt + 1)));
}
}
}
throw lastError;
}
// --- Platform detection ---
export function detectPlatform(): Platform {
if (process.platform === "win32") return "windows";
if (process.platform === "darwin") return "macos";
return "linux";
}
// --- Shell detection (cached) ---
const SHELL_CACHE = new Map<string, ShellInfo>();
function getCachedShell(shellPath: string | undefined): ShellInfo | undefined {
return SHELL_CACHE.get(shellPath || "");
}
function setCachedShell(shellPath: string, info: ShellInfo): void {
SHELL_CACHE.set(shellPath || "", info);
}
export function detectShell(shellPath?: string): ShellInfo {
const cached = getCachedShell(shellPath);
if (cached) return cached;
const platform = detectPlatform();
let shell: string;
if (shellPath) {
shell = shellPath;
} else if (process.platform === "win32") {
shell = resolvePwshPath() || "";
} else {
shell = process.env.SHELL || "/bin/sh";
}
const isPowerShell =
platform === "windows" && (shell.includes("powershell") || shell.includes("pwsh"));
const info: ShellInfo = { path: shell, args: [], platform, isPowerShell };
setCachedShell(shellPath ?? "", info);
return info;
}
// --- execCommand (with sanitisation + retry) ---
export function execCommand(
command: string,
options: ExecOptions = {},
): Promise<ExecResult> {
const {
cwd = resolveCwd(options.cwd),
timeoutMs = EXEC_DEFAULT_TIMEOUT_MS,
shellPath = "",
windowsShell = "powershell",
env = {},
stdin,
maxOutputBytes = 10 * 1024 * 1024,
} = options;
// 1. Sanitise command
const sanitised = sanitizeCommand(command);
if (!sanitised.valid) {
return Promise.reject(new Error(sanitised.error ?? "Invalid command"));
}
// 2. Validate params
const validated = validateExecutionParams({
timeoutMs,
maxOutputBytes,
cwd,
env,
stdin,
});
if (!validated.valid) {
return Promise.reject(new Error(validated.error ?? "Invalid parameters"));
}
const shell = detectShell(shellPath);
// Build the final command string
const effectiveCommand = shell.isPowerShell
? command
: `set -o pipefail && ${command}`;
const execFn = (
cmd: string,
args: string[],
opts: child_process.ExecFileOptions,
): Promise<{ stdout: string; stderr: string }> =>
new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve({ stdout: "", stderr: "Command timed out" });
}, timeoutMs);
const child = child_process.execFile(cmd, args, opts, (error, stdout, stderr) => {
clearTimeout(timeoutId);
if (error) {
if (error.killed) {
resolve({ stdout: "", stderr: "Command was killed due to timeout" });
} else {
resolve({
stdout: normalizeLineEndings(stdout?.toString() ?? ""),
stderr: normalizeLineEndings(stderr?.toString() ?? error.message),
});
}
} else {
resolve({
stdout: normalizeLineEndings(stdout),
stderr: normalizeLineEndings(stderr),
});
}
});
if (stdin) {
child.stdin?.end(stdin);
}
});
// Execute with retry
return withRetry(
() =>
execFn(
shell.path || (windowsShell === "cmd" ? "cmd.exe" : "/bin/sh"),
shell.isPowerShell
? ["-NoProfile", "-Command", effectiveCommand]
: windowsShell === "cmd"
? ["/C", command]
: [
"-c",
effectiveCommand,
],
{
cwd,
timeout: Math.min(timeoutMs, MAX_TIMER_MS),
env: { ...process.env, ...env },
windowsVerbatimArguments: true,
maxBuffer: maxOutputBytes,
},
),
).then(({ stdout, stderr }) => {
const exitCode = 0; // execFile doesn't expose exitCode in callback
return {
stdout,
stderr,
exitCode,
timedOut: stderr.includes("Command timed out") || stderr.includes("Command was killed"),
truncated: false,
shell: shell.path || (windowsShell === "cmd" ? "cmd.exe" : "/bin/sh"),
platform: shell.platform,
};
});
}
// --- runProcess (with retry) ---
export interface RunProcessResult {
stdout: string;
stderr: string;
exitCode: number;
timedOut: boolean;
platform: Platform;
}
export function runProcess(
command: string,
args: string[],
options: ExecOptions = {},
): Promise<RunProcessResult> {
const {
cwd = resolveCwd(options.cwd),
timeoutMs = EXEC_DEFAULT_TIMEOUT_MS,
env = {},
stdin,
maxOutputBytes = 10 * 1024 * 1024,
} = options;
const validated = validateExecutionParams({ timeoutMs, maxOutputBytes, cwd, env, stdin });
if (!validated.valid) {
return Promise.reject(new Error(validated.error ?? "Invalid parameters"));
}
const platform = detectPlatform();
const spawnFn = (): Promise<RunProcessResult> =>
new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
resolve({
stdout: "",
stderr: "Process timed out",
exitCode: -1,
timedOut: true,
platform,
});
}, timeoutMs);
let resolved = false;
const resolveOnce = (r: RunProcessResult) => {
if (!resolved) {
resolved = true;
clearTimeout(timeoutId);
resolve(r);
}
};
const proc = child_process.spawn(command, args, {
cwd,
timeout: Math.min(timeoutMs, MAX_TIMER_MS),
env: { ...process.env, ...env },
stdio: ["pipe", "pipe", "pipe"],
});
const stdoutBuf: Buffer[] = [];
const stderrBuf: Buffer[] = [];
proc.stdout?.on("data", (d: Buffer) => stdoutBuf.push(d));
proc.stderr?.on("data", (d: Buffer) => stderrBuf.push(d));
proc.on("error", (err: NodeJS.ErrnoException) => {
resolveOnce({
stdout: normalizeLineEndings(Buffer.concat(stdoutBuf).toString()),
stderr: err.message,
exitCode: err.code ? (typeof err.code === "number" ? err.code : parseInt(err.code, 10)) : 1,
timedOut: false,
platform,
});
});
proc.on("close", (code) => {
resolveOnce({
stdout: normalizeLineEndings(Buffer.concat(stdoutBuf).toString()),
stderr: normalizeLineEndings(Buffer.concat(stderrBuf).toString()),
exitCode: code ?? 1,
timedOut: false,
platform,
});
});
if (stdin) {
proc.stdin?.end(stdin);
}
});
return withRetry(spawnFn);
}