Initial commit

Signed-off-by: Ilan Bigio <ilan@openai.com>
This commit is contained in:
Ilan Bigio
2025-04-16 12:56:08 -04:00
commit 59a180ddec
163 changed files with 30587 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View 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;
}
// -----------------------------------------------------------------------------
// Patchrelated 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];
}
// -----------------------------------------------------------------------------
// Highlevel 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);
}
});
}

View 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>";
}

View 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";
// ---------------------------------------------------------------------------
// Sessionlevel 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 (noop) 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;
}
}

View 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();
}

View 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");
}

View 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",
}

View 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;
};

View 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();

View 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 callersupplied 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
// longrunning 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 processgroup 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,
});
});
});
}