1022
codex-cli/src/utils/agent/agent-loop.ts
Normal file
1022
codex-cli/src/utils/agent/agent-loop.ts
Normal file
File diff suppressed because it is too large
Load Diff
644
codex-cli/src/utils/agent/apply-patch.ts
Normal file
644
codex-cli/src/utils/agent/apply-patch.ts
Normal file
@@ -0,0 +1,644 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Types & Models
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export enum ActionType {
|
||||
ADD = "add",
|
||||
DELETE = "delete",
|
||||
UPDATE = "update",
|
||||
}
|
||||
|
||||
export interface FileChange {
|
||||
type: ActionType;
|
||||
old_content?: string | null;
|
||||
new_content?: string | null;
|
||||
move_path?: string | null;
|
||||
}
|
||||
|
||||
export interface Commit {
|
||||
changes: Record<string, FileChange>;
|
||||
}
|
||||
|
||||
export function assemble_changes(
|
||||
orig: Record<string, string | null>,
|
||||
updatedFiles: Record<string, string | null>,
|
||||
): Commit {
|
||||
const commit: Commit = { changes: {} };
|
||||
for (const [p, newContent] of Object.entries(updatedFiles)) {
|
||||
const oldContent = orig[p];
|
||||
if (oldContent === newContent) {
|
||||
continue;
|
||||
}
|
||||
if (oldContent !== undefined && newContent !== undefined) {
|
||||
commit.changes[p] = {
|
||||
type: ActionType.UPDATE,
|
||||
old_content: oldContent,
|
||||
new_content: newContent,
|
||||
};
|
||||
} else if (newContent !== undefined) {
|
||||
commit.changes[p] = {
|
||||
type: ActionType.ADD,
|
||||
new_content: newContent,
|
||||
};
|
||||
} else if (oldContent !== undefined) {
|
||||
commit.changes[p] = {
|
||||
type: ActionType.DELETE,
|
||||
old_content: oldContent,
|
||||
};
|
||||
} else {
|
||||
throw new Error("Unexpected state in assemble_changes");
|
||||
}
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Patch‑related structures
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export interface Chunk {
|
||||
orig_index: number; // line index of the first line in the original file
|
||||
del_lines: Array<string>;
|
||||
ins_lines: Array<string>;
|
||||
}
|
||||
|
||||
export interface PatchAction {
|
||||
type: ActionType;
|
||||
new_file?: string | null;
|
||||
chunks: Array<Chunk>;
|
||||
move_path?: string | null;
|
||||
}
|
||||
|
||||
export interface Patch {
|
||||
actions: Record<string, PatchAction>;
|
||||
}
|
||||
|
||||
export class DiffError extends Error {}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Parser (patch text -> Patch)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
class Parser {
|
||||
current_files: Record<string, string>;
|
||||
lines: Array<string>;
|
||||
index = 0;
|
||||
patch: Patch = { actions: {} };
|
||||
fuzz = 0;
|
||||
|
||||
constructor(currentFiles: Record<string, string>, lines: Array<string>) {
|
||||
this.current_files = currentFiles;
|
||||
this.lines = lines;
|
||||
}
|
||||
|
||||
private is_done(prefixes?: Array<string>): boolean {
|
||||
if (this.index >= this.lines.length) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
prefixes &&
|
||||
prefixes.some((p) => this.lines[this.index]!.startsWith(p))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private startswith(prefix: string | Array<string>): boolean {
|
||||
const prefixes = Array.isArray(prefix) ? prefix : [prefix];
|
||||
return prefixes.some((p) => this.lines[this.index]!.startsWith(p));
|
||||
}
|
||||
|
||||
private read_str(prefix = "", returnEverything = false): string {
|
||||
if (this.index >= this.lines.length) {
|
||||
throw new DiffError(`Index: ${this.index} >= ${this.lines.length}`);
|
||||
}
|
||||
if (this.lines[this.index]!.startsWith(prefix)) {
|
||||
const text = returnEverything
|
||||
? this.lines[this.index]
|
||||
: this.lines[this.index]!.slice(prefix.length);
|
||||
this.index += 1;
|
||||
return text ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
parse(): void {
|
||||
while (!this.is_done(["*** End Patch"])) {
|
||||
let path = this.read_str("*** Update File: ");
|
||||
if (path) {
|
||||
if (this.patch.actions[path]) {
|
||||
throw new DiffError(`Update File Error: Duplicate Path: ${path}`);
|
||||
}
|
||||
const moveTo = this.read_str("*** Move to: ");
|
||||
if (!(path in this.current_files)) {
|
||||
throw new DiffError(`Update File Error: Missing File: ${path}`);
|
||||
}
|
||||
const text = this.current_files[path];
|
||||
const action = this.parse_update_file(text ?? "");
|
||||
action.move_path = moveTo || undefined;
|
||||
this.patch.actions[path] = action;
|
||||
continue;
|
||||
}
|
||||
path = this.read_str("*** Delete File: ");
|
||||
if (path) {
|
||||
if (this.patch.actions[path]) {
|
||||
throw new DiffError(`Delete File Error: Duplicate Path: ${path}`);
|
||||
}
|
||||
if (!(path in this.current_files)) {
|
||||
throw new DiffError(`Delete File Error: Missing File: ${path}`);
|
||||
}
|
||||
this.patch.actions[path] = { type: ActionType.DELETE, chunks: [] };
|
||||
continue;
|
||||
}
|
||||
path = this.read_str("*** Add File: ");
|
||||
if (path) {
|
||||
if (this.patch.actions[path]) {
|
||||
throw new DiffError(`Add File Error: Duplicate Path: ${path}`);
|
||||
}
|
||||
if (path in this.current_files) {
|
||||
throw new DiffError(`Add File Error: File already exists: ${path}`);
|
||||
}
|
||||
this.patch.actions[path] = this.parse_add_file();
|
||||
continue;
|
||||
}
|
||||
throw new DiffError(`Unknown Line: ${this.lines[this.index]}`);
|
||||
}
|
||||
if (!this.startswith("*** End Patch")) {
|
||||
throw new DiffError("Missing End Patch");
|
||||
}
|
||||
this.index += 1;
|
||||
}
|
||||
|
||||
private parse_update_file(text: string): PatchAction {
|
||||
const action: PatchAction = { type: ActionType.UPDATE, chunks: [] };
|
||||
const fileLines = text.split("\n");
|
||||
let index = 0;
|
||||
|
||||
while (
|
||||
!this.is_done([
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
"*** End of File",
|
||||
])
|
||||
) {
|
||||
const defStr = this.read_str("@@ ");
|
||||
let sectionStr = "";
|
||||
if (!defStr && this.lines[this.index] === "@@") {
|
||||
sectionStr = this.lines[this.index]!;
|
||||
this.index += 1;
|
||||
}
|
||||
if (!(defStr || sectionStr || index === 0)) {
|
||||
throw new DiffError(`Invalid Line:\n${this.lines[this.index]}`);
|
||||
}
|
||||
if (defStr.trim()) {
|
||||
let found = false;
|
||||
if (!fileLines.slice(0, index).some((s) => s === defStr)) {
|
||||
for (let i = index; i < fileLines.length; i++) {
|
||||
if (fileLines[i] === defStr) {
|
||||
index = i + 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
!found &&
|
||||
!fileLines.slice(0, index).some((s) => s.trim() === defStr.trim())
|
||||
) {
|
||||
for (let i = index; i < fileLines.length; i++) {
|
||||
if (fileLines[i]!.trim() === defStr.trim()) {
|
||||
index = i + 1;
|
||||
this.fuzz += 1;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const [nextChunkContext, chunks, endPatchIndex, eof] = peek_next_section(
|
||||
this.lines,
|
||||
this.index,
|
||||
);
|
||||
const [newIndex, fuzz] = find_context(
|
||||
fileLines,
|
||||
nextChunkContext,
|
||||
index,
|
||||
eof,
|
||||
);
|
||||
if (newIndex === -1) {
|
||||
const ctxText = nextChunkContext.join("\n");
|
||||
if (eof) {
|
||||
throw new DiffError(`Invalid EOF Context ${index}:\n${ctxText}`);
|
||||
} else {
|
||||
throw new DiffError(`Invalid Context ${index}:\n${ctxText}`);
|
||||
}
|
||||
}
|
||||
this.fuzz += fuzz;
|
||||
for (const ch of chunks) {
|
||||
ch.orig_index += newIndex;
|
||||
action.chunks.push(ch);
|
||||
}
|
||||
index = newIndex + nextChunkContext.length;
|
||||
this.index = endPatchIndex;
|
||||
}
|
||||
return action;
|
||||
}
|
||||
|
||||
private parse_add_file(): PatchAction {
|
||||
const lines: Array<string> = [];
|
||||
while (
|
||||
!this.is_done([
|
||||
"*** End Patch",
|
||||
"*** Update File:",
|
||||
"*** Delete File:",
|
||||
"*** Add File:",
|
||||
])
|
||||
) {
|
||||
const s = this.read_str();
|
||||
if (!s.startsWith("+")) {
|
||||
throw new DiffError(`Invalid Add File Line: ${s}`);
|
||||
}
|
||||
lines.push(s.slice(1));
|
||||
}
|
||||
return {
|
||||
type: ActionType.ADD,
|
||||
new_file: lines.join("\n"),
|
||||
chunks: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function find_context_core(
|
||||
lines: Array<string>,
|
||||
context: Array<string>,
|
||||
start: number,
|
||||
): [number, number] {
|
||||
if (context.length === 0) {
|
||||
return [start, 0];
|
||||
}
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (lines.slice(i, i + context.length).join("\n") === context.join("\n")) {
|
||||
return [i, 0];
|
||||
}
|
||||
}
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (
|
||||
lines
|
||||
.slice(i, i + context.length)
|
||||
.map((s) => s.trimEnd())
|
||||
.join("\n") === context.map((s) => s.trimEnd()).join("\n")
|
||||
) {
|
||||
return [i, 1];
|
||||
}
|
||||
}
|
||||
for (let i = start; i < lines.length; i++) {
|
||||
if (
|
||||
lines
|
||||
.slice(i, i + context.length)
|
||||
.map((s) => s.trim())
|
||||
.join("\n") === context.map((s) => s.trim()).join("\n")
|
||||
) {
|
||||
return [i, 100];
|
||||
}
|
||||
}
|
||||
return [-1, 0];
|
||||
}
|
||||
|
||||
function find_context(
|
||||
lines: Array<string>,
|
||||
context: Array<string>,
|
||||
start: number,
|
||||
eof: boolean,
|
||||
): [number, number] {
|
||||
if (eof) {
|
||||
let [newIndex, fuzz] = find_context_core(
|
||||
lines,
|
||||
context,
|
||||
lines.length - context.length,
|
||||
);
|
||||
if (newIndex !== -1) {
|
||||
return [newIndex, fuzz];
|
||||
}
|
||||
[newIndex, fuzz] = find_context_core(lines, context, start);
|
||||
return [newIndex, fuzz + 10000];
|
||||
}
|
||||
return find_context_core(lines, context, start);
|
||||
}
|
||||
|
||||
function peek_next_section(
|
||||
lines: Array<string>,
|
||||
initialIndex: number,
|
||||
): [Array<string>, Array<Chunk>, number, boolean] {
|
||||
let index = initialIndex;
|
||||
const old: Array<string> = [];
|
||||
let delLines: Array<string> = [];
|
||||
let insLines: Array<string> = [];
|
||||
const chunks: Array<Chunk> = [];
|
||||
let mode: "keep" | "add" | "delete" = "keep";
|
||||
|
||||
while (index < lines.length) {
|
||||
const s = lines[index]!;
|
||||
if (
|
||||
s.startsWith("@@") ||
|
||||
s.startsWith("*** End Patch") ||
|
||||
s.startsWith("*** Update File:") ||
|
||||
s.startsWith("*** Delete File:") ||
|
||||
s.startsWith("*** Add File:") ||
|
||||
s.startsWith("*** End of File")
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (s === "***") {
|
||||
break;
|
||||
}
|
||||
if (s.startsWith("***")) {
|
||||
throw new DiffError(`Invalid Line: ${s}`);
|
||||
}
|
||||
index += 1;
|
||||
const lastMode: "keep" | "add" | "delete" = mode;
|
||||
let line = s;
|
||||
if (line[0] === "+") {
|
||||
mode = "add";
|
||||
} else if (line[0] === "-") {
|
||||
mode = "delete";
|
||||
} else if (line[0] === " ") {
|
||||
mode = "keep";
|
||||
} else {
|
||||
// Tolerate invalid lines where the leading whitespace is missing. This is necessary as
|
||||
// the model sometimes doesn't fully adhere to the spec and returns lines without leading
|
||||
// whitespace for context lines.
|
||||
mode = "keep";
|
||||
line = " " + line;
|
||||
|
||||
// TODO: Re-enable strict mode.
|
||||
// throw new DiffError(`Invalid Line: ${line}`)
|
||||
}
|
||||
|
||||
line = line.slice(1);
|
||||
if (mode === "keep" && lastMode !== mode) {
|
||||
if (insLines.length || delLines.length) {
|
||||
chunks.push({
|
||||
orig_index: old.length - delLines.length,
|
||||
del_lines: delLines,
|
||||
ins_lines: insLines,
|
||||
});
|
||||
}
|
||||
delLines = [];
|
||||
insLines = [];
|
||||
}
|
||||
if (mode === "delete") {
|
||||
delLines.push(line);
|
||||
old.push(line);
|
||||
} else if (mode === "add") {
|
||||
insLines.push(line);
|
||||
} else {
|
||||
old.push(line);
|
||||
}
|
||||
}
|
||||
if (insLines.length || delLines.length) {
|
||||
chunks.push({
|
||||
orig_index: old.length - delLines.length,
|
||||
del_lines: delLines,
|
||||
ins_lines: insLines,
|
||||
});
|
||||
}
|
||||
if (index < lines.length && lines[index] === "*** End of File") {
|
||||
index += 1;
|
||||
return [old, chunks, index, true];
|
||||
}
|
||||
return [old, chunks, index, false];
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// High‑level helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function text_to_patch(
|
||||
text: string,
|
||||
orig: Record<string, string>,
|
||||
): [Patch, number] {
|
||||
const lines = text.trim().split("\n");
|
||||
if (
|
||||
lines.length < 2 ||
|
||||
!(lines[0] ?? "").startsWith("*** Begin Patch") ||
|
||||
lines[lines.length - 1] !== "*** End Patch"
|
||||
) {
|
||||
throw new DiffError("Invalid patch text");
|
||||
}
|
||||
const parser = new Parser(orig, lines);
|
||||
parser.index = 1;
|
||||
parser.parse();
|
||||
return [parser.patch, parser.fuzz];
|
||||
}
|
||||
|
||||
export function identify_files_needed(text: string): Array<string> {
|
||||
const lines = text.trim().split("\n");
|
||||
const result = new Set<string>();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("*** Update File: ")) {
|
||||
result.add(line.slice("*** Update File: ".length));
|
||||
}
|
||||
if (line.startsWith("*** Delete File: ")) {
|
||||
result.add(line.slice("*** Delete File: ".length));
|
||||
}
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
export function identify_files_added(text: string): Array<string> {
|
||||
const lines = text.trim().split("\n");
|
||||
const result = new Set<string>();
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("*** Add File: ")) {
|
||||
result.add(line.slice("*** Add File: ".length));
|
||||
}
|
||||
}
|
||||
return [...result];
|
||||
}
|
||||
|
||||
function _get_updated_file(
|
||||
text: string,
|
||||
action: PatchAction,
|
||||
path: string,
|
||||
): string {
|
||||
if (action.type !== ActionType.UPDATE) {
|
||||
throw new Error("Expected UPDATE action");
|
||||
}
|
||||
const origLines = text.split("\n");
|
||||
const destLines: Array<string> = [];
|
||||
let origIndex = 0;
|
||||
for (const chunk of action.chunks) {
|
||||
if (chunk.orig_index > origLines.length) {
|
||||
throw new DiffError(
|
||||
`${path}: chunk.orig_index ${chunk.orig_index} > len(lines) ${origLines.length}`,
|
||||
);
|
||||
}
|
||||
if (origIndex > chunk.orig_index) {
|
||||
throw new DiffError(
|
||||
`${path}: orig_index ${origIndex} > chunk.orig_index ${chunk.orig_index}`,
|
||||
);
|
||||
}
|
||||
destLines.push(...origLines.slice(origIndex, chunk.orig_index));
|
||||
const delta = chunk.orig_index - origIndex;
|
||||
origIndex += delta;
|
||||
|
||||
// inserted lines
|
||||
if (chunk.ins_lines.length) {
|
||||
for (const l of chunk.ins_lines) {
|
||||
destLines.push(l);
|
||||
}
|
||||
}
|
||||
origIndex += chunk.del_lines.length;
|
||||
}
|
||||
destLines.push(...origLines.slice(origIndex));
|
||||
return destLines.join("\n");
|
||||
}
|
||||
|
||||
export function patch_to_commit(
|
||||
patch: Patch,
|
||||
orig: Record<string, string>,
|
||||
): Commit {
|
||||
const commit: Commit = { changes: {} };
|
||||
for (const [pathKey, action] of Object.entries(patch.actions)) {
|
||||
if (action.type === ActionType.DELETE) {
|
||||
commit.changes[pathKey] = {
|
||||
type: ActionType.DELETE,
|
||||
old_content: orig[pathKey],
|
||||
};
|
||||
} else if (action.type === ActionType.ADD) {
|
||||
commit.changes[pathKey] = {
|
||||
type: ActionType.ADD,
|
||||
new_content: action.new_file ?? "",
|
||||
};
|
||||
} else if (action.type === ActionType.UPDATE) {
|
||||
const newContent = _get_updated_file(orig[pathKey]!, action, pathKey);
|
||||
commit.changes[pathKey] = {
|
||||
type: ActionType.UPDATE,
|
||||
old_content: orig[pathKey],
|
||||
new_content: newContent,
|
||||
move_path: action.move_path ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return commit;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Filesystem helpers for Node environment
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
export function load_files(
|
||||
paths: Array<string>,
|
||||
openFn: (p: string) => string,
|
||||
): Record<string, string> {
|
||||
const orig: Record<string, string> = {};
|
||||
for (const p of paths) {
|
||||
try {
|
||||
orig[p] = openFn(p);
|
||||
} catch {
|
||||
// Convert any file read error into a DiffError so that callers
|
||||
// consistently receive DiffError for patch-related failures.
|
||||
throw new DiffError(`File not found: ${p}`);
|
||||
}
|
||||
}
|
||||
return orig;
|
||||
}
|
||||
|
||||
export function apply_commit(
|
||||
commit: Commit,
|
||||
writeFn: (p: string, c: string) => void,
|
||||
removeFn: (p: string) => void,
|
||||
): void {
|
||||
for (const [p, change] of Object.entries(commit.changes)) {
|
||||
if (change.type === ActionType.DELETE) {
|
||||
removeFn(p);
|
||||
} else if (change.type === ActionType.ADD) {
|
||||
writeFn(p, change.new_content ?? "");
|
||||
} else if (change.type === ActionType.UPDATE) {
|
||||
if (change.move_path) {
|
||||
writeFn(change.move_path, change.new_content ?? "");
|
||||
removeFn(p);
|
||||
} else {
|
||||
writeFn(p, change.new_content ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function process_patch(
|
||||
text: string,
|
||||
openFn: (p: string) => string,
|
||||
writeFn: (p: string, c: string) => void,
|
||||
removeFn: (p: string) => void,
|
||||
): string {
|
||||
if (!text.startsWith("*** Begin Patch")) {
|
||||
throw new DiffError("Patch must start with *** Begin Patch");
|
||||
}
|
||||
const paths = identify_files_needed(text);
|
||||
const orig = load_files(paths, openFn);
|
||||
const [patch, _fuzz] = text_to_patch(text, orig);
|
||||
const commit = patch_to_commit(patch, orig);
|
||||
apply_commit(commit, writeFn, removeFn);
|
||||
return "Done!";
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Default filesystem implementations
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
function open_file(p: string): string {
|
||||
return fs.readFileSync(p, "utf8");
|
||||
}
|
||||
|
||||
function write_file(p: string, content: string): void {
|
||||
if (path.isAbsolute(p)) {
|
||||
throw new DiffError("We do not support absolute paths.");
|
||||
}
|
||||
const parent = path.dirname(p);
|
||||
if (parent !== ".") {
|
||||
fs.mkdirSync(parent, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(p, content, "utf8");
|
||||
}
|
||||
|
||||
function remove_file(p: string): void {
|
||||
fs.unlinkSync(p);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// CLI mode. Not exported, executed only if run directly.
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
let patchText = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (chunk) => (patchText += chunk));
|
||||
process.stdin.on("end", () => {
|
||||
if (!patchText) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Please pass patch text through stdin");
|
||||
process.exit(1);
|
||||
}
|
||||
try {
|
||||
const result = process_patch(
|
||||
patchText,
|
||||
open_file,
|
||||
write_file,
|
||||
remove_file,
|
||||
);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(result);
|
||||
} catch (err: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
67
codex-cli/src/utils/agent/exec.ts
Normal file
67
codex-cli/src/utils/agent/exec.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { ExecInput, ExecResult } from "./sandbox/interface.js";
|
||||
import type { SpawnOptions } from "child_process";
|
||||
|
||||
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 "@lib/format-command.js";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 10_000; // 10 seconds
|
||||
|
||||
/**
|
||||
* 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 }: ExecInput,
|
||||
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 execForSandbox =
|
||||
sandbox === SandboxType.MACOS_SEATBELT ? execWithSeatbelt : rawExec;
|
||||
|
||||
const opts: SpawnOptions = {
|
||||
timeout: timeoutInMillis || DEFAULT_TIMEOUT_MS,
|
||||
...(workdir ? { cwd: workdir } : {}),
|
||||
};
|
||||
const writableRoots = [process.cwd(), os.tmpdir()];
|
||||
return execForSandbox(cmd, opts, writableRoots, abortSignal);
|
||||
}
|
||||
|
||||
export function execApplyPatch(patchText: string): 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(p, "utf8"),
|
||||
(p, c) => fs.writeFileSync(p, c, "utf8"),
|
||||
(p) => fs.unlinkSync(p),
|
||||
);
|
||||
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>";
|
||||
}
|
||||
315
codex-cli/src/utils/agent/handle-exec-command.ts
Normal file
315
codex-cli/src/utils/agent/handle-exec-command.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import type { CommandConfirmation } from "./agent-loop.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { ExecInput } from "./sandbox/interface.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "@lib/approvals.js";
|
||||
import type { ResponseInputItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { exec, execApplyPatch } from "./exec.js";
|
||||
import { isLoggingEnabled, log } from "./log.js";
|
||||
import { ReviewDecision } from "./review.js";
|
||||
import { FullAutoErrorMode } from "../auto-approval-mode.js";
|
||||
import { SandboxType } from "./sandbox/interface.js";
|
||||
import { canAutoApprove } from "@lib/approvals.js";
|
||||
import { formatCommandForDisplay } from "@lib/format-command.js";
|
||||
import { access } from "fs/promises";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session‑level cache of commands that the user has chosen to always approve.
|
||||
//
|
||||
// The values are derived via `deriveCommandKey()` which intentionally ignores
|
||||
// volatile arguments (for example the patch text passed to `apply_patch`).
|
||||
// Storing *generalised* keys means that once a user selects "always approve"
|
||||
// for a given class of command we will genuinely stop prompting them for
|
||||
// subsequent, equivalent invocations during the same CLI session.
|
||||
// ---------------------------------------------------------------------------
|
||||
const alwaysApprovedCommands = new Set<string>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: Given the argv-style representation of a command, return a stable
|
||||
// string key that can be used for equality checks.
|
||||
//
|
||||
// The key space purposefully abstracts away parts of the command line that
|
||||
// are expected to change between invocations while still retaining enough
|
||||
// information to differentiate *meaningfully distinct* operations. See the
|
||||
// extensive inline documentation for details.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function deriveCommandKey(cmd: Array<string>): string {
|
||||
// pull off only the bits you care about
|
||||
const [
|
||||
maybeShell,
|
||||
maybeFlag,
|
||||
coreInvocation,
|
||||
/* …ignore the rest… */
|
||||
] = cmd;
|
||||
|
||||
if (coreInvocation?.startsWith("apply_patch")) {
|
||||
return "apply_patch";
|
||||
}
|
||||
|
||||
if (maybeShell === "bash" && maybeFlag === "-lc") {
|
||||
// If the command was invoked through `bash -lc "<script>"` we extract the
|
||||
// base program name from the script string.
|
||||
const script = coreInvocation ?? "";
|
||||
return script.split(/\s+/)[0] || "bash";
|
||||
}
|
||||
|
||||
// For every other command we fall back to using only the program name (the
|
||||
// first argv element). This guarantees we always return a *string* even if
|
||||
// `coreInvocation` is undefined.
|
||||
if (coreInvocation) {
|
||||
return coreInvocation.split(/\s+/)[0]!;
|
||||
}
|
||||
|
||||
return JSON.stringify(cmd);
|
||||
}
|
||||
|
||||
type HandleExecCommandResult = {
|
||||
outputText: string;
|
||||
metadata: Record<string, unknown>;
|
||||
additionalItems?: Array<ResponseInputItem>;
|
||||
};
|
||||
|
||||
export async function handleExecCommand(
|
||||
args: ExecInput,
|
||||
config: AppConfig,
|
||||
policy: ApprovalPolicy,
|
||||
getCommandConfirmation: (
|
||||
command: Array<string>,
|
||||
applyPatch: ApplyPatchCommand | undefined,
|
||||
) => Promise<CommandConfirmation>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<HandleExecCommandResult> {
|
||||
const { cmd: command } = args;
|
||||
|
||||
const key = deriveCommandKey(command);
|
||||
|
||||
// 1) If the user has already said "always approve", skip
|
||||
// any policy & never sandbox.
|
||||
if (alwaysApprovedCommands.has(key)) {
|
||||
return execCommand(
|
||||
args,
|
||||
/* applyPatch */ undefined,
|
||||
/* runInSandbox */ false,
|
||||
abortSignal,
|
||||
).then(convertSummaryToResult);
|
||||
}
|
||||
|
||||
// 2) Otherwise fall back to the normal policy
|
||||
// `canAutoApprove` now requires the list of writable roots that the command
|
||||
// is allowed to modify. For the CLI we conservatively pass the current
|
||||
// working directory so that edits are constrained to the project root. If
|
||||
// the caller wishes to broaden or restrict the set it can be made
|
||||
// configurable in the future.
|
||||
const safety = canAutoApprove(command, policy, [process.cwd()]);
|
||||
|
||||
let runInSandbox: boolean;
|
||||
switch (safety.type) {
|
||||
case "ask-user": {
|
||||
const review = await askUserPermission(
|
||||
args,
|
||||
safety.applyPatch,
|
||||
getCommandConfirmation,
|
||||
);
|
||||
if (review != null) {
|
||||
return review;
|
||||
}
|
||||
|
||||
runInSandbox = false;
|
||||
break;
|
||||
}
|
||||
case "auto-approve": {
|
||||
runInSandbox = safety.runInSandbox;
|
||||
break;
|
||||
}
|
||||
case "reject": {
|
||||
return {
|
||||
outputText: "aborted",
|
||||
metadata: {
|
||||
error: "command rejected",
|
||||
reason: "Command rejected by auto-approval system.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { applyPatch } = safety;
|
||||
const summary = await execCommand(
|
||||
args,
|
||||
applyPatch,
|
||||
runInSandbox,
|
||||
abortSignal,
|
||||
);
|
||||
// If the operation was aborted in the meantime, propagate the cancellation
|
||||
// upward by returning an empty (no‑op) result so that the agent loop will
|
||||
// exit cleanly without emitting spurious output.
|
||||
if (abortSignal?.aborted) {
|
||||
return {
|
||||
outputText: "",
|
||||
metadata: {},
|
||||
};
|
||||
}
|
||||
if (
|
||||
summary.exitCode !== 0 &&
|
||||
runInSandbox &&
|
||||
// Default: If the user has configured to ignore and continue,
|
||||
// skip re-running the command.
|
||||
//
|
||||
// Otherwise, if they selected "ask-user", then we should ask the user
|
||||
// for permission to re-run the command outside of the sandbox.
|
||||
config.fullAutoErrorMode &&
|
||||
config.fullAutoErrorMode === FullAutoErrorMode.ASK_USER
|
||||
) {
|
||||
const review = await askUserPermission(
|
||||
args,
|
||||
safety.applyPatch,
|
||||
getCommandConfirmation,
|
||||
);
|
||||
if (review != null) {
|
||||
return review;
|
||||
} else {
|
||||
// The user has approved the command, so we will run it outside of the
|
||||
// sandbox.
|
||||
const summary = await execCommand(args, applyPatch, false, abortSignal);
|
||||
return convertSummaryToResult(summary);
|
||||
}
|
||||
} else {
|
||||
return convertSummaryToResult(summary);
|
||||
}
|
||||
}
|
||||
|
||||
function convertSummaryToResult(
|
||||
summary: ExecCommandSummary,
|
||||
): HandleExecCommandResult {
|
||||
const { stdout, stderr, exitCode, durationMs } = summary;
|
||||
return {
|
||||
outputText: stdout || stderr,
|
||||
metadata: {
|
||||
exit_code: exitCode,
|
||||
duration_seconds: Math.round(durationMs / 100) / 10,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type ExecCommandSummary = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
};
|
||||
|
||||
async function execCommand(
|
||||
execInput: ExecInput,
|
||||
applyPatchCommand: ApplyPatchCommand | undefined,
|
||||
runInSandbox: boolean,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecCommandSummary> {
|
||||
if (isLoggingEnabled()) {
|
||||
if (applyPatchCommand != null) {
|
||||
log("EXEC running apply_patch command");
|
||||
} else {
|
||||
const { cmd, workdir, timeoutInMillis } = execInput;
|
||||
// Seconds are a bit easier to read in log messages and most timeouts
|
||||
// are specified as multiples of 1000, anyway.
|
||||
const timeout =
|
||||
timeoutInMillis != null
|
||||
? Math.round(timeoutInMillis / 1000).toString()
|
||||
: "undefined";
|
||||
log(
|
||||
`EXEC running \`${formatCommandForDisplay(
|
||||
cmd,
|
||||
)}\` in workdir=${workdir} with timeout=${timeout}s`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note execApplyPatch() and exec() are coded defensively and should not
|
||||
// throw. Any internal errors should be mapped to a non-zero value for the
|
||||
// exitCode field.
|
||||
const start = Date.now();
|
||||
const execResult =
|
||||
applyPatchCommand != null
|
||||
? execApplyPatch(applyPatchCommand.patch)
|
||||
: await exec(execInput, await getSandbox(runInSandbox), abortSignal);
|
||||
const duration = Date.now() - start;
|
||||
const { stdout, stderr, exitCode } = execResult;
|
||||
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
`EXEC exit=${exitCode} time=${duration}ms:\n\tSTDOUT: ${stdout}\n\tSTDERR: ${stderr}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
durationMs: duration,
|
||||
};
|
||||
}
|
||||
|
||||
const isInContainer = async (): Promise<boolean> => {
|
||||
try {
|
||||
await access("/proc/1/cgroup");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
|
||||
if (runInSandbox) {
|
||||
if (process.platform === "darwin") {
|
||||
return SandboxType.MACOS_SEATBELT;
|
||||
} else if (await isInContainer()) {
|
||||
return SandboxType.NONE;
|
||||
}
|
||||
throw new Error("Sandbox was mandated, but no sandbox is available!");
|
||||
} else {
|
||||
return SandboxType.NONE;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If return value is non-null, then the command was rejected by the user.
|
||||
*/
|
||||
async function askUserPermission(
|
||||
args: ExecInput,
|
||||
applyPatchCommand: ApplyPatchCommand | undefined,
|
||||
getCommandConfirmation: (
|
||||
command: Array<string>,
|
||||
applyPatch: ApplyPatchCommand | undefined,
|
||||
) => Promise<CommandConfirmation>,
|
||||
): Promise<HandleExecCommandResult | null> {
|
||||
const { review: decision, customDenyMessage } = await getCommandConfirmation(
|
||||
args.cmd,
|
||||
applyPatchCommand,
|
||||
);
|
||||
|
||||
if (decision === ReviewDecision.ALWAYS) {
|
||||
// Persist this command so we won't ask again during this session.
|
||||
const key = deriveCommandKey(args.cmd);
|
||||
alwaysApprovedCommands.add(key);
|
||||
}
|
||||
|
||||
// Any decision other than an affirmative (YES / ALWAYS) aborts execution.
|
||||
if (decision !== ReviewDecision.YES && decision !== ReviewDecision.ALWAYS) {
|
||||
const note =
|
||||
decision === ReviewDecision.NO_CONTINUE
|
||||
? customDenyMessage?.trim() || "No, don't do that — keep going though."
|
||||
: "No, don't do that — stop for now.";
|
||||
return {
|
||||
outputText: "aborted",
|
||||
metadata: {},
|
||||
additionalItems: [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: note }],
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
129
codex-cli/src/utils/agent/log.ts
Normal file
129
codex-cli/src/utils/agent/log.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import * as fsSync from "fs";
|
||||
import * as fs from "fs/promises";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
interface Logger {
|
||||
/** Checking this can be used to avoid constructing a large log message. */
|
||||
isLoggingEnabled(): boolean;
|
||||
|
||||
log(message: string): void;
|
||||
}
|
||||
|
||||
class AsyncLogger implements Logger {
|
||||
private queue: Array<string> = [];
|
||||
private isWriting: boolean = false;
|
||||
|
||||
constructor(private filePath: string) {
|
||||
this.filePath = filePath;
|
||||
}
|
||||
|
||||
isLoggingEnabled(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
log(message: string): void {
|
||||
const entry = `[${now()}] ${message}\n`;
|
||||
this.queue.push(entry);
|
||||
this.maybeWrite();
|
||||
}
|
||||
|
||||
private async maybeWrite(): Promise<void> {
|
||||
if (this.isWriting || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWriting = true;
|
||||
const messages = this.queue.join("");
|
||||
this.queue = [];
|
||||
|
||||
try {
|
||||
await fs.appendFile(this.filePath, messages);
|
||||
} finally {
|
||||
this.isWriting = false;
|
||||
}
|
||||
|
||||
this.maybeWrite();
|
||||
}
|
||||
}
|
||||
|
||||
class EmptyLogger implements Logger {
|
||||
isLoggingEnabled(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
log(_message: string): void {
|
||||
// No-op
|
||||
}
|
||||
}
|
||||
|
||||
function now() {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
let logger: Logger;
|
||||
|
||||
/**
|
||||
* Creates a .log file for this session, but also symlinks codex-cli-latest.log
|
||||
* to the current log file so you can reliably run:
|
||||
*
|
||||
* - Mac/Windows: `tail -F "$TMPDIR/oai-codex/codex-cli-latest.log"`
|
||||
* - Linux: `tail -F ~/.local/oai-codex/codex-cli-latest.log`
|
||||
*/
|
||||
export function initLogger(): Logger {
|
||||
if (logger) {
|
||||
return logger;
|
||||
} else if (!process.env["DEBUG"]) {
|
||||
logger = new EmptyLogger();
|
||||
return logger;
|
||||
}
|
||||
|
||||
const isMac = process.platform === "darwin";
|
||||
const isWin = process.platform === "win32";
|
||||
|
||||
// On Mac and Windows, os.tmpdir() returns a user-specifc folder, so prefer
|
||||
// it there. On Linux, use ~/.local/oai-codex so logs are not world-readable.
|
||||
const logDir =
|
||||
isMac || isWin
|
||||
? path.join(os.tmpdir(), "oai-codex")
|
||||
: path.join(os.homedir(), ".local", "oai-codex");
|
||||
fsSync.mkdirSync(logDir, { recursive: true });
|
||||
const logFile = path.join(logDir, `codex-cli-${now()}.log`);
|
||||
// Write the empty string so the file exists and can be tail'd.
|
||||
fsSync.writeFileSync(logFile, "");
|
||||
|
||||
// Symlink to codex-cli-latest.log on UNIX because Windows is funny about
|
||||
// symlinks.
|
||||
if (!isWin) {
|
||||
const latestLink = path.join(logDir, "codex-cli-latest.log");
|
||||
try {
|
||||
fsSync.symlinkSync(logFile, latestLink, "file");
|
||||
} catch (err: unknown) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code === "EEXIST") {
|
||||
fsSync.unlinkSync(latestLink);
|
||||
fsSync.symlinkSync(logFile, latestLink, "file");
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger = new AsyncLogger(logFile);
|
||||
return logger;
|
||||
}
|
||||
|
||||
export function log(message: string): void {
|
||||
(logger ?? initLogger()).log(message);
|
||||
}
|
||||
|
||||
export function isLoggingEnabled(): boolean {
|
||||
return (logger ?? initLogger()).isLoggingEnabled();
|
||||
}
|
||||
112
codex-cli/src/utils/agent/parse-apply-patch.ts
Normal file
112
codex-cli/src/utils/agent/parse-apply-patch.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
export type ApplyPatchCreateFileOp = {
|
||||
type: "create";
|
||||
path: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchDeleteFileOp = {
|
||||
type: "delete";
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type ApplyPatchUpdateFileOp = {
|
||||
type: "update";
|
||||
path: string;
|
||||
update: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
};
|
||||
|
||||
export type ApplyPatchOp =
|
||||
| ApplyPatchCreateFileOp
|
||||
| ApplyPatchDeleteFileOp
|
||||
| ApplyPatchUpdateFileOp;
|
||||
|
||||
const PATCH_PREFIX = "*** Begin Patch\n";
|
||||
const PATCH_SUFFIX = "\n*** End Patch";
|
||||
const ADD_FILE_PREFIX = "*** Add File: ";
|
||||
const DELETE_FILE_PREFIX = "*** Delete File: ";
|
||||
const UPDATE_FILE_PREFIX = "*** Update File: ";
|
||||
const END_OF_FILE_PREFIX = "*** End of File";
|
||||
const HUNK_ADD_LINE_PREFIX = "+";
|
||||
|
||||
/**
|
||||
* @returns null when the patch is invalid
|
||||
*/
|
||||
export function parseApplyPatch(patch: string): Array<ApplyPatchOp> | null {
|
||||
if (!patch.startsWith(PATCH_PREFIX)) {
|
||||
// Patch must begin with '*** Begin Patch'
|
||||
return null;
|
||||
} else if (!patch.endsWith(PATCH_SUFFIX)) {
|
||||
// Patch must end with '*** End Patch'
|
||||
return null;
|
||||
}
|
||||
|
||||
const patchBody = patch.slice(
|
||||
PATCH_PREFIX.length,
|
||||
patch.length - PATCH_SUFFIX.length,
|
||||
);
|
||||
|
||||
const lines = patchBody.split("\n");
|
||||
|
||||
const ops: Array<ApplyPatchOp> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith(END_OF_FILE_PREFIX)) {
|
||||
continue;
|
||||
} else if (line.startsWith(ADD_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "create",
|
||||
path: line.slice(ADD_FILE_PREFIX.length).trim(),
|
||||
content: "",
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(DELETE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "delete",
|
||||
path: line.slice(DELETE_FILE_PREFIX.length).trim(),
|
||||
});
|
||||
continue;
|
||||
} else if (line.startsWith(UPDATE_FILE_PREFIX)) {
|
||||
ops.push({
|
||||
type: "update",
|
||||
path: line.slice(UPDATE_FILE_PREFIX.length).trim(),
|
||||
update: "",
|
||||
added: 0,
|
||||
deleted: 0,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const lastOp = ops[ops.length - 1];
|
||||
|
||||
if (lastOp?.type === "create") {
|
||||
lastOp.content = appendLine(
|
||||
lastOp.content,
|
||||
line.slice(HUNK_ADD_LINE_PREFIX.length),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (lastOp?.type !== "update") {
|
||||
// Expected update op but got ${lastOp?.type} for line ${line}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (line.startsWith(HUNK_ADD_LINE_PREFIX)) {
|
||||
lastOp.added += 1;
|
||||
} else if (line.startsWith("-")) {
|
||||
lastOp.deleted += 1;
|
||||
}
|
||||
lastOp.update += lastOp.update ? "\n" + line : line;
|
||||
}
|
||||
|
||||
return ops;
|
||||
}
|
||||
|
||||
function appendLine(content: string, line: string) {
|
||||
if (!content.length) {
|
||||
return line;
|
||||
}
|
||||
return [content, line].join("\n");
|
||||
}
|
||||
18
codex-cli/src/utils/agent/review.ts
Normal file
18
codex-cli/src/utils/agent/review.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { SafeCommandReason } from "@lib/approvals";
|
||||
|
||||
export type CommandReviewDetails = {
|
||||
cmd: Array<string>;
|
||||
cmdReadableText: string;
|
||||
autoApproval: SafeCommandReason | null;
|
||||
};
|
||||
|
||||
export enum ReviewDecision {
|
||||
YES = "yes",
|
||||
NO_CONTINUE = "no-continue",
|
||||
NO_EXIT = "no-exit",
|
||||
/**
|
||||
* User has approved this command and wants to automatically approve any
|
||||
* future identical instances for the remainder of the session.
|
||||
*/
|
||||
ALWAYS = "always",
|
||||
}
|
||||
30
codex-cli/src/utils/agent/sandbox/interface.ts
Normal file
30
codex-cli/src/utils/agent/sandbox/interface.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
export enum SandboxType {
|
||||
NONE = "none",
|
||||
MACOS_SEATBELT = "macos.seatbelt",
|
||||
LINUX_LANDLOCK = "linux.landlock",
|
||||
}
|
||||
|
||||
export type ExecInput = {
|
||||
cmd: Array<string>;
|
||||
workdir: string | undefined;
|
||||
timeoutInMillis: number | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Result of executing a command. Caller is responsible for checking `code` to
|
||||
* determine whether the command was successful.
|
||||
*/
|
||||
export type ExecResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
exitCode: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Value to use with the `metadata` field of a `ResponseItem` whose type is
|
||||
* `function_call_output`.
|
||||
*/
|
||||
export type ExecOutputMetadata = {
|
||||
exit_code: number;
|
||||
duration_seconds: number;
|
||||
};
|
||||
141
codex-cli/src/utils/agent/sandbox/macos-seatbelt.ts
Normal file
141
codex-cli/src/utils/agent/sandbox/macos-seatbelt.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import type { ExecResult } from "./interface.js";
|
||||
import type { SpawnOptions } from "child_process";
|
||||
|
||||
import { exec } from "./raw-exec.js";
|
||||
import { log } from "../log.js";
|
||||
import { CONFIG_DIR } from "src/utils/config.js";
|
||||
|
||||
function getCommonRoots() {
|
||||
return [
|
||||
CONFIG_DIR,
|
||||
// Without this root, it'll cause:
|
||||
// pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
|
||||
`${process.env["HOME"]}/.pyenv`,
|
||||
];
|
||||
}
|
||||
|
||||
export function execWithSeatbelt(
|
||||
cmd: Array<string>,
|
||||
opts: SpawnOptions,
|
||||
writableRoots: Array<string>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
let scopedWritePolicy: string;
|
||||
let policyTemplateParams: Array<string>;
|
||||
if (writableRoots.length > 0) {
|
||||
// Add `~/.codex` to the list of writable roots
|
||||
// (if there's any already, not in read-only mode)
|
||||
getCommonRoots().map((root) => writableRoots.push(root));
|
||||
const { policies, params } = writableRoots
|
||||
.map((root, index) => ({
|
||||
policy: `(subpath (param "WRITABLE_ROOT_${index}"))`,
|
||||
param: `-DWRITABLE_ROOT_${index}=${root}`,
|
||||
}))
|
||||
.reduce(
|
||||
(
|
||||
acc: { policies: Array<string>; params: Array<string> },
|
||||
{ policy, param },
|
||||
) => {
|
||||
acc.policies.push(policy);
|
||||
acc.params.push(param);
|
||||
return acc;
|
||||
},
|
||||
{ policies: [], params: [] },
|
||||
);
|
||||
|
||||
scopedWritePolicy = `\n(allow file-write*\n${policies.join(" ")}\n)`;
|
||||
policyTemplateParams = params;
|
||||
} else {
|
||||
scopedWritePolicy = "";
|
||||
policyTemplateParams = [];
|
||||
}
|
||||
|
||||
const fullPolicy = READ_ONLY_SEATBELT_POLICY + scopedWritePolicy;
|
||||
log(
|
||||
`Running seatbelt with policy: ${fullPolicy} and ${
|
||||
policyTemplateParams.length
|
||||
} template params: ${policyTemplateParams.join(", ")}`,
|
||||
);
|
||||
|
||||
const fullCommand = [
|
||||
"sandbox-exec",
|
||||
"-p",
|
||||
fullPolicy,
|
||||
...policyTemplateParams,
|
||||
"--",
|
||||
...cmd,
|
||||
];
|
||||
return exec(fullCommand, opts, writableRoots, abortSignal);
|
||||
}
|
||||
|
||||
const READ_ONLY_SEATBELT_POLICY = `
|
||||
(version 1)
|
||||
|
||||
; inspired by Chrome's sandbox policy:
|
||||
; https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd
|
||||
|
||||
; start with closed-by-default
|
||||
(deny default)
|
||||
|
||||
; allow read-only file operations
|
||||
(allow file-read*)
|
||||
|
||||
; child processes inherit the policy of their parent
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
|
||||
(allow file-write-data
|
||||
(require-all
|
||||
(path "/dev/null")
|
||||
(vnode-type CHARACTER-DEVICE)))
|
||||
|
||||
; sysctls permitted.
|
||||
(allow sysctl-read
|
||||
(sysctl-name "hw.activecpu")
|
||||
(sysctl-name "hw.busfrequency_compat")
|
||||
(sysctl-name "hw.byteorder")
|
||||
(sysctl-name "hw.cacheconfig")
|
||||
(sysctl-name "hw.cachelinesize_compat")
|
||||
(sysctl-name "hw.cpufamily")
|
||||
(sysctl-name "hw.cpufrequency_compat")
|
||||
(sysctl-name "hw.cputype")
|
||||
(sysctl-name "hw.l1dcachesize_compat")
|
||||
(sysctl-name "hw.l1icachesize_compat")
|
||||
(sysctl-name "hw.l2cachesize_compat")
|
||||
(sysctl-name "hw.l3cachesize_compat")
|
||||
(sysctl-name "hw.logicalcpu_max")
|
||||
(sysctl-name "hw.machine")
|
||||
(sysctl-name "hw.ncpu")
|
||||
(sysctl-name "hw.nperflevels")
|
||||
(sysctl-name "hw.optional.arm.FEAT_BF16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_DotProd")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FCMA")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FHM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_FP16")
|
||||
(sysctl-name "hw.optional.arm.FEAT_I8MM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_JSCVT")
|
||||
(sysctl-name "hw.optional.arm.FEAT_LSE")
|
||||
(sysctl-name "hw.optional.arm.FEAT_RDM")
|
||||
(sysctl-name "hw.optional.arm.FEAT_SHA512")
|
||||
(sysctl-name "hw.optional.armv8_2_sha512")
|
||||
(sysctl-name "hw.memsize")
|
||||
(sysctl-name "hw.pagesize")
|
||||
(sysctl-name "hw.packages")
|
||||
(sysctl-name "hw.pagesize_compat")
|
||||
(sysctl-name "hw.physicalcpu_max")
|
||||
(sysctl-name "hw.tbfrequency_compat")
|
||||
(sysctl-name "hw.vectorunit")
|
||||
(sysctl-name "kern.hostname")
|
||||
(sysctl-name "kern.maxfilesperproc")
|
||||
(sysctl-name "kern.osproductversion")
|
||||
(sysctl-name "kern.osrelease")
|
||||
(sysctl-name "kern.ostype")
|
||||
(sysctl-name "kern.osvariant_status")
|
||||
(sysctl-name "kern.osversion")
|
||||
(sysctl-name "kern.secure_kernel")
|
||||
(sysctl-name "kern.usrstack64")
|
||||
(sysctl-name "kern.version")
|
||||
(sysctl-name "sysctl.proc_cputype")
|
||||
(sysctl-name-prefix "hw.perflevel")
|
||||
)`.trim();
|
||||
199
codex-cli/src/utils/agent/sandbox/raw-exec.ts
Normal file
199
codex-cli/src/utils/agent/sandbox/raw-exec.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { ExecResult } from "./interface";
|
||||
import type {
|
||||
ChildProcess,
|
||||
SpawnOptions,
|
||||
SpawnOptionsWithStdioTuple,
|
||||
StdioNull,
|
||||
StdioPipe,
|
||||
} from "child_process";
|
||||
|
||||
import { log, isLoggingEnabled } from "../log.js";
|
||||
import { spawn } from "child_process";
|
||||
import * as os from "os";
|
||||
|
||||
const MAX_BUFFER = 1024 * 100; // 100 KB
|
||||
|
||||
/**
|
||||
* 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(
|
||||
command: Array<string>,
|
||||
options: SpawnOptions,
|
||||
_writableRoots: Array<string>,
|
||||
abortSignal?: AbortSignal,
|
||||
): Promise<ExecResult> {
|
||||
const prog = command[0];
|
||||
if (typeof prog !== "string") {
|
||||
return Promise.resolve({
|
||||
stdout: "",
|
||||
stderr: "command[0] is not a string",
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
|
||||
// We use spawn() instead of exec() or execFile() so that we can set the
|
||||
// stdio options to "ignore" for stdin. Ripgrep has a heuristic where it
|
||||
// may try to read from stdin as explained here:
|
||||
//
|
||||
// https://github.com/BurntSushi/ripgrep/blob/e2362d4d5185d02fa857bf381e7bd52e66fafc73/crates/core/flags/hiargs.rs#L1101-L1103
|
||||
//
|
||||
// This can be a problem because if you save the following to a file and
|
||||
// run it with `node`, it will hang forever:
|
||||
//
|
||||
// ```
|
||||
// const {execFile} = require('child_process');
|
||||
//
|
||||
// execFile('rg', ['foo'], (error, stdout, stderr) => {
|
||||
// if (error) {
|
||||
// console.error(`error: ${error}n\nstderr: ${stderr}`);
|
||||
// } else {
|
||||
// console.log(`stdout: ${stdout}`);
|
||||
// }
|
||||
// });
|
||||
// ```
|
||||
//
|
||||
// Even if you pass `{stdio: ["ignore", "pipe", "pipe"] }` to execFile(), the
|
||||
// hang still happens as the `stdio` is seemingly ignored. Using spawn()
|
||||
// works around this issue.
|
||||
const fullOptions: SpawnOptionsWithStdioTuple<
|
||||
StdioNull,
|
||||
StdioPipe,
|
||||
StdioPipe
|
||||
> = {
|
||||
...options,
|
||||
// Inherit any caller‑supplied stdio flags but force stdin to "ignore" so
|
||||
// the child never attempts to read from us (see lengthy comment above).
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Launch the child in its *own* process group so that we can later send a
|
||||
// single signal to the entire group – this reliably terminates not only
|
||||
// the immediate child but also any grandchildren it might have spawned
|
||||
// (think `bash -c "sleep 999"`).
|
||||
detached: true,
|
||||
};
|
||||
|
||||
const child: ChildProcess = spawn(prog, command.slice(1), fullOptions);
|
||||
// If an AbortSignal is provided, ensure the spawned process is terminated
|
||||
// when the signal is triggered so that cancellations propagate down to any
|
||||
// long‑running child processes. We default to SIGTERM to give the process a
|
||||
// chance to clean up, falling back to SIGKILL if it does not exit in a
|
||||
// timely fashion.
|
||||
if (abortSignal) {
|
||||
const abortHandler = () => {
|
||||
if (isLoggingEnabled()) {
|
||||
log(`raw-exec: abort signal received – killing child ${child.pid}`);
|
||||
}
|
||||
const killTarget = (signal: NodeJS.Signals) => {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
try {
|
||||
// Send to the *process group* so grandchildren are included.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
// Fallback: kill only the immediate child (may leave orphans on
|
||||
// exotic kernels that lack process‑group semantics, but better
|
||||
// than nothing).
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
};
|
||||
|
||||
// First try graceful termination.
|
||||
killTarget("SIGTERM");
|
||||
|
||||
// Escalate to SIGKILL if the group refuses to die.
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
killTarget("SIGKILL");
|
||||
}
|
||||
}, 2000).unref();
|
||||
};
|
||||
if (abortSignal.aborted) {
|
||||
abortHandler();
|
||||
} else {
|
||||
abortSignal.addEventListener("abort", abortHandler, { once: true });
|
||||
}
|
||||
}
|
||||
if (!child.pid) {
|
||||
return Promise.resolve({
|
||||
stdout: "",
|
||||
stderr: `likely failed because ${prog} could not be found`,
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
|
||||
const stdoutChunks: Array<Buffer> = [];
|
||||
const stderrChunks: Array<Buffer> = [];
|
||||
let numStdoutBytes = 0;
|
||||
let numStderrBytes = 0;
|
||||
let hitMaxStdout = false;
|
||||
let hitMaxStderr = false;
|
||||
|
||||
return new Promise<ExecResult>((resolve) => {
|
||||
child.stdout?.on("data", (data: Buffer) => {
|
||||
if (!hitMaxStdout) {
|
||||
numStdoutBytes += data.length;
|
||||
if (numStdoutBytes <= MAX_BUFFER) {
|
||||
stdoutChunks.push(data);
|
||||
} else {
|
||||
hitMaxStdout = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (data: Buffer) => {
|
||||
if (!hitMaxStderr) {
|
||||
numStderrBytes += data.length;
|
||||
if (numStderrBytes <= MAX_BUFFER) {
|
||||
stderrChunks.push(data);
|
||||
} else {
|
||||
hitMaxStderr = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
const stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
||||
const stderr = Buffer.concat(stderrChunks).toString("utf8");
|
||||
|
||||
// Map (code, signal) to an exit code. We expect exactly one of the two
|
||||
// values to be non-null, but we code defensively to handle the case where
|
||||
// both are null.
|
||||
let exitCode: number;
|
||||
if (code != null) {
|
||||
exitCode = code;
|
||||
} else if (signal != null && signal in os.constants.signals) {
|
||||
const signalNum =
|
||||
os.constants.signals[signal as keyof typeof os.constants.signals];
|
||||
exitCode = 128 + signalNum;
|
||||
} else {
|
||||
exitCode = 1;
|
||||
}
|
||||
|
||||
if (isLoggingEnabled()) {
|
||||
log(
|
||||
`raw-exec: child ${child.pid} exited code=${exitCode} signal=${signal}`,
|
||||
);
|
||||
}
|
||||
resolve({
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
});
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
resolve({
|
||||
stdout: "",
|
||||
stderr: String(err),
|
||||
exitCode: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user