Files
llmx/codex-cli/src/utils/agent/exec.ts

114 lines
3.8 KiB
TypeScript
Raw Normal View History

import type { ExecInput, ExecResult } from "./sandbox/interface.js";
import type { SpawnOptions } from "child_process";
import type { ParseEntry } from "shell-quote";
import { process_patch } from "./apply-patch.js";
import { SandboxType } from "./sandbox/interface.js";
import { execWithSeatbelt } from "./sandbox/macos-seatbelt.js";
import { exec as rawExec } from "./sandbox/raw-exec.js";
import { formatCommandForDisplay } from "../../format-command.js";
import fs from "fs";
import os from "os";
import path from "path";
import { parse } from "shell-quote";
import { resolvePathAgainstWorkdir } from "src/approvals.js";
const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
function requiresShell(cmd: Array<string>): boolean {
// If the command is a single string that contains shell operators,
// it needs to be run with shell: true
if (cmd.length === 1 && cmd[0] !== undefined) {
const tokens = parse(cmd[0]) as Array<ParseEntry>;
return tokens.some((token) => typeof token === "object" && "op" in token);
}
// If the command is split into multiple arguments, we don't need shell: true
// even if one of the arguments is a shell operator like '|'
return false;
}
/**
* This function should never return a rejected promise: errors should be
* mapped to a non-zero exit code and the error message should be in stderr.
*/
export function exec(
{
cmd,
workdir,
timeoutInMillis,
additionalWritableRoots,
}: ExecInput & { additionalWritableRoots: ReadonlyArray<string> },
sandbox: SandboxType,
abortSignal?: AbortSignal,
): Promise<ExecResult> {
// This is a temporary measure to understand what are the common base commands
// until we start persisting and uploading rollouts
const opts: SpawnOptions = {
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
...(requiresShell(cmd) ? { shell: true } : {}),
...(workdir ? { cwd: workdir } : {}),
};
// Merge default writable roots with any user-specified ones.
const writableRoots = [
process.cwd(),
os.tmpdir(),
...additionalWritableRoots,
];
if (sandbox === SandboxType.MACOS_SEATBELT) {
return execWithSeatbelt(cmd, opts, writableRoots, abortSignal);
}
// SandboxType.NONE (or any other) falls back to the raw exec implementation
return rawExec(cmd, opts, abortSignal);
}
export function execApplyPatch(
patchText: string,
workdir: string | undefined = undefined,
): ExecResult {
// This is a temporary measure to understand what are the common base commands
// until we start persisting and uploading rollouts
try {
const result = process_patch(
patchText,
(p) => fs.readFileSync(resolvePathAgainstWorkdir(p, workdir), "utf8"),
(p, c) => {
const resolvedPath = resolvePathAgainstWorkdir(p, workdir);
// Ensure the parent directory exists before writing the file. This
// mirrors the behaviour of the standalone apply_patch CLI (see
// write_file() in apply-patch.ts) and prevents errors when adding a
// new file in a notyetcreated subdirectory.
const dir = path.dirname(resolvedPath);
if (dir !== ".") {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(resolvedPath, c, "utf8");
},
(p) => fs.unlinkSync(resolvePathAgainstWorkdir(p, workdir)),
);
return {
stdout: result,
stderr: "",
exitCode: 0,
};
} catch (error: unknown) {
// @ts-expect-error error might not be an object or have a message property.
const stderr = String(error.message ?? error);
return {
stdout: "",
stderr: stderr,
exitCode: 1,
};
}
}
export function getBaseCmd(cmd: Array<string>): string {
const formattedCommand = formatCommandForDisplay(cmd);
return formattedCommand.split(" ")[0] || cmd[0] || "<unknown>";
}