mirror of
https://github.com/openai/codex.git
synced 2026-02-07 17:33:41 +00:00
Compare commits
1 Commits
codex/task
...
pr675
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0492b91d38 |
10
.github/workflows/rust-ci.yml
vendored
10
.github/workflows/rust-ci.yml
vendored
@@ -38,6 +38,8 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: codex-rs
|
||||
env:
|
||||
CARGO_HOME: ${{ runner.os == 'Windows' && format('{0}\\.cargo', env.USERPROFILE) || format('{0}/.cargo', env.HOME) }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -65,10 +67,10 @@ jobs:
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/bin/
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
${{ env.CARGO_HOME }}/bin/
|
||||
${{ env.CARGO_HOME }}/registry/index/
|
||||
${{ env.CARGO_HOME }}/registry/cache/
|
||||
${{ env.CARGO_HOME }}/git/db/
|
||||
${{ github.workspace }}/codex-rs/target/
|
||||
key: cargo-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
|
||||
45
CHANGELOG.md
45
CHANGELOG.md
@@ -2,41 +2,6 @@
|
||||
|
||||
You can install any of these versions: `npm install -g codex@version`
|
||||
|
||||
## `0.1.2504251709`
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
- Add openai model info configuration (#551)
|
||||
- Added provider to run quiet mode function (#571)
|
||||
- Create parent directories when creating new files (#552)
|
||||
- Print bug report URL in terminal instead of opening browser (#510) (#528)
|
||||
- Add support for custom provider configuration in the user config (#537)
|
||||
- Add support for OpenAI-Organization and OpenAI-Project headers (#626)
|
||||
- Add specific instructions for creating API keys in error msg (#581)
|
||||
- Enhance toCodePoints to prevent potential unicode 14 errors (#615)
|
||||
- More native keyboard navigation in multiline editor (#655)
|
||||
- Display error on selection of invalid model (#594)
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
|
||||
- Model selection (#643)
|
||||
- Nits in apply patch (#640)
|
||||
- Input keyboard shortcuts (#676)
|
||||
- `apply_patch` unicode characters (#625)
|
||||
- Don't clear turn input before retries (#611)
|
||||
- More loosely match context for apply_patch (#610)
|
||||
- Update bug report template - there is no --revision flag (#614)
|
||||
- Remove outdated copy of text input and external editor feature (#670)
|
||||
- Remove unreachable "disableResponseStorage" logic flow introduced in #543 (#573)
|
||||
- Non-openai mode - fix for gemini content: null, fix 429 to throw before stream (#563)
|
||||
- Only allow going up in history when not already in history if input is empty (#654)
|
||||
- Do not grant "node" user sudo access when using run_in_container.sh (#627)
|
||||
- Update scripts/build_container.sh to use pnpm instead of npm (#631)
|
||||
- Update lint-staged config to use pnpm --filter (#582)
|
||||
- Non-openai mode - don't default temp and top_p (#572)
|
||||
- Fix error catching when checking for updates (#597)
|
||||
- Close stdin when running an exec tool call (#636)
|
||||
|
||||
## `0.1.2504221401`
|
||||
|
||||
### 🚀 Features
|
||||
@@ -44,7 +9,7 @@ You can install any of these versions: `npm install -g codex@version`
|
||||
- Show actionable errors when api keys are missing (#523)
|
||||
- Add CLI `--version` flag (#492)
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Agent loop for ZDR (`disableResponseStorage`) (#543)
|
||||
- Fix relative `workdir` check for `apply_patch` (#556)
|
||||
@@ -75,7 +40,7 @@ You can install any of these versions: `npm install -g codex@version`
|
||||
- Add /command autocomplete (#317)
|
||||
- Allow multi-line input (#438)
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- `full-auto` support in quiet mode (#374)
|
||||
- Enable shell option for child process execution (#391)
|
||||
@@ -99,7 +64,7 @@ You can install any of these versions: `npm install -g codex@version`
|
||||
- Add `/bug` report command (#312)
|
||||
- Notify when a newer version is available (#333)
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Update context left display logic in TerminalChatInput component (#307)
|
||||
- Improper spawn of sh on Windows Powershell (#318)
|
||||
@@ -112,7 +77,7 @@ You can install any of these versions: `npm install -g codex@version`
|
||||
|
||||
- Add Nix flake for reproducible development environments (#225)
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Handle invalid commands (#304)
|
||||
- Raw-exec-process-group.test improve reliability and error handling (#280)
|
||||
@@ -131,7 +96,7 @@ You can install any of these versions: `npm install -g codex@version`
|
||||
- `--config`/`-c` flag to open global instructions in nvim (#158)
|
||||
- Update position of cursor when navigating input history with arrow keys to the end of the text (#255)
|
||||
|
||||
### 🪲 Bug Fixes
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Correct word deletion logic for trailing spaces (Ctrl+Backspace) (#131)
|
||||
- Improve Windows compatibility for CLI commands and sandbox (#261)
|
||||
|
||||
@@ -35,7 +35,7 @@ conventional_commits = true
|
||||
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->🪲 Bug Fixes" },
|
||||
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
|
||||
{ message = "^bump", group = "<!-- 6 -->🛳️ Release" },
|
||||
# Fallback – skip anything that didn't match the above rules.
|
||||
{ message = ".*", group = "<!-- 10 -->💼 Other" },
|
||||
|
||||
@@ -46,10 +46,6 @@ RUN npm install -g codex.tgz \
|
||||
&& rm -rf /usr/local/share/npm-global/lib/node_modules/codex-cli/tests \
|
||||
&& rm -rf /usr/local/share/npm-global/lib/node_modules/codex-cli/docs
|
||||
|
||||
# Inside the container we consider the environment already sufficiently locked
|
||||
# down, therefore instruct Codex CLI to allow running without sandboxing.
|
||||
ENV CODEX_UNSAFE_ALLOW_NO_SANDBOX=1
|
||||
|
||||
# Copy and set up firewall script as root.
|
||||
USER root
|
||||
COPY scripts/init_firewall.sh /usr/local/bin/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openai/codex",
|
||||
"version": "0.1.2504251709",
|
||||
"version": "0.1.2504221401",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex": "bin/codex.js"
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { ApprovalPolicy } from "./approvals";
|
||||
import type { CommandConfirmation } from "./utils/agent/agent-loop";
|
||||
import type { AppConfig } from "./utils/config";
|
||||
import type { ResponseItem } from "openai/resources/responses/responses";
|
||||
import type { ReasoningEffort } from "openai/resources.mjs";
|
||||
|
||||
import App from "./app";
|
||||
import { runSinglePass } from "./cli-singlepass";
|
||||
@@ -161,12 +160,6 @@ const cli = meow(
|
||||
"Disable truncation of command stdout/stderr messages (show everything)",
|
||||
aliases: ["no-truncate"],
|
||||
},
|
||||
reasoning: {
|
||||
type: "string",
|
||||
description: "Set the reasoning effort level (low, medium, high)",
|
||||
choices: ["low", "medium", "high"],
|
||||
default: "high",
|
||||
},
|
||||
// Notification
|
||||
notify: {
|
||||
type: "boolean",
|
||||
@@ -191,10 +184,6 @@ const cli = meow(
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global flag handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Handle 'completion' subcommand before any prompting or API calls
|
||||
if (cli.input[0] === "completion") {
|
||||
const shell = cli.input[1] || "bash";
|
||||
@@ -294,22 +283,17 @@ if (!apiKey && !NO_API_KEY_REQUIRED.has(provider.toLowerCase())) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const flagPresent = Object.hasOwn(cli.flags, "disableResponseStorage");
|
||||
|
||||
const disableResponseStorage = flagPresent
|
||||
? Boolean(cli.flags.disableResponseStorage) // value user actually passed
|
||||
: (config.disableResponseStorage ?? false); // fall back to YAML, default to false
|
||||
|
||||
config = {
|
||||
apiKey,
|
||||
...config,
|
||||
model: model ?? config.model,
|
||||
notify: Boolean(cli.flags.notify),
|
||||
reasoningEffort:
|
||||
(cli.flags.reasoning as ReasoningEffort | undefined) ?? "high",
|
||||
flexMode: Boolean(cli.flags.flexMode),
|
||||
provider,
|
||||
disableResponseStorage,
|
||||
disableResponseStorage:
|
||||
cli.flags.disableResponseStorage !== undefined
|
||||
? Boolean(cli.flags.disableResponseStorage)
|
||||
: config.disableResponseStorage,
|
||||
};
|
||||
|
||||
// Check for updates after loading config. This is important because we write state file in
|
||||
|
||||
@@ -106,16 +106,11 @@ export default function TerminalChatInputThinking({
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box justifyContent="space-between">
|
||||
<Box gap={2}>
|
||||
<Text>{frameWithSeconds}</Text>
|
||||
<Text>
|
||||
Thinking
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box gap={2}>
|
||||
<Text>{frameWithSeconds}</Text>
|
||||
<Text>
|
||||
Press <Text bold>Esc</Text> twice to interrupt
|
||||
Thinking
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
|
||||
@@ -412,7 +412,7 @@ export default function TerminalChatInput({
|
||||
setInput("");
|
||||
openApprovalOverlay();
|
||||
return;
|
||||
} else if (["exit", "q", ":q"].includes(inputValue)) {
|
||||
} else if (inputValue === "exit") {
|
||||
setInput("");
|
||||
setTimeout(() => {
|
||||
app.exit();
|
||||
@@ -881,30 +881,20 @@ function TerminalChatInputThinking({
|
||||
);
|
||||
|
||||
return (
|
||||
<Box width="100%" flexDirection="column" gap={1}>
|
||||
<Box
|
||||
flexDirection="row"
|
||||
width="100%"
|
||||
justifyContent="space-between"
|
||||
paddingRight={1}
|
||||
>
|
||||
<Box gap={2}>
|
||||
<Text>{frameWithSeconds}</Text>
|
||||
<Text>
|
||||
Thinking
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" gap={1}>
|
||||
<Box gap={2}>
|
||||
<Text>{frameWithSeconds}</Text>
|
||||
<Text>
|
||||
<Text dimColor>press</Text> <Text bold>Esc</Text>{" "}
|
||||
{awaitingConfirm ? (
|
||||
<Text bold>again</Text>
|
||||
) : (
|
||||
<Text dimColor>twice</Text>
|
||||
)}{" "}
|
||||
<Text dimColor>to interrupt</Text>
|
||||
Thinking
|
||||
{dots}
|
||||
</Text>
|
||||
</Box>
|
||||
{awaitingConfirm && (
|
||||
<Text dimColor>
|
||||
Press <Text bold>Esc</Text> again to interrupt and enter a new
|
||||
instruction
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
<Text color="blueBright">↳</Text> approval:{" "}
|
||||
<Text bold color={colorsByPolicy[approvalPolicy]}>
|
||||
<Text bold color={colorsByPolicy[approvalPolicy]} dimColor>
|
||||
{approvalPolicy}
|
||||
</Text>
|
||||
</Text>
|
||||
|
||||
@@ -610,24 +610,6 @@ export default class TextBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
* Document-level navigation helpers
|
||||
* ---------------------------------------------------------------- */
|
||||
|
||||
/** Move caret to *absolute* beginning of the buffer (row-0, col-0). */
|
||||
private moveToStartOfDocument(): void {
|
||||
this.preferredCol = null;
|
||||
this.cursorRow = 0;
|
||||
this.cursorCol = 0;
|
||||
}
|
||||
|
||||
/** Move caret to *absolute* end of the buffer (last row, last column). */
|
||||
private moveToEndOfDocument(): void {
|
||||
this.preferredCol = null;
|
||||
this.cursorRow = this.lines.length - 1;
|
||||
this.cursorCol = this.lineLen(this.cursorRow);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Higher‑level helpers
|
||||
* =================================================================== */
|
||||
@@ -798,18 +780,6 @@ export default class TextBuffer {
|
||||
key["rightArrow"]
|
||||
) {
|
||||
this.move("wordRight");
|
||||
}
|
||||
// Many terminal/OS combinations (e.g. macOS Terminal.app & iTerm2 with
|
||||
// the default key-bindings) translate ⌥← / ⌥→ into the classic readline
|
||||
// shortcuts ESC-b / ESC-f rather than an ANSI arrow sequence that Ink
|
||||
// would tag with `leftArrow` / `rightArrow`. Ink parses those 2-byte
|
||||
// escape sequences into `input === "b"|"f"` with `key.meta === true`.
|
||||
// Handle this variant explicitly so that Option+Arrow performs word
|
||||
// navigation consistently across environments.
|
||||
else if (key["meta"] && (input === "b" || input === "B")) {
|
||||
this.move("wordLeft");
|
||||
} else if (key["meta"] && (input === "f" || input === "F")) {
|
||||
this.move("wordRight");
|
||||
} else if (key["home"]) {
|
||||
this.move("home");
|
||||
} else if (key["end"]) {
|
||||
@@ -853,11 +823,11 @@ export default class TextBuffer {
|
||||
|
||||
// Emacs/readline-style shortcuts
|
||||
else if (key["ctrl"] && (input === "a" || input === "\x01")) {
|
||||
// Ctrl+A → start of input (first row, first column)
|
||||
this.moveToStartOfDocument();
|
||||
// Ctrl+A or ⌥← → start of line
|
||||
this.move("home");
|
||||
} else if (key["ctrl"] && (input === "e" || input === "\x05")) {
|
||||
// Ctrl+E → end of input (last row, last column)
|
||||
this.moveToEndOfDocument();
|
||||
// Ctrl+E or ⌥→ → end of line
|
||||
this.move("end");
|
||||
} else if (key["ctrl"] && (input === "b" || input === "\x02")) {
|
||||
// Ctrl+B → char left
|
||||
this.move("left");
|
||||
|
||||
@@ -34,7 +34,7 @@ import OpenAI, { APIConnectionTimeoutError } from "openai";
|
||||
|
||||
// Wait time before retrying after rate limit errors (ms).
|
||||
const RATE_LIMIT_RETRY_WAIT_MS = parseInt(
|
||||
process.env["OPENAI_RATE_LIMIT_RETRY_WAIT_MS"] || "500",
|
||||
process.env["OPENAI_RATE_LIMIT_RETRY_WAIT_MS"] || "2500",
|
||||
10,
|
||||
);
|
||||
|
||||
@@ -46,7 +46,6 @@ export type CommandConfirmation = {
|
||||
};
|
||||
|
||||
const alreadyProcessedResponses = new Set();
|
||||
const alreadyStagedItemIds = new Set<string>();
|
||||
|
||||
type AgentLoopParams = {
|
||||
model: string;
|
||||
@@ -563,12 +562,6 @@ export class AgentLoop {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip items we've already processed to avoid staging duplicates
|
||||
if (item.id && alreadyStagedItemIds.has(item.id)) {
|
||||
return;
|
||||
}
|
||||
alreadyStagedItemIds.add(item.id);
|
||||
|
||||
// Store the item so the final flush can still operate on a complete list.
|
||||
// We'll nil out entries once they're delivered.
|
||||
const idx = staged.push(item) - 1;
|
||||
@@ -671,12 +664,12 @@ export class AgentLoop {
|
||||
let stream;
|
||||
|
||||
// Retry loop for transient errors. Up to MAX_RETRIES attempts.
|
||||
const MAX_RETRIES = 8;
|
||||
const MAX_RETRIES = 5;
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
try {
|
||||
let reasoning: Reasoning | undefined;
|
||||
if (this.model.startsWith("o")) {
|
||||
reasoning = { effort: this.config.reasoningEffort ?? "high" };
|
||||
reasoning = { effort: "high" };
|
||||
if (this.model === "o3" || this.model === "o4-mini") {
|
||||
reasoning.summary = "auto";
|
||||
}
|
||||
@@ -1137,7 +1130,7 @@ export class AgentLoop {
|
||||
content: [
|
||||
{
|
||||
type: "input_text",
|
||||
text: `\u26a0 Insufficient quota: ${err instanceof Error && err.message ? err.message.trim() : "No remaining quota."} Manage or purchase credits at https://platform.openai.com/account/billing.`,
|
||||
text: "⚠️ Insufficient quota. Please check your billing details and retry.",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { CommandConfirmation } from "./agent-loop.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "../../approvals.js";
|
||||
import type { AppConfig } from "../config.js";
|
||||
import type { ExecInput } from "./sandbox/interface.js";
|
||||
import type { ApplyPatchCommand, ApprovalPolicy } from "../../approvals.js";
|
||||
import type { ResponseInputItem } from "openai/resources/responses/responses.mjs";
|
||||
|
||||
import { canAutoApprove } from "../../approvals.js";
|
||||
import { formatCommandForDisplay } from "../../format-command.js";
|
||||
import { FullAutoErrorMode } from "../auto-approval-mode.js";
|
||||
import { CODEX_UNSAFE_ALLOW_NO_SANDBOX, type AppConfig } from "../config.js";
|
||||
import { exec, execApplyPatch } from "./exec.js";
|
||||
import { ReviewDecision } from "./review.js";
|
||||
import { isLoggingEnabled, log } from "../logger/log.js";
|
||||
import { FullAutoErrorMode } from "../auto-approval-mode.js";
|
||||
import { SandboxType } from "./sandbox/interface.js";
|
||||
import { PATH_TO_SEATBELT_EXECUTABLE } from "./sandbox/macos-seatbelt.js";
|
||||
import fs from "fs/promises";
|
||||
import { canAutoApprove } from "../../approvals.js";
|
||||
import { formatCommandForDisplay } from "../../format-command.js";
|
||||
import { isLoggingEnabled, log } from "../logger/log.js";
|
||||
import { access } from "fs/promises";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session‑level cache of commands that the user has chosen to always approve.
|
||||
@@ -218,7 +217,7 @@ async function execCommand(
|
||||
let { workdir } = execInput;
|
||||
if (workdir) {
|
||||
try {
|
||||
await fs.access(workdir);
|
||||
await access(workdir);
|
||||
} catch (e) {
|
||||
log(`EXEC workdir=${workdir} not found, use process.cwd() instead`);
|
||||
workdir = process.cwd();
|
||||
@@ -271,45 +270,30 @@ async function execCommand(
|
||||
};
|
||||
}
|
||||
|
||||
/** Return `true` if the `/usr/bin/sandbox-exec` is present and executable. */
|
||||
const isSandboxExecAvailable: Promise<boolean> = fs
|
||||
.access(PATH_TO_SEATBELT_EXECUTABLE, fs.constants.X_OK)
|
||||
.then(
|
||||
() => true,
|
||||
(err) => {
|
||||
if (!["ENOENT", "ACCESS", "EPERM"].includes(err.code)) {
|
||||
log(
|
||||
`Unexpected error for \`stat ${PATH_TO_SEATBELT_EXECUTABLE}\`: ${err.message}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
);
|
||||
const isInLinux = 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") {
|
||||
// On macOS we rely on the system-provided `sandbox-exec` binary to
|
||||
// enforce the Seatbelt profile. However, starting with macOS 14 the
|
||||
// executable may be removed from the default installation or the user
|
||||
// might be running the CLI on a stripped-down environment (for
|
||||
// instance, inside certain CI images). Attempting to spawn a missing
|
||||
// binary makes Node.js throw an *uncaught* `ENOENT` error further down
|
||||
// the stack which crashes the whole CLI.
|
||||
if (await isSandboxExecAvailable) {
|
||||
return SandboxType.MACOS_SEATBELT;
|
||||
} else {
|
||||
throw new Error(
|
||||
"Sandbox was mandated, but 'sandbox-exec' was not found in PATH!",
|
||||
);
|
||||
}
|
||||
} else if (CODEX_UNSAFE_ALLOW_NO_SANDBOX) {
|
||||
// Allow running without a sandbox if the user has explicitly marked the
|
||||
// environment as already being sufficiently locked-down.
|
||||
return SandboxType.MACOS_SEATBELT;
|
||||
} else if (await isInLinux()) {
|
||||
return SandboxType.NONE;
|
||||
} else if (process.platform === "win32") {
|
||||
// On Windows, we don't have a sandbox implementation yet, so we fall back to NONE
|
||||
// instead of throwing an error, which would crash the application
|
||||
log(
|
||||
"WARNING: Sandbox was requested but is not available on Windows. Continuing without sandbox.",
|
||||
);
|
||||
return SandboxType.NONE;
|
||||
}
|
||||
|
||||
// For all else, we hard fail if the user has requested a sandbox and none is available.
|
||||
// For other platforms, still throw an error as before
|
||||
throw new Error("Sandbox was mandated, but no sandbox is available!");
|
||||
} else {
|
||||
return SandboxType.NONE;
|
||||
|
||||
@@ -12,14 +12,6 @@ function getCommonRoots() {
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
|
||||
* to defend against an attacker trying to inject a malicious version on the
|
||||
* PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker
|
||||
* already has root access.
|
||||
*/
|
||||
export const PATH_TO_SEATBELT_EXECUTABLE = "/usr/bin/sandbox-exec";
|
||||
|
||||
export function execWithSeatbelt(
|
||||
cmd: Array<string>,
|
||||
opts: SpawnOptions,
|
||||
@@ -65,7 +57,7 @@ export function execWithSeatbelt(
|
||||
);
|
||||
|
||||
const fullCommand = [
|
||||
PATH_TO_SEATBELT_EXECUTABLE,
|
||||
"sandbox-exec",
|
||||
"-p",
|
||||
fullPolicy,
|
||||
...policyTemplateParams,
|
||||
|
||||
@@ -7,42 +7,15 @@
|
||||
// compiled `dist/` output used by the published CLI.
|
||||
|
||||
import type { FullAutoErrorMode } from "./auto-approval-mode.js";
|
||||
import type { ReasoningEffort } from "openai/resources.mjs";
|
||||
|
||||
import { AutoApprovalMode } from "./auto-approval-mode.js";
|
||||
import { log } from "./logger/log.js";
|
||||
import { providers } from "./providers.js";
|
||||
import { config as loadDotenv } from "dotenv";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
||||
import { load as loadYaml, dump as dumpYaml } from "js-yaml";
|
||||
import { homedir } from "os";
|
||||
import { dirname, join, extname, resolve as resolvePath } from "path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User‑wide environment config (~/.codex.env)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Load a user‑level dotenv file **after** process.env and any project‑local
|
||||
// .env file (loaded via "dotenv/config" in cli.tsx) are in place. We rely on
|
||||
// dotenv's default behaviour of *not* overriding existing variables so that
|
||||
// the precedence order becomes:
|
||||
// 1. Explicit environment variables
|
||||
// 2. Project‑local .env (handled in cli.tsx)
|
||||
// 3. User‑wide ~/.codex.env (loaded here)
|
||||
// This guarantees that users can still override the global key on a per‑project
|
||||
// basis while enjoying the convenience of a persistent default.
|
||||
|
||||
// Skip when running inside Vitest to avoid interfering with the FS mocks used
|
||||
// by tests that stub out `fs` *after* importing this module.
|
||||
const USER_WIDE_CONFIG_PATH = join(homedir(), ".codex.env");
|
||||
|
||||
const isVitest =
|
||||
typeof (globalThis as { vitest?: unknown }).vitest !== "undefined";
|
||||
|
||||
if (!isVitest) {
|
||||
loadDotenv({ path: USER_WIDE_CONFIG_PATH });
|
||||
}
|
||||
|
||||
export const DEFAULT_AGENTIC_MODEL = "o4-mini";
|
||||
export const DEFAULT_FULL_CONTEXT_MODEL = "gpt-4.1";
|
||||
export const DEFAULT_APPROVAL_MODE = AutoApprovalMode.SUGGEST;
|
||||
@@ -63,17 +36,9 @@ export const OPENAI_TIMEOUT_MS =
|
||||
parseInt(process.env["OPENAI_TIMEOUT_MS"] || "0", 10) || undefined;
|
||||
export const OPENAI_BASE_URL = process.env["OPENAI_BASE_URL"] || "";
|
||||
export let OPENAI_API_KEY = process.env["OPENAI_API_KEY"] || "";
|
||||
|
||||
export const DEFAULT_REASONING_EFFORT = "high";
|
||||
export const OPENAI_ORGANIZATION = process.env["OPENAI_ORGANIZATION"] || "";
|
||||
export const OPENAI_PROJECT = process.env["OPENAI_PROJECT"] || "";
|
||||
|
||||
// Can be set `true` when Codex is running in an environment that is marked as already
|
||||
// considered sufficiently locked-down so that we allow running wihtout an explicit sandbox.
|
||||
export const CODEX_UNSAFE_ALLOW_NO_SANDBOX = Boolean(
|
||||
process.env["CODEX_UNSAFE_ALLOW_NO_SANDBOX"] || "",
|
||||
);
|
||||
|
||||
export function setApiKey(apiKey: string): void {
|
||||
OPENAI_API_KEY = apiKey;
|
||||
}
|
||||
@@ -145,9 +110,6 @@ export type StoredConfig = {
|
||||
saveHistory?: boolean;
|
||||
sensitivePatterns?: Array<string>;
|
||||
};
|
||||
/** User-defined safe commands */
|
||||
safeCommands?: Array<string>;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
};
|
||||
|
||||
// Minimal config written on first run. An *empty* model string ensures that
|
||||
@@ -155,7 +117,7 @@ export type StoredConfig = {
|
||||
// propagating to existing users until they explicitly set a model.
|
||||
export const EMPTY_STORED_CONFIG: StoredConfig = { model: "" };
|
||||
|
||||
// Pre‑stringified JSON variant so we don't stringify repeatedly.
|
||||
// Pre‑stringified JSON variant so we don’t stringify repeatedly.
|
||||
const EMPTY_CONFIG_JSON = JSON.stringify(EMPTY_STORED_CONFIG, null, 2) + "\n";
|
||||
|
||||
export type MemoryConfig = {
|
||||
@@ -171,7 +133,6 @@ export type AppConfig = {
|
||||
approvalMode?: AutoApprovalMode;
|
||||
fullAutoErrorMode?: FullAutoErrorMode;
|
||||
memory?: MemoryConfig;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
/** Whether to enable desktop notifications for responses */
|
||||
notify?: boolean;
|
||||
|
||||
@@ -323,22 +284,6 @@ export const loadConfig = (
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
storedConfig.disableResponseStorage !== undefined &&
|
||||
typeof storedConfig.disableResponseStorage !== "boolean"
|
||||
) {
|
||||
if (storedConfig.disableResponseStorage === "true") {
|
||||
storedConfig.disableResponseStorage = true;
|
||||
} else if (storedConfig.disableResponseStorage === "false") {
|
||||
storedConfig.disableResponseStorage = false;
|
||||
} else {
|
||||
log(
|
||||
`[codex] Warning: 'disableResponseStorage' in config is not a boolean (got '${storedConfig.disableResponseStorage}'). Ignoring this value.`,
|
||||
);
|
||||
delete storedConfig.disableResponseStorage;
|
||||
}
|
||||
}
|
||||
|
||||
const instructionsFilePathResolved =
|
||||
instructionsPath ?? INSTRUCTIONS_FILEPATH;
|
||||
const userInstructions = existsSync(instructionsFilePathResolved)
|
||||
@@ -388,8 +333,7 @@ export const loadConfig = (
|
||||
instructions: combinedInstructions,
|
||||
notify: storedConfig.notify === true,
|
||||
approvalMode: storedConfig.approvalMode,
|
||||
disableResponseStorage: storedConfig.disableResponseStorage === true,
|
||||
reasoningEffort: storedConfig.reasoningEffort,
|
||||
disableResponseStorage: storedConfig.disableResponseStorage ?? false,
|
||||
};
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
@@ -504,8 +448,6 @@ export const saveConfig = (
|
||||
provider: config.provider,
|
||||
providers: config.providers,
|
||||
approvalMode: config.approvalMode,
|
||||
disableResponseStorage: config.disableResponseStorage,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
};
|
||||
|
||||
// Add history settings if they exist
|
||||
|
||||
@@ -1,26 +1,5 @@
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
// The objects thrown by `child_process.execSync()` are `Error` instances that
|
||||
// include additional, undocumented properties such as `status` (exit code) and
|
||||
// `stdout` (captured standard output). Declare a minimal interface that captures
|
||||
// just the fields we need so that we can avoid the use of `any` while keeping
|
||||
// the checks type-safe.
|
||||
interface ExecSyncError extends Error {
|
||||
// Exit status code. When a diff is produced, git exits with code 1 which we
|
||||
// treat as a non-error signal.
|
||||
status?: number;
|
||||
// Captured stdout. We rely on this to obtain the diff output when git exits
|
||||
// with status 1.
|
||||
stdout?: string;
|
||||
}
|
||||
|
||||
// Type-guard that narrows an unknown value to `ExecSyncError`.
|
||||
function isExecSyncError(err: unknown): err is ExecSyncError {
|
||||
return (
|
||||
typeof err === "object" && err != null && "status" in err && "stdout" in err
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current Git diff for the working directory. If the current
|
||||
* working directory is not inside a Git repository, `isGitRepo` will be
|
||||
@@ -36,86 +15,13 @@ export function getGitDiff(): {
|
||||
execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" });
|
||||
|
||||
// If the above call didn’t throw, we are inside a git repo. Retrieve the
|
||||
// diff for tracked files **and** include any untracked files so that the
|
||||
// `/diff` overlay shows a complete picture of the working tree state.
|
||||
// diff including color codes so that the overlay can render them.
|
||||
const output = execSync("git diff --color", {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024, // 10 MB ought to be enough for now
|
||||
});
|
||||
|
||||
// 1. Diff for tracked files (unchanged behaviour)
|
||||
let trackedDiff = "";
|
||||
try {
|
||||
trackedDiff = execSync("git diff --color", {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024, // 10 MB ought to be enough for now
|
||||
});
|
||||
} catch (err) {
|
||||
// Exit status 1 simply means that differences were found. Capture the
|
||||
// diff from stdout in that case. Re-throw for any other status codes.
|
||||
if (
|
||||
isExecSyncError(err) &&
|
||||
err.status === 1 &&
|
||||
typeof err.stdout === "string"
|
||||
) {
|
||||
trackedDiff = err.stdout;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Determine untracked files.
|
||||
// We use `git ls-files --others --exclude-standard` which outputs paths
|
||||
// relative to the repository root, one per line. These are files that
|
||||
// are not tracked *and* are not ignored by .gitignore.
|
||||
const untrackedOutput = execSync(
|
||||
"git ls-files --others --exclude-standard",
|
||||
{
|
||||
encoding: "utf8",
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
},
|
||||
);
|
||||
|
||||
const untrackedFiles = untrackedOutput
|
||||
.split("\n")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
let untrackedDiff = "";
|
||||
|
||||
const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null";
|
||||
|
||||
for (const file of untrackedFiles) {
|
||||
try {
|
||||
// `git diff --no-index` produces a diff even outside the index by
|
||||
// comparing two paths. We compare the file against /dev/null so that
|
||||
// the file is treated as "new".
|
||||
//
|
||||
// `git diff --color --no-index /dev/null <file>` exits with status 1
|
||||
// when differences are found, so we capture stdout from the thrown
|
||||
// error object instead of letting it propagate.
|
||||
execSync(`git diff --color --no-index -- "${nullDevice}" "${file}"`, {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
} catch (err) {
|
||||
if (
|
||||
isExecSyncError(err) &&
|
||||
// Exit status 1 simply means that the two inputs differ, which is
|
||||
// exactly what we expect here. Any other status code indicates a
|
||||
// real error (e.g. the file disappeared between the ls-files and
|
||||
// diff calls), so re-throw those.
|
||||
err.status === 1 &&
|
||||
typeof err.stdout === "string"
|
||||
) {
|
||||
untrackedDiff += err.stdout;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Concatenate tracked and untracked diffs.
|
||||
const combinedDiff = `${trackedDiff}${untrackedDiff}`;
|
||||
|
||||
return { isGitRepo: true, diff: combinedDiff };
|
||||
return { isGitRepo: true, diff: output };
|
||||
} catch {
|
||||
// Either git is not installed or we’re not inside a repository.
|
||||
return { isGitRepo: false, diff: "" };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export const CLI_VERSION = "0.1.2504251709"; // Must be in sync with package.json.
|
||||
export const CLI_VERSION = "0.1.2504221401"; // Must be in sync with package.json.
|
||||
export const ORIGIN = "codex_cli_ts";
|
||||
|
||||
export type TerminalChatSession = {
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// This regression test ensures that AgentLoop only surfaces each response item
|
||||
// once even when the same item appears multiple times in the OpenAI streaming
|
||||
// response (e.g. as an early `response.output_item.done` event *and* again in
|
||||
// the final `response.completed` payload).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Fake OpenAI stream that emits the *same* message twice: first as an
|
||||
// incremental output event and then again in the turn completion payload.
|
||||
class FakeStream {
|
||||
public controller = { abort: vi.fn() };
|
||||
|
||||
async *[Symbol.asyncIterator]() {
|
||||
// 1) Early incremental item.
|
||||
yield {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "call-dedupe-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_text", text: "Hello!" }],
|
||||
},
|
||||
} as any;
|
||||
|
||||
// 2) Turn completion containing the *same* item again.
|
||||
yield {
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp-dedupe-1",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
id: "call-dedupe-1",
|
||||
role: "assistant",
|
||||
content: [{ type: "input_text", text: "Hello!" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
}
|
||||
}
|
||||
|
||||
// Intercept the OpenAI SDK used inside AgentLoop so we can inject our fake
|
||||
// streaming implementation.
|
||||
vi.mock("openai", () => {
|
||||
class FakeOpenAI {
|
||||
public responses = {
|
||||
create: async () => new FakeStream(),
|
||||
};
|
||||
}
|
||||
|
||||
class APIConnectionTimeoutError extends Error {}
|
||||
|
||||
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
|
||||
});
|
||||
|
||||
// Stub approvals / formatting helpers – not relevant here.
|
||||
vi.mock("../src/approvals.js", () => ({
|
||||
__esModule: true,
|
||||
alwaysApprovedCommands: new Set<string>(),
|
||||
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
|
||||
isSafeCommand: () => null,
|
||||
}));
|
||||
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
__esModule: true,
|
||||
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
|
||||
}));
|
||||
|
||||
vi.mock("../src/utils/agent/log.js", () => ({
|
||||
__esModule: true,
|
||||
log: () => {},
|
||||
isLoggingEnabled: () => false,
|
||||
}));
|
||||
|
||||
// After the dependency mocks we can import the module under test.
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
|
||||
|
||||
describe("AgentLoop deduplicates output items", () => {
|
||||
it("invokes onItem exactly once for duplicate items with the same id", async () => {
|
||||
const received: Array<any> = [];
|
||||
|
||||
const agent = new AgentLoop({
|
||||
model: "any",
|
||||
instructions: "",
|
||||
config: { model: "any", instructions: "", notify: false },
|
||||
approvalPolicy: { mode: "auto" } as any,
|
||||
additionalWritableRoots: [],
|
||||
onItem: (item) => received.push(item),
|
||||
onLoading: () => {},
|
||||
getCommandConfirmation: async () => ({ review: "yes" }) as any,
|
||||
onLastResponseId: () => {},
|
||||
});
|
||||
|
||||
const userMsg = [
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hi" }],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.run(userMsg as any);
|
||||
|
||||
// Give the setTimeout(3ms) inside AgentLoop.stageItem a chance to fire.
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
// Count how many times the duplicate item surfaced.
|
||||
const appearances = received.filter((i) => i.id === "call-dedupe-1").length;
|
||||
expect(appearances).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -98,8 +98,10 @@ describe("AgentLoop – rate‑limit handling", () => {
|
||||
// is in progress.
|
||||
const runPromise = agent.run(userMsg as any);
|
||||
|
||||
// Should be done in at most 180 seconds.
|
||||
await vi.advanceTimersByTimeAsync(180_000);
|
||||
// The agent waits 15 000 ms between retries (rate‑limit back‑off) and does
|
||||
// this four times (after attempts 1‑4). Fast‑forward a bit more to cover
|
||||
// any additional small `setTimeout` calls inside the implementation.
|
||||
await vi.advanceTimersByTimeAsync(61_000); // 4 * 15s + 1s safety margin
|
||||
|
||||
// Ensure the promise settles without throwing.
|
||||
await expect(runPromise).resolves.not.toThrow();
|
||||
@@ -108,8 +110,8 @@ describe("AgentLoop – rate‑limit handling", () => {
|
||||
await vi.advanceTimersByTimeAsync(20);
|
||||
|
||||
// The OpenAI client should have been called the maximum number of retry
|
||||
// attempts (8).
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(8);
|
||||
// attempts (5).
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(5);
|
||||
|
||||
// Finally, verify that the user sees a helpful system message.
|
||||
const sysMsg = received.find(
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("AgentLoop – automatic retry on 5xx errors", () => {
|
||||
expect(assistant?.content?.[0]?.text).toBe("ok");
|
||||
});
|
||||
|
||||
it("fails after a few attempts and surfaces system message", async () => {
|
||||
it("fails after 3 attempts and surfaces system message", async () => {
|
||||
openAiState.createSpy = vi.fn(async () => {
|
||||
const err: any = new Error("Internal Server Error");
|
||||
err.status = 502; // any 5xx
|
||||
@@ -154,7 +154,7 @@ describe("AgentLoop – automatic retry on 5xx errors", () => {
|
||||
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(8);
|
||||
expect(openAiState.createSpy).toHaveBeenCalledTimes(5);
|
||||
|
||||
const sysMsg = received.find(
|
||||
(i) =>
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
loadConfig,
|
||||
DEFAULT_REASONING_EFFORT,
|
||||
saveConfig,
|
||||
} from "../src/utils/config";
|
||||
import type { ReasoningEffort } from "openai/resources.mjs";
|
||||
import * as fs from "fs";
|
||||
|
||||
// Mock the fs module
|
||||
vi.mock("fs", () => ({
|
||||
existsSync: vi.fn(),
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
mkdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock path.dirname
|
||||
vi.mock("path", async () => {
|
||||
const actual = await vi.importActual("path");
|
||||
return {
|
||||
...actual,
|
||||
dirname: vi.fn().mockReturnValue("/mock/dir"),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Reasoning Effort Configuration", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should have "high" as the default reasoning effort', () => {
|
||||
expect(DEFAULT_REASONING_EFFORT).toBe("high");
|
||||
});
|
||||
|
||||
it("should use default reasoning effort when not specified in config", () => {
|
||||
// Mock fs.existsSync to return true for config file
|
||||
vi.mocked(fs.existsSync).mockImplementation(() => true);
|
||||
|
||||
// Mock fs.readFileSync to return a JSON with no reasoningEffort
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() =>
|
||||
JSON.stringify({ model: "test-model" }),
|
||||
);
|
||||
|
||||
const config = loadConfig("/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Config should not have reasoningEffort explicitly set
|
||||
expect(config.reasoningEffort).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should load reasoningEffort from config file", () => {
|
||||
// Mock fs.existsSync to return true for config file
|
||||
vi.mocked(fs.existsSync).mockImplementation(() => true);
|
||||
|
||||
// Mock fs.readFileSync to return a JSON with reasoningEffort
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() =>
|
||||
JSON.stringify({
|
||||
model: "test-model",
|
||||
reasoningEffort: "low" as ReasoningEffort,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = loadConfig("/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Config should have the reasoningEffort from the file
|
||||
expect(config.reasoningEffort).toBe("low");
|
||||
});
|
||||
|
||||
it("should support all valid reasoning effort values", () => {
|
||||
// Valid values for ReasoningEffort
|
||||
const validEfforts: Array<ReasoningEffort> = ["low", "medium", "high"];
|
||||
|
||||
for (const effort of validEfforts) {
|
||||
// Mock fs.existsSync to return true for config file
|
||||
vi.mocked(fs.existsSync).mockImplementation(() => true);
|
||||
|
||||
// Mock fs.readFileSync to return a JSON with reasoningEffort
|
||||
vi.mocked(fs.readFileSync).mockImplementation(() =>
|
||||
JSON.stringify({
|
||||
model: "test-model",
|
||||
reasoningEffort: effort,
|
||||
}),
|
||||
);
|
||||
|
||||
const config = loadConfig("/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Config should have the correct reasoningEffort
|
||||
expect(config.reasoningEffort).toBe(effort);
|
||||
}
|
||||
});
|
||||
|
||||
it("should preserve reasoningEffort when saving configuration", () => {
|
||||
// Setup
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
// Create config with reasoningEffort
|
||||
const configToSave = {
|
||||
model: "test-model",
|
||||
instructions: "",
|
||||
reasoningEffort: "medium" as ReasoningEffort,
|
||||
notify: false,
|
||||
};
|
||||
|
||||
// Act
|
||||
saveConfig(configToSave, "/mock/config.json", "/mock/instructions.md");
|
||||
|
||||
// Assert
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
"/mock/config.json",
|
||||
expect.stringContaining('"model"'),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
// Note: Current implementation of saveConfig doesn't save reasoningEffort,
|
||||
// this test would need to be updated if that functionality is added
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* codex-cli/tests/disableResponseStorage.agentLoop.test.ts
|
||||
*
|
||||
* Verifies AgentLoop's request-building logic for both values of
|
||||
* disableResponseStorage.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { AgentLoop } from "../src/utils/agent/agent-loop";
|
||||
import type { AppConfig } from "../src/utils/config";
|
||||
import { ReviewDecision } from "../src/utils/agent/review";
|
||||
|
||||
/* ─────────── 1. Spy + module mock ─────────────────────────────── */
|
||||
const createSpy = vi.fn().mockResolvedValue({
|
||||
data: { id: "resp_123", status: "completed", output: [] },
|
||||
});
|
||||
|
||||
vi.mock("openai", () => ({
|
||||
default: class {
|
||||
public responses = { create: createSpy };
|
||||
},
|
||||
APIConnectionTimeoutError: class extends Error {},
|
||||
}));
|
||||
|
||||
/* ─────────── 2. Parametrised tests ─────────────────────────────── */
|
||||
describe.each([
|
||||
{ flag: true, title: "omits previous_response_id & sets store:false" },
|
||||
{ flag: false, title: "sends previous_response_id & allows store:true" },
|
||||
])("AgentLoop with disableResponseStorage=%s", ({ flag, title }) => {
|
||||
/* build a fresh config for each case */
|
||||
const cfg: AppConfig = {
|
||||
model: "o4-mini",
|
||||
provider: "openai",
|
||||
instructions: "",
|
||||
disableResponseStorage: flag,
|
||||
notify: false,
|
||||
};
|
||||
|
||||
it(title, async () => {
|
||||
/* reset spy per iteration */
|
||||
createSpy.mockClear();
|
||||
|
||||
const loop = new AgentLoop({
|
||||
model: cfg.model,
|
||||
provider: cfg.provider,
|
||||
config: cfg,
|
||||
instructions: "",
|
||||
approvalPolicy: "suggest",
|
||||
disableResponseStorage: flag,
|
||||
additionalWritableRoots: [],
|
||||
onItem() {},
|
||||
onLoading() {},
|
||||
getCommandConfirmation: async () => ({ review: ReviewDecision.YES }),
|
||||
onLastResponseId() {},
|
||||
});
|
||||
|
||||
await loop.run([
|
||||
{
|
||||
type: "message",
|
||||
role: "user",
|
||||
content: [{ type: "input_text", text: "hello" }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(createSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
const call = createSpy.mock.calls[0];
|
||||
if (!call) {
|
||||
throw new Error("Expected createSpy to have been called at least once");
|
||||
}
|
||||
const payload: any = call[0];
|
||||
|
||||
if (flag) {
|
||||
/* behaviour when ZDR is *on* */
|
||||
expect(payload).not.toHaveProperty("previous_response_id");
|
||||
if (payload.input) {
|
||||
payload.input.forEach((m: any) => {
|
||||
expect(m.store === undefined ? false : m.store).toBe(false);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
/* behaviour when ZDR is *off* */
|
||||
expect(payload).toHaveProperty("previous_response_id");
|
||||
if (payload.input) {
|
||||
payload.input.forEach((m: any) => {
|
||||
if ("store" in m) {
|
||||
expect(m.store).not.toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* codex/codex-cli/tests/disableResponseStorage.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { loadConfig, saveConfig } from "../src/utils/config";
|
||||
import type { AppConfig } from "../src/utils/config";
|
||||
|
||||
const sandboxHome: string = mkdtempSync(join(tmpdir(), "codex-home-"));
|
||||
const codexDir: string = join(sandboxHome, ".codex");
|
||||
const yamlPath: string = join(codexDir, "config.yaml");
|
||||
|
||||
describe("disableResponseStorage persistence", () => {
|
||||
beforeAll((): void => {
|
||||
// mkdir -p ~/.codex inside the sandbox
|
||||
rmSync(codexDir, { recursive: true, force: true });
|
||||
mkdirSync(codexDir, { recursive: true });
|
||||
|
||||
// seed YAML with ZDR enabled
|
||||
writeFileSync(yamlPath, "model: o4-mini\ndisableResponseStorage: true\n");
|
||||
});
|
||||
|
||||
afterAll((): void => {
|
||||
rmSync(sandboxHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("keeps disableResponseStorage=true across load/save cycle", async (): Promise<void> => {
|
||||
// 1️⃣ explicitly load the sandbox file
|
||||
const cfg1: AppConfig = loadConfig(yamlPath);
|
||||
expect(cfg1.disableResponseStorage).toBe(true);
|
||||
|
||||
// 2️⃣ save right back to the same file
|
||||
await saveConfig(cfg1, yamlPath);
|
||||
|
||||
// 3️⃣ reload and re-assert
|
||||
const cfg2: AppConfig = loadConfig(yamlPath);
|
||||
expect(cfg2.disableResponseStorage).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
/**
|
||||
* Verifies that ~/.codex.env is parsed (lowest‑priority) when present.
|
||||
*/
|
||||
|
||||
describe("user‑wide ~/.codex.env support", () => {
|
||||
const ORIGINAL_HOME = process.env["HOME"];
|
||||
const ORIGINAL_API_KEY = process.env["OPENAI_API_KEY"];
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
beforeEach(() => {
|
||||
// Create an isolated fake $HOME directory.
|
||||
tempHome = mkdtempSync(join(tmpdir(), "codex-home-"));
|
||||
process.env["HOME"] = tempHome;
|
||||
|
||||
// Ensure the env var is unset so that the file value is picked up.
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
|
||||
// Write ~/.codex.env with a dummy key.
|
||||
writeFileSync(
|
||||
join(tempHome, ".codex.env"),
|
||||
"OPENAI_API_KEY=my-home-key\n",
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup temp directory.
|
||||
try {
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Restore original env.
|
||||
if (ORIGINAL_HOME !== undefined) {
|
||||
process.env["HOME"] = ORIGINAL_HOME;
|
||||
} else {
|
||||
delete process.env["HOME"];
|
||||
}
|
||||
|
||||
if (ORIGINAL_API_KEY !== undefined) {
|
||||
process.env["OPENAI_API_KEY"] = ORIGINAL_API_KEY;
|
||||
} else {
|
||||
delete process.env["OPENAI_API_KEY"];
|
||||
}
|
||||
});
|
||||
|
||||
it("loads the API key from ~/.codex.env when not set elsewhere", async () => {
|
||||
// Import the config module AFTER setting up the fake env.
|
||||
const { getApiKey } = await import("../src/utils/config.js");
|
||||
|
||||
expect(getApiKey("openai")).toBe("my-home-key");
|
||||
});
|
||||
});
|
||||
24
codex-rs/Cargo.lock
generated
24
codex-rs/Cargo.lock
generated
@@ -475,6 +475,7 @@ dependencies = [
|
||||
"clap",
|
||||
"codex-core",
|
||||
"codex-exec",
|
||||
"codex-interactive",
|
||||
"codex-repl",
|
||||
"codex-tui",
|
||||
"serde_json",
|
||||
@@ -528,7 +529,6 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-core",
|
||||
"owo-colors",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
@@ -554,6 +554,16 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-interactive"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-core",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-repl"
|
||||
version = "0.1.0"
|
||||
@@ -561,7 +571,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-core",
|
||||
"owo-colors",
|
||||
"owo-colors 4.2.0",
|
||||
"rand",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -599,7 +609,7 @@ dependencies = [
|
||||
"eyre",
|
||||
"indenter",
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"owo-colors 3.5.0",
|
||||
"tracing-error",
|
||||
]
|
||||
|
||||
@@ -610,7 +620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"owo-colors",
|
||||
"owo-colors 3.5.0",
|
||||
"tracing-core",
|
||||
"tracing-error",
|
||||
]
|
||||
@@ -2225,6 +2235,12 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39"
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1b04fb49957986fdce4d6ee7a65027d55d4b6d2265e5848bbb507b58ccfdb6f"
|
||||
|
||||
[[package]]
|
||||
name = "owo-colors"
|
||||
version = "4.2.0"
|
||||
|
||||
@@ -7,6 +7,7 @@ members = [
|
||||
"core",
|
||||
"exec",
|
||||
"execpolicy",
|
||||
"interactive",
|
||||
"repl",
|
||||
"tui",
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ Currently, the Rust implementation is materially behind the TypeScript implement
|
||||
This folder is the root of a Cargo workspace. It contains quite a bit of experimental code, but here are the key crates:
|
||||
|
||||
- [`core/`](./core) contains the business logic for Codex. Ultimately, we hope this to be a library crate that is generally useful for building other Rust/native applications that use Codex.
|
||||
- [`interactive/`](./interactive) CLI with a UX comparable to the TypeScript Codex CLI.
|
||||
- [`exec/`](./exec) "headless" CLI for use in automation.
|
||||
- [`tui/`](./tui) CLI that launches a fullscreen TUI built with [Ratatui](https://ratatui.rs/).
|
||||
- [`repl/`](./repl) CLI that launches a lightweight REPL similar to the Python or Node.js REPL.
|
||||
|
||||
@@ -86,8 +86,6 @@ pub enum ApplyPatchFileChange {
|
||||
Update {
|
||||
unified_diff: String,
|
||||
move_path: Option<PathBuf>,
|
||||
/// new_content that will result after the unified_diff is applied.
|
||||
new_content: String,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -128,10 +126,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
|
||||
move_path,
|
||||
chunks,
|
||||
} => {
|
||||
let ApplyPatchFileUpdate {
|
||||
unified_diff,
|
||||
content: contents,
|
||||
} = match unified_diff_from_chunks(&path, &chunks) {
|
||||
let unified_diff = match unified_diff_from_chunks(&path, &chunks) {
|
||||
Ok(diff) => diff,
|
||||
Err(e) => {
|
||||
return MaybeApplyPatchVerified::CorrectnessError(e);
|
||||
@@ -142,7 +137,6 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
new_content: contents,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -522,17 +516,10 @@ fn apply_replacements(
|
||||
lines
|
||||
}
|
||||
|
||||
/// Intended result of a file update for apply_patch.
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub struct ApplyPatchFileUpdate {
|
||||
unified_diff: String,
|
||||
content: String,
|
||||
}
|
||||
|
||||
pub fn unified_diff_from_chunks(
|
||||
path: &Path,
|
||||
chunks: &[UpdateFileChunk],
|
||||
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
||||
) -> std::result::Result<String, ApplyPatchError> {
|
||||
unified_diff_from_chunks_with_context(path, chunks, 1)
|
||||
}
|
||||
|
||||
@@ -540,17 +527,13 @@ pub fn unified_diff_from_chunks_with_context(
|
||||
path: &Path,
|
||||
chunks: &[UpdateFileChunk],
|
||||
context: usize,
|
||||
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
|
||||
) -> std::result::Result<String, ApplyPatchError> {
|
||||
let AppliedPatch {
|
||||
original_contents,
|
||||
new_contents,
|
||||
} = derive_new_contents_from_chunks(path, chunks)?;
|
||||
let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
|
||||
let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
|
||||
Ok(ApplyPatchFileUpdate {
|
||||
unified_diff,
|
||||
content: new_contents,
|
||||
})
|
||||
Ok(text_diff.unified_diff().context_radius(context).to_string())
|
||||
}
|
||||
|
||||
/// Print the summary of changes in git-style format.
|
||||
@@ -915,11 +898,7 @@ PATCH"#,
|
||||
-qux
|
||||
+QUX
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "foo\nBAR\nbaz\nQUX\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
assert_eq!(expected_diff, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -951,11 +930,7 @@ PATCH"#,
|
||||
+FOO
|
||||
bar
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "FOO\nbar\nbaz\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
assert_eq!(expected_diff, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -988,11 +963,7 @@ PATCH"#,
|
||||
-baz
|
||||
+BAZ
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "foo\nbar\nBAZ\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
assert_eq!(expected_diff, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1022,11 +993,7 @@ PATCH"#,
|
||||
baz
|
||||
+quux
|
||||
"#;
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "foo\nbar\nbaz\nquux\n".to_string(),
|
||||
};
|
||||
assert_eq!(expected, diff);
|
||||
assert_eq!(expected_diff, diff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1065,7 +1032,7 @@ PATCH"#,
|
||||
|
||||
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
|
||||
|
||||
let expected_diff = r#"@@ -1,6 +1,7 @@
|
||||
let expected = r#"@@ -1,6 +1,7 @@
|
||||
a
|
||||
-b
|
||||
+B
|
||||
@@ -1077,11 +1044,6 @@ PATCH"#,
|
||||
+g
|
||||
"#;
|
||||
|
||||
let expected = ApplyPatchFileUpdate {
|
||||
unified_diff: expected_diff.to_string(),
|
||||
content: "a\nB\nc\nd\nE\nf\ng\n".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(expected, diff);
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
|
||||
@@ -12,6 +12,7 @@ anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-exec = { path = "../exec" }
|
||||
codex-interactive = { path = "../interactive" }
|
||||
codex-repl = { path = "../repl" }
|
||||
codex-tui = { path = "../tui" }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
//! `debug landlock` implementation for the Codex CLI.
|
||||
//!
|
||||
//! On Linux the command is executed inside a Landlock + seccomp sandbox by
|
||||
//! calling the low-level `exec_linux` helper from `codex_core::linux`.
|
||||
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
use std::path::PathBuf;
|
||||
use std::process;
|
||||
use std::process::Command;
|
||||
use std::process::ExitStatus;
|
||||
|
||||
/// Execute `command` in a Linux sandbox (Landlock + seccomp) the way Codex
|
||||
/// would.
|
||||
pub(crate) fn run_landlock(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
writable_roots: Vec<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
if command.is_empty() {
|
||||
anyhow::bail!("command args are empty");
|
||||
}
|
||||
|
||||
// Spawn a new thread and apply the sandbox policies there.
|
||||
let handle = std::thread::spawn(move || -> anyhow::Result<ExitStatus> {
|
||||
// Apply sandbox policies inside this thread so only the child inherits
|
||||
// them, not the entire CLI process.
|
||||
if sandbox_policy.is_network_restricted() {
|
||||
codex_core::linux::install_network_seccomp_filter_on_current_thread()?;
|
||||
}
|
||||
|
||||
if sandbox_policy.is_file_write_restricted() {
|
||||
codex_core::linux::install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
|
||||
}
|
||||
|
||||
let status = Command::new(&command[0]).args(&command[1..]).status()?;
|
||||
Ok(status)
|
||||
});
|
||||
let status = handle
|
||||
.join()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to join thread: {e:?}"))??;
|
||||
|
||||
// Use ExitStatus to derive the exit code.
|
||||
if let Some(code) = status.code() {
|
||||
process::exit(code);
|
||||
} else if let Some(signal) = status.signal() {
|
||||
process::exit(128 + signal);
|
||||
} else {
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod landlock;
|
||||
mod proto;
|
||||
mod seatbelt;
|
||||
|
||||
@@ -7,8 +5,8 @@ use std::path::PathBuf;
|
||||
|
||||
use clap::ArgAction;
|
||||
use clap::Parser;
|
||||
use codex_core::SandboxModeCliArg;
|
||||
use codex_exec::Cli as ExecCli;
|
||||
use codex_interactive::Cli as InteractiveCli;
|
||||
use codex_repl::Cli as ReplCli;
|
||||
use codex_tui::Cli as TuiCli;
|
||||
|
||||
@@ -26,7 +24,7 @@ use crate::proto::ProtoCli;
|
||||
)]
|
||||
struct MultitoolCli {
|
||||
#[clap(flatten)]
|
||||
interactive: TuiCli,
|
||||
interactive: InteractiveCli,
|
||||
|
||||
#[clap(subcommand)]
|
||||
subcommand: Option<Subcommand>,
|
||||
@@ -38,6 +36,10 @@ enum Subcommand {
|
||||
#[clap(visible_alias = "e")]
|
||||
Exec(ExecCli),
|
||||
|
||||
/// Run the TUI.
|
||||
#[clap(visible_alias = "t")]
|
||||
Tui(TuiCli),
|
||||
|
||||
/// Run the REPL.
|
||||
#[clap(visible_alias = "r")]
|
||||
Repl(ReplCli),
|
||||
@@ -60,41 +62,19 @@ struct DebugArgs {
|
||||
enum DebugCommand {
|
||||
/// Run a command under Seatbelt (macOS only).
|
||||
Seatbelt(SeatbeltCommand),
|
||||
|
||||
/// Run a command under Landlock+seccomp (Linux only).
|
||||
Landlock(LandlockCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct SeatbeltCommand {
|
||||
/// Writable folder for sandbox (can be specified multiple times).
|
||||
/// Writable folder for sandbox in full-auto mode (can be specified multiple times).
|
||||
#[arg(long = "writable-root", short = 'w', value_name = "DIR", action = ArgAction::Append, use_value_delimiter = false)]
|
||||
writable_roots: Vec<PathBuf>,
|
||||
|
||||
/// Configure the process restrictions for the command.
|
||||
#[arg(long = "sandbox", short = 's')]
|
||||
sandbox_policy: SandboxModeCliArg,
|
||||
|
||||
/// Full command args to run under seatbelt.
|
||||
#[arg(trailing_var_arg = true)]
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct LandlockCommand {
|
||||
/// Writable folder for sandbox (can be specified multiple times).
|
||||
#[arg(long = "writable-root", short = 'w', value_name = "DIR", action = ArgAction::Append, use_value_delimiter = false)]
|
||||
writable_roots: Vec<PathBuf>,
|
||||
|
||||
/// Configure the process restrictions for the command.
|
||||
#[arg(long = "sandbox", short = 's')]
|
||||
sandbox_policy: SandboxModeCliArg,
|
||||
|
||||
/// Full command args to run under landlock.
|
||||
#[arg(trailing_var_arg = true)]
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ReplProto {}
|
||||
|
||||
@@ -104,11 +84,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
match cli.subcommand {
|
||||
None => {
|
||||
codex_tui::run_main(cli.interactive)?;
|
||||
codex_interactive::run_main(cli.interactive).await?;
|
||||
}
|
||||
Some(Subcommand::Exec(exec_cli)) => {
|
||||
codex_exec::run_main(exec_cli).await?;
|
||||
}
|
||||
Some(Subcommand::Tui(tui_cli)) => {
|
||||
codex_tui::run_main(tui_cli)?;
|
||||
}
|
||||
Some(Subcommand::Repl(repl_cli)) => {
|
||||
codex_repl::run_main(repl_cli).await?;
|
||||
}
|
||||
@@ -118,22 +101,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
|
||||
DebugCommand::Seatbelt(SeatbeltCommand {
|
||||
command,
|
||||
sandbox_policy,
|
||||
writable_roots,
|
||||
}) => {
|
||||
seatbelt::run_seatbelt(command, sandbox_policy.into(), writable_roots).await?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
DebugCommand::Landlock(LandlockCommand {
|
||||
command,
|
||||
sandbox_policy,
|
||||
writable_roots,
|
||||
}) => {
|
||||
landlock::run_landlock(command, sandbox_policy.into(), writable_roots)?;
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
DebugCommand::Landlock(_) => {
|
||||
anyhow::bail!("Landlock is only supported on Linux.");
|
||||
seatbelt::run_seatbelt(command, writable_roots).await?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
use codex_core::exec::create_seatbelt_command;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) async fn run_seatbelt(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
writable_roots: Vec<PathBuf>,
|
||||
) -> anyhow::Result<()> {
|
||||
let seatbelt_command = create_seatbelt_command(command, sandbox_policy, &writable_roots);
|
||||
let seatbelt_command = create_seatbelt_command(command, &writable_roots);
|
||||
let status = tokio::process::Command::new(seatbelt_command[0].clone())
|
||||
.args(&seatbelt_command[1..])
|
||||
.spawn()
|
||||
|
||||
@@ -6,7 +6,7 @@ use clap::ValueEnum;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum ApprovalModeCliArg {
|
||||
/// Run all commands without asking for user approval.
|
||||
@@ -24,7 +24,7 @@ pub enum ApprovalModeCliArg {
|
||||
Never,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
#[derive(Clone, Debug, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum SandboxModeCliArg {
|
||||
/// Network syscalls will be blocked
|
||||
|
||||
@@ -3,6 +3,8 @@ use std::collections::HashSet;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -34,6 +36,7 @@ use crate::exec::process_exec_tool_call;
|
||||
use crate::exec::ExecParams;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::SandboxType;
|
||||
use crate::flags::OPENAI_DEFAULT_MODEL;
|
||||
use crate::flags::OPENAI_STREAM_MAX_RETRIES;
|
||||
use crate::models::ContentItem;
|
||||
use crate::models::FunctionCallOutputPayload;
|
||||
@@ -483,6 +486,7 @@ async fn submission_loop(
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
} => {
|
||||
let model = model.unwrap_or_else(|| OPENAI_DEFAULT_MODEL.to_string());
|
||||
info!(model, "Configuring session");
|
||||
let client = ModelClient::new(model.clone());
|
||||
|
||||
@@ -982,17 +986,13 @@ async fn handle_function_call(
|
||||
)
|
||||
.await;
|
||||
|
||||
let decision = rx_approve.await.unwrap_or_default();
|
||||
|
||||
if matches!(decision, ReviewDecision::ApprovedForSession) {
|
||||
// Persist this command as pre-approved for the
|
||||
// remainder of the session so future executions
|
||||
// can skip the sandbox directly.
|
||||
sess.add_approved_command(params.command.clone());
|
||||
}
|
||||
|
||||
match decision {
|
||||
match rx_approve.await.unwrap_or_default() {
|
||||
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
|
||||
// Persist this command as pre‑approved for the
|
||||
// remainder of the session so future
|
||||
// executions skip the sandbox directly.
|
||||
// TODO(ragona): Isn't this a bug? It always saves the command in an | fork?
|
||||
sess.add_approved_command(params.command.clone());
|
||||
// Inform UI we are retrying without sandbox.
|
||||
sess.notify_background_event(
|
||||
&sub_id,
|
||||
@@ -1348,7 +1348,6 @@ fn convert_apply_patch_to_protocol(
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff,
|
||||
move_path,
|
||||
new_content: _new_content,
|
||||
} => FileChange::Update {
|
||||
unified_diff: unified_diff.clone(),
|
||||
move_path: move_path.clone(),
|
||||
@@ -1403,10 +1402,28 @@ fn apply_changes_from_apply_patch(
|
||||
deleted.push(path.clone());
|
||||
}
|
||||
ApplyPatchFileChange::Update {
|
||||
unified_diff: _unified_diff,
|
||||
unified_diff,
|
||||
move_path,
|
||||
new_content,
|
||||
} => {
|
||||
// TODO(mbolin): `patch` is not guaranteed to be available.
|
||||
// Allegedly macOS provides it, but minimal Linux installs
|
||||
// might omit it.
|
||||
Command::new("patch")
|
||||
.arg(path)
|
||||
.arg("-p0")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
stdin.write_all(unified_diff.as_bytes())?;
|
||||
stdin.flush()?;
|
||||
// Drop stdin to send EOF.
|
||||
drop(stdin);
|
||||
child.wait()
|
||||
})
|
||||
.with_context(|| format!("Failed to apply patch to {}", path.display()))?;
|
||||
if let Some(move_path) = move_path {
|
||||
if let Some(parent) = move_path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
@@ -1418,14 +1435,11 @@ fn apply_changes_from_apply_patch(
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
std::fs::rename(path, move_path)
|
||||
.with_context(|| format!("Failed to rename file {}", path.display()))?;
|
||||
std::fs::write(move_path, new_content)?;
|
||||
modified.push(move_path.clone());
|
||||
deleted.push(path.clone());
|
||||
} else {
|
||||
std::fs::write(path, new_content)?;
|
||||
modified.push(path.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,29 +2,39 @@ use std::sync::atomic::AtomicU64;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::Submission;
|
||||
use crate::util::notify_on_sigint;
|
||||
use crate::Codex;
|
||||
use tokio::sync::Notify;
|
||||
use tracing::debug;
|
||||
|
||||
/// Spawn a new [`Codex`] and initialise the session.
|
||||
///
|
||||
/// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that
|
||||
/// is received as a response to the initial `ConfigureSession` submission so
|
||||
/// that callers can surface the information to the UI.
|
||||
pub async fn init_codex(config: Config) -> anyhow::Result<(CodexWrapper, Event, Arc<Notify>)> {
|
||||
pub async fn init_codex(
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
disable_response_storage: bool,
|
||||
model_override: Option<String>,
|
||||
) -> anyhow::Result<(CodexWrapper, Event, Arc<Notify>)> {
|
||||
let ctrl_c = notify_on_sigint();
|
||||
let config = Config::load().unwrap_or_default();
|
||||
debug!("loaded config: {config:?}");
|
||||
let codex = CodexWrapper::new(Codex::spawn(ctrl_c.clone())?);
|
||||
let init_id = codex
|
||||
.submit(Op::ConfigureSession {
|
||||
model: config.model.clone(),
|
||||
instructions: config.instructions.clone(),
|
||||
approval_policy: config.approval_policy,
|
||||
sandbox_policy: config.sandbox_policy,
|
||||
disable_response_storage: config.disable_response_storage,
|
||||
model: model_override.or_else(|| config.model.clone()),
|
||||
instructions: config.instructions,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
})
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,140 +1,42 @@
|
||||
use crate::flags::OPENAI_DEFAULT_MODEL;
|
||||
use crate::protocol::AskForApproval;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Embedded fallback instructions that mirror the TypeScript CLI’s default
|
||||
/// system prompt. These are compiled into the binary so a clean install behaves
|
||||
/// correctly even if the user has not created `~/.codex/instructions.md`.
|
||||
/// Embedded fallback instructions that mirror the TypeScript CLI’s default system prompt. These
|
||||
/// are compiled into the binary so a clean install behaves correctly even if the user has not
|
||||
/// created `~/.codex/instructions.md`.
|
||||
const EMBEDDED_INSTRUCTIONS: &str = include_str!("../prompt.md");
|
||||
|
||||
/// Application configuration loaded from disk and merged with overrides.
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
#[derive(Default, Deserialize, Debug, Clone)]
|
||||
pub struct Config {
|
||||
/// Optional override of model selection.
|
||||
#[serde(default = "default_model")]
|
||||
pub model: String,
|
||||
/// Default approval policy for executing commands.
|
||||
#[serde(default)]
|
||||
pub approval_policy: AskForApproval,
|
||||
#[serde(default)]
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
|
||||
/// Disable server-side response storage (sends the full conversation
|
||||
/// context with every request). Currently necessary for OpenAI customers
|
||||
/// who have opted into Zero Data Retention (ZDR).
|
||||
#[serde(default)]
|
||||
pub disable_response_storage: bool,
|
||||
|
||||
/// System instructions.
|
||||
pub model: Option<String>,
|
||||
pub instructions: Option<String>,
|
||||
}
|
||||
|
||||
/// Optional overrides for user configuration (e.g., from CLI flags).
|
||||
#[derive(Default, Debug, Clone)]
|
||||
pub struct ConfigOverrides {
|
||||
pub model: Option<String>,
|
||||
pub approval_policy: Option<AskForApproval>,
|
||||
pub sandbox_policy: Option<SandboxPolicy>,
|
||||
pub disable_response_storage: Option<bool>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Load configuration, optionally applying overrides (CLI flags). Merges
|
||||
/// ~/.codex/config.toml, ~/.codex/instructions.md, embedded defaults, and
|
||||
/// any values provided in `overrides` (highest precedence).
|
||||
pub fn load_with_overrides(overrides: ConfigOverrides) -> std::io::Result<Self> {
|
||||
let mut cfg: Config = Self::load_from_toml()?;
|
||||
tracing::warn!("Config parsed from config.toml: {cfg:?}");
|
||||
/// Load ~/.codex/config.toml and ~/.codex/instructions.md (if present).
|
||||
/// Returns `None` if neither file exists.
|
||||
pub fn load() -> Option<Self> {
|
||||
let mut cfg: Config = Self::load_from_toml().unwrap_or_default();
|
||||
|
||||
// Highest precedence → user‑provided ~/.codex/instructions.md (if present)
|
||||
// Fallback → embedded default instructions baked into the binary
|
||||
|
||||
// Instructions: user-provided instructions.md > embedded default.
|
||||
cfg.instructions =
|
||||
Self::load_instructions().or_else(|| Some(EMBEDDED_INSTRUCTIONS.to_string()));
|
||||
|
||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||
let ConfigOverrides {
|
||||
model,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
} = overrides;
|
||||
|
||||
if let Some(model) = model {
|
||||
cfg.model = model;
|
||||
}
|
||||
if let Some(approval_policy) = approval_policy {
|
||||
cfg.approval_policy = approval_policy;
|
||||
}
|
||||
if let Some(sandbox_policy) = sandbox_policy {
|
||||
cfg.sandbox_policy = sandbox_policy;
|
||||
}
|
||||
if let Some(disable_response_storage) = disable_response_storage {
|
||||
cfg.disable_response_storage = disable_response_storage;
|
||||
}
|
||||
Ok(cfg)
|
||||
Some(cfg)
|
||||
}
|
||||
|
||||
/// Attempt to parse the file at `~/.codex/config.toml` into a Config.
|
||||
fn load_from_toml() -> std::io::Result<Self> {
|
||||
let config_toml_path = codex_dir()?.join("config.toml");
|
||||
match std::fs::read_to_string(&config_toml_path) {
|
||||
Ok(contents) => toml::from_str::<Self>(&contents).map_err(|e| {
|
||||
tracing::error!("Failed to parse config.toml: {e}");
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
|
||||
}),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::info!("config.toml not found, using defaults");
|
||||
Ok(Self::load_default_config())
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to read config.toml: {e}");
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Meant to be used exclusively for tests: load_with_overrides() should be
|
||||
/// used in all other cases.
|
||||
pub fn load_default_config_for_test() -> Self {
|
||||
Self::load_default_config()
|
||||
}
|
||||
|
||||
fn load_default_config() -> Self {
|
||||
// Load from an empty string to exercise #[serde(default)] to
|
||||
// get the default values for each field.
|
||||
toml::from_str::<Self>("").expect("empty string should parse as TOML")
|
||||
fn load_from_toml() -> Option<Self> {
|
||||
let mut p = home_dir()?;
|
||||
p.push(".codex/config.toml");
|
||||
let contents = std::fs::read_to_string(&p).ok()?;
|
||||
toml::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
fn load_instructions() -> Option<String> {
|
||||
let mut p = codex_dir().ok()?;
|
||||
p.push("instructions.md");
|
||||
let mut p = home_dir()?;
|
||||
p.push(".codex/instructions.md");
|
||||
std::fs::read_to_string(&p).ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
OPENAI_DEFAULT_MODEL.to_string()
|
||||
}
|
||||
|
||||
/// Returns the path to the Codex configuration directory, which is `~/.codex`.
|
||||
/// Does not verify that the directory exists.
|
||||
pub fn codex_dir() -> std::io::Result<PathBuf> {
|
||||
let mut p = home_dir().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"Could not find home directory",
|
||||
)
|
||||
})?;
|
||||
p.push(".codex");
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Returns the path to the folder where Codex logs are stored. Does not verify
|
||||
/// that the directory exists.
|
||||
pub fn log_dir() -> std::io::Result<PathBuf> {
|
||||
let mut p = codex_dir()?;
|
||||
p.push("log");
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
@@ -35,12 +35,6 @@ const TIMEOUT_CODE: i32 = 64;
|
||||
|
||||
const MACOS_SEATBELT_READONLY_POLICY: &str = include_str!("seatbelt_readonly_policy.sbpl");
|
||||
|
||||
/// When working with `sandbox-exec`, only consider `sandbox-exec` in `/usr/bin`
|
||||
/// to defend against an attacker trying to inject a malicious version on the
|
||||
/// PATH. If /usr/bin/sandbox-exec has been tampered with, then the attacker
|
||||
/// already has root access.
|
||||
const MACOS_PATH_TO_SEATBELT_EXECUTABLE: &str = "/usr/bin/sandbox-exec";
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct ExecParams {
|
||||
pub command: Vec<String>,
|
||||
@@ -104,7 +98,7 @@ pub async fn process_exec_tool_call(
|
||||
workdir,
|
||||
timeout_ms,
|
||||
} = params;
|
||||
let seatbelt_command = create_seatbelt_command(command, sandbox_policy, writable_roots);
|
||||
let seatbelt_command = create_seatbelt_command(command, writable_roots);
|
||||
exec(
|
||||
ExecParams {
|
||||
command: seatbelt_command,
|
||||
@@ -160,11 +154,7 @@ pub async fn process_exec_tool_call(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_seatbelt_command(
|
||||
command: Vec<String>,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
writable_roots: &[PathBuf],
|
||||
) -> Vec<String> {
|
||||
pub fn create_seatbelt_command(command: Vec<String>, writable_roots: &[PathBuf]) -> Vec<String> {
|
||||
let (policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -176,14 +166,6 @@ pub fn create_seatbelt_command(
|
||||
})
|
||||
.unzip();
|
||||
|
||||
// TODO(ragona): The seatbelt policy should reflect the SandboxPolicy that
|
||||
// is passed, but everything is currently hardcoded to use
|
||||
// MACOS_SEATBELT_READONLY_POLICY.
|
||||
// TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
|
||||
if !matches!(sandbox_policy, SandboxPolicy::NetworkRestricted) {
|
||||
tracing::error!("specified sandbox policy {sandbox_policy:?} will not be honroed");
|
||||
}
|
||||
|
||||
let full_policy = if policies.is_empty() {
|
||||
MACOS_SEATBELT_READONLY_POLICY.to_string()
|
||||
} else {
|
||||
@@ -192,7 +174,7 @@ pub fn create_seatbelt_command(
|
||||
};
|
||||
|
||||
let mut seatbelt_command: Vec<String> = vec![
|
||||
MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string(),
|
||||
"sandbox-exec".to_string(),
|
||||
"-p".to_string(),
|
||||
full_policy.to_string(),
|
||||
];
|
||||
|
||||
@@ -14,7 +14,7 @@ pub mod exec;
|
||||
mod flags;
|
||||
mod is_safe_command;
|
||||
#[cfg(target_os = "linux")]
|
||||
pub mod linux;
|
||||
mod linux;
|
||||
mod models;
|
||||
pub mod protocol;
|
||||
mod safety;
|
||||
|
||||
@@ -72,15 +72,7 @@ pub async fn exec_linux(
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs Landlock file-system rules on the current thread allowing read
|
||||
/// access to the entire file-system while restricting write access to
|
||||
/// `/dev/null` and the provided list of `writable_roots`.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns [`CodexErr::Sandbox`] variants when the ruleset fails to apply.
|
||||
pub fn install_filesystem_landlock_rules_on_current_thread(
|
||||
writable_roots: Vec<PathBuf>,
|
||||
) -> Result<()> {
|
||||
fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathBuf>) -> Result<()> {
|
||||
let abi = ABI::V5;
|
||||
let access_rw = AccessFs::from_all(abi);
|
||||
let access_ro = AccessFs::from_read(abi);
|
||||
@@ -106,9 +98,7 @@ pub fn install_filesystem_landlock_rules_on_current_thread(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Installs a seccomp filter that blocks outbound network access except for
|
||||
/// AF_UNIX domain sockets.
|
||||
pub fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
|
||||
fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
|
||||
// Build rule map.
|
||||
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
|
||||
|
||||
@@ -235,9 +225,7 @@ mod tests_linux {
|
||||
&format!("echo blah > {}", file_path.to_string_lossy()),
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
// We have seen timeouts when running this test in CI on GitHub,
|
||||
// so we are using a generous timeout until we can diagnose further.
|
||||
1_000,
|
||||
500,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ pub enum Op {
|
||||
/// Configure the model session.
|
||||
ConfigureSession {
|
||||
/// If not specified, server will use its default model.
|
||||
model: String,
|
||||
model: Option<String>,
|
||||
/// Model instructions
|
||||
instructions: Option<String>,
|
||||
/// When to escalate for approval for execution
|
||||
@@ -66,13 +66,11 @@ pub enum Op {
|
||||
}
|
||||
|
||||
/// Determines how liberally commands are auto‑approved by the system.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum AskForApproval {
|
||||
/// Under this policy, only “known safe” commands—as determined by
|
||||
/// `is_safe_command()`—that **only read files** are auto‑approved.
|
||||
/// Everything else will ask the user to approve.
|
||||
#[default]
|
||||
UnlessAllowListed,
|
||||
|
||||
/// In addition to everything allowed by **`Suggest`**, commands that
|
||||
@@ -93,15 +91,13 @@ pub enum AskForApproval {
|
||||
}
|
||||
|
||||
/// Determines execution restrictions for model shell commands
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum SandboxPolicy {
|
||||
/// Network syscalls will be blocked
|
||||
NetworkRestricted,
|
||||
/// Filesystem writes will be restricted
|
||||
FileWriteRestricted,
|
||||
/// Network and filesystem writes will be restricted
|
||||
#[default]
|
||||
NetworkAndFileWriteRestricted,
|
||||
/// No restrictions; full "unsandboxed" mode
|
||||
DangerousNoRestrictions,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
@@ -47,14 +47,13 @@ async fn spawn_codex() -> Codex {
|
||||
|
||||
let agent = Codex::spawn(std::sync::Arc::new(Notify::new())).unwrap();
|
||||
|
||||
let config = Config::load_default_config_for_test();
|
||||
agent
|
||||
.submit(Submission {
|
||||
id: "init".into(),
|
||||
op: Op::ConfigureSession {
|
||||
model: config.model,
|
||||
model: None,
|
||||
instructions: None,
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
|
||||
disable_response_storage: false,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -87,14 +87,13 @@ async fn keeps_previous_response_id_between_tasks() {
|
||||
let codex = Codex::spawn(std::sync::Arc::new(tokio::sync::Notify::new())).unwrap();
|
||||
|
||||
// Init session
|
||||
let config = Config::load_default_config_for_test();
|
||||
codex
|
||||
.submit(Submission {
|
||||
id: "init".into(),
|
||||
op: Op::ConfigureSession {
|
||||
model: config.model,
|
||||
model: None,
|
||||
instructions: None,
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
|
||||
disable_response_storage: false,
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
@@ -70,14 +70,13 @@ async fn retries_on_early_close() {
|
||||
|
||||
let codex = Codex::spawn(std::sync::Arc::new(tokio::sync::Notify::new())).unwrap();
|
||||
|
||||
let config = Config::load_default_config_for_test();
|
||||
codex
|
||||
.submit(Submission {
|
||||
id: "init".into(),
|
||||
op: Op::ConfigureSession {
|
||||
model: config.model,
|
||||
model: None,
|
||||
instructions: None,
|
||||
approval_policy: config.approval_policy,
|
||||
approval_policy: AskForApproval::OnFailure,
|
||||
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
|
||||
disable_response_storage: false,
|
||||
},
|
||||
|
||||
@@ -14,7 +14,7 @@ path = "src/lib.rs"
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
codex-core = { path = "../core", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
tokio = { version = "1", features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
@@ -24,4 +24,3 @@ tokio = { version = "1", features = [
|
||||
] }
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
owo-colors = "4.2.0"
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use clap::Parser;
|
||||
use codex_core::SandboxModeCliArg;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
@@ -13,12 +12,6 @@ pub struct Cli {
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Configure the process restrictions when a command is executed.
|
||||
///
|
||||
/// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux.
|
||||
#[arg(long = "sandbox", short = 's')]
|
||||
pub sandbox_policy: Option<SandboxModeCliArg>,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
@@ -3,32 +3,19 @@ use std::sync::Arc;
|
||||
|
||||
pub use cli::Cli;
|
||||
use codex_core::codex_wrapper;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use owo_colors::OwoColorize;
|
||||
use tracing::debug;
|
||||
use tracing::error;
|
||||
use tracing::info;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
/// Returns `true` if a recognised API key is present in the environment.
|
||||
///
|
||||
/// At present we only support `OPENAI_API_KEY`, mirroring the behaviour of the
|
||||
/// Node-based `codex-cli`. Additional providers can be added here when the
|
||||
/// Rust implementation gains first-class support for them.
|
||||
fn has_api_key() -> bool {
|
||||
std::env::var("OPENAI_API_KEY")
|
||||
.map(|s| !s.trim().is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
// TODO(mbolin): Take a more thoughtful approach to logging.
|
||||
let default_level = "error";
|
||||
@@ -46,27 +33,12 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
let Cli {
|
||||
images,
|
||||
model,
|
||||
sandbox_policy,
|
||||
skip_git_repo_check,
|
||||
disable_response_storage,
|
||||
prompt,
|
||||
..
|
||||
} = cli;
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// API key handling
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
if !has_api_key() {
|
||||
eprintln!(
|
||||
"\n{msg}\n\nSet the environment variable {var} and re-run this command.\nYou can create a key here: {url}\n",
|
||||
msg = "Missing OpenAI API key.".red(),
|
||||
var = "OPENAI_API_KEY".bold(),
|
||||
url = "https://platform.openai.com/account/api-keys".bold().underline(),
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
if !skip_git_repo_check && !is_inside_git_repo() {
|
||||
eprintln!("Not inside a Git repo and --skip-git-repo-check was not specified.");
|
||||
std::process::exit(1);
|
||||
@@ -75,21 +47,17 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Load configuration and determine approval policy
|
||||
let overrides = ConfigOverrides {
|
||||
model: model.clone(),
|
||||
// This CLI is intended to be headless and has no affordances for asking
|
||||
// the user for approval.
|
||||
approval_policy: Some(AskForApproval::Never),
|
||||
sandbox_policy: sandbox_policy.map(Into::into),
|
||||
disable_response_storage: if disable_response_storage {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
let config = Config::load_with_overrides(overrides)?;
|
||||
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?;
|
||||
// TODO(mbolin): We are reworking the CLI args right now, so this will
|
||||
// likely come from a new --execution-policy arg.
|
||||
let approval_policy = AskForApproval::Never;
|
||||
let sandbox_policy = SandboxPolicy::NetworkAndFileWriteRestricted;
|
||||
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
model,
|
||||
)
|
||||
.await?;
|
||||
let codex = Arc::new(codex_wrapper);
|
||||
info!("Codex initialized with event: {event:?}");
|
||||
|
||||
|
||||
24
codex-rs/interactive/Cargo.toml
Normal file
24
codex-rs/interactive/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "codex-interactive"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "codex-interactive"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lib]
|
||||
name = "codex_interactive"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
codex-core = { path = "../core", features = ["cli"] }
|
||||
tokio = { version = "1", features = [
|
||||
"io-std",
|
||||
"macros",
|
||||
"process",
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
33
codex-rs/interactive/src/cli.rs
Normal file
33
codex-rs/interactive/src/cli.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use clap::Parser;
|
||||
use codex_core::ApprovalModeCliArg;
|
||||
use codex_core::SandboxModeCliArg;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Optional image(s) to attach to the initial prompt.
|
||||
#[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)]
|
||||
pub images: Vec<PathBuf>,
|
||||
|
||||
/// Model the agent should use.
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Configure when the model requires human approval before executing a command.
|
||||
#[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)]
|
||||
pub approval_policy: ApprovalModeCliArg,
|
||||
|
||||
/// Configure the process restrictions when a command is executed.
|
||||
///
|
||||
/// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux.
|
||||
#[arg(long = "sandbox", short = 's', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)]
|
||||
pub sandbox_policy: SandboxModeCliArg,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
pub skip_git_repo_check: bool,
|
||||
|
||||
/// Initial instructions for the agent.
|
||||
pub prompt: Option<String>,
|
||||
}
|
||||
7
codex-rs/interactive/src/lib.rs
Normal file
7
codex-rs/interactive/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
mod cli;
|
||||
pub use cli::Cli;
|
||||
|
||||
pub async fn run_main(_cli: Cli) -> anyhow::Result<()> {
|
||||
eprintln!("Interactive mode is not implemented yet.");
|
||||
std::process::exit(1);
|
||||
}
|
||||
11
codex-rs/interactive/src/main.rs
Normal file
11
codex-rs/interactive/src/main.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use clap::Parser;
|
||||
use codex_interactive::run_main;
|
||||
use codex_interactive::Cli;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
run_main(cli).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -34,14 +34,14 @@ pub struct Cli {
|
||||
pub no_ansi: bool,
|
||||
|
||||
/// Configure when the model requires human approval before executing a command.
|
||||
#[arg(long = "ask-for-approval", short = 'a')]
|
||||
pub approval_policy: Option<ApprovalModeCliArg>,
|
||||
#[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)]
|
||||
pub approval_policy: ApprovalModeCliArg,
|
||||
|
||||
/// Configure the process restrictions when a command is executed.
|
||||
///
|
||||
/// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux.
|
||||
#[arg(long = "sandbox", short = 's')]
|
||||
pub sandbox_policy: Option<SandboxModeCliArg>,
|
||||
#[arg(long = "sandbox", short = 's', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)]
|
||||
pub sandbox_policy: SandboxModeCliArg,
|
||||
|
||||
/// Allow running Codex outside a Git repository. By default the CLI
|
||||
/// aborts early when the current working directory is **not** inside a
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::protocol;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
@@ -76,23 +75,12 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
|
||||
// Initialize logging before any other work so early errors are captured.
|
||||
init_logger(cli.verbose, !cli.no_ansi);
|
||||
|
||||
// Load config file and apply CLI overrides (model & approval policy)
|
||||
let overrides = ConfigOverrides {
|
||||
model: cli.model.clone(),
|
||||
approval_policy: cli.approval_policy.map(Into::into),
|
||||
sandbox_policy: cli.sandbox_policy.map(Into::into),
|
||||
disable_response_storage: if cli.disable_response_storage {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
let config = Config::load_with_overrides(overrides)?;
|
||||
let config = Config::load().unwrap_or_default();
|
||||
|
||||
codex_main(cli, config, ctrl_c).await
|
||||
}
|
||||
|
||||
async fn codex_main(cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Result<()> {
|
||||
async fn codex_main(mut cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Result<()> {
|
||||
let mut builder = Codex::builder();
|
||||
if let Some(path) = cli.record_submissions {
|
||||
builder = builder.record_submissions(path);
|
||||
@@ -105,11 +93,11 @@ async fn codex_main(cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Resul
|
||||
let init = protocol::Submission {
|
||||
id: init_id.clone(),
|
||||
op: protocol::Op::ConfigureSession {
|
||||
model: cfg.model,
|
||||
model: cli.model.or(cfg.model),
|
||||
instructions: cfg.instructions,
|
||||
approval_policy: cfg.approval_policy,
|
||||
sandbox_policy: cfg.sandbox_policy,
|
||||
disable_response_storage: cfg.disable_response_storage,
|
||||
approval_policy: cli.approval_policy.into(),
|
||||
sandbox_policy: cli.sandbox_policy.into(),
|
||||
disable_response_storage: cli.disable_response_storage,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -145,8 +133,8 @@ async fn codex_main(cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Resul
|
||||
// run loop
|
||||
let mut reader = InputReader::new(ctrl_c.clone());
|
||||
loop {
|
||||
let text = match &cli.prompt {
|
||||
Some(input) => input.clone(),
|
||||
let text = match cli.prompt.take() {
|
||||
Some(input) => input,
|
||||
None => match reader.request_input().await? {
|
||||
Some(input) => input,
|
||||
None => {
|
||||
|
||||
@@ -4,9 +4,10 @@ use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
use crate::scroll_event_helper::ScrollEventHelper;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -33,10 +34,13 @@ pub(crate) struct App<'a> {
|
||||
|
||||
impl App<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
initial_prompt: Option<String>,
|
||||
show_git_warning: bool,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
model: Option<String>,
|
||||
disable_response_storage: bool,
|
||||
) -> Self {
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
|
||||
@@ -76,10 +80,13 @@ impl App<'_> {
|
||||
}
|
||||
|
||||
let chat_widget = ChatWidget::new(
|
||||
config,
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
app_event_tx.clone(),
|
||||
initial_prompt.clone(),
|
||||
initial_images,
|
||||
model,
|
||||
disable_response_storage,
|
||||
);
|
||||
|
||||
let app_state = if show_git_warning {
|
||||
|
||||
@@ -3,11 +3,12 @@ use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_core::codex_wrapper::init_codex;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Constraint;
|
||||
@@ -33,7 +34,7 @@ pub(crate) struct ChatWidget<'a> {
|
||||
conversation_history: ConversationHistoryWidget,
|
||||
bottom_pane: BottomPane<'a>,
|
||||
input_focus: InputFocus,
|
||||
config: Config,
|
||||
approval_policy: AskForApproval,
|
||||
cwd: std::path::PathBuf,
|
||||
}
|
||||
|
||||
@@ -45,10 +46,13 @@ enum InputFocus {
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
pub(crate) fn new(
|
||||
config: Config,
|
||||
approval_policy: AskForApproval,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
app_event_tx: Sender<AppEvent>,
|
||||
initial_prompt: Option<String>,
|
||||
initial_images: Vec<std::path::PathBuf>,
|
||||
model: Option<String>,
|
||||
disable_response_storage: bool,
|
||||
) -> Self {
|
||||
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
|
||||
|
||||
@@ -59,12 +63,19 @@ impl ChatWidget<'_> {
|
||||
|
||||
let app_event_tx_clone = app_event_tx.clone();
|
||||
// Create the Codex asynchronously so the UI loads as quickly as possible.
|
||||
let config_for_agent_loop = config.clone();
|
||||
tokio::spawn(async move {
|
||||
let (codex, session_event, _ctrl_c) = match init_codex(config_for_agent_loop).await {
|
||||
// Initialize session; storage enabled by default
|
||||
let (codex, session_event, _ctrl_c) = match init_codex(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
disable_response_storage,
|
||||
model,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(vals) => vals,
|
||||
Err(e) => {
|
||||
// TODO: surface this error to the user.
|
||||
// TODO(mbolin): This error needs to be surfaced to the user.
|
||||
tracing::error!("failed to initialize codex: {e}");
|
||||
return;
|
||||
}
|
||||
@@ -104,7 +115,7 @@ impl ChatWidget<'_> {
|
||||
has_input_focus: true,
|
||||
}),
|
||||
input_focus: InputFocus::BottomPane,
|
||||
config,
|
||||
approval_policy,
|
||||
cwd: cwd.clone(),
|
||||
};
|
||||
|
||||
@@ -232,8 +243,11 @@ impl ChatWidget<'_> {
|
||||
match msg {
|
||||
EventMsg::SessionConfigured { model } => {
|
||||
// Record session information at the top of the conversation.
|
||||
self.conversation_history
|
||||
.add_session_info(&self.config, model, self.cwd.clone());
|
||||
self.conversation_history.add_session_info(
|
||||
model,
|
||||
self.cwd.clone(),
|
||||
self.approval_policy,
|
||||
);
|
||||
self.request_redraw()?;
|
||||
}
|
||||
EventMsg::AgentMessage { message } => {
|
||||
|
||||
@@ -18,14 +18,14 @@ pub struct Cli {
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Configure when the model requires human approval before executing a command.
|
||||
#[arg(long = "ask-for-approval", short = 'a')]
|
||||
pub approval_policy: Option<ApprovalModeCliArg>,
|
||||
#[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)]
|
||||
pub approval_policy: ApprovalModeCliArg,
|
||||
|
||||
/// Configure the process restrictions when a command is executed.
|
||||
///
|
||||
/// Uses OS-specific sandboxing tools; Seatbelt on OSX, landlock+seccomp on Linux.
|
||||
#[arg(long = "sandbox", short = 's')]
|
||||
pub sandbox_policy: Option<SandboxModeCliArg>,
|
||||
#[arg(long = "sandbox", short = 's', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)]
|
||||
pub sandbox_policy: SandboxModeCliArg,
|
||||
|
||||
/// Allow running Codex outside a Git repository.
|
||||
#[arg(long = "skip-git-repo-check", default_value_t = false)]
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PatchEventType;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::FileChange;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
@@ -48,11 +47,11 @@ impl ConversationHistoryWidget {
|
||||
self.scroll_down(1);
|
||||
true
|
||||
}
|
||||
KeyCode::PageUp | KeyCode::Char('b') => {
|
||||
KeyCode::PageUp | KeyCode::Char('b') | KeyCode::Char('u') | KeyCode::Char('U') => {
|
||||
self.scroll_page_up();
|
||||
true
|
||||
}
|
||||
KeyCode::PageDown | KeyCode::Char(' ') => {
|
||||
KeyCode::PageDown | KeyCode::Char(' ') | KeyCode::Char('d') | KeyCode::Char('D') => {
|
||||
self.scroll_page_down();
|
||||
true
|
||||
}
|
||||
@@ -182,10 +181,13 @@ impl ConversationHistoryWidget {
|
||||
self.add_to_history(HistoryCell::new_patch_event(event_type, changes));
|
||||
}
|
||||
|
||||
/// Note `model` could differ from `config.model` if the agent decided to
|
||||
/// use a different model than the one requested by the user.
|
||||
pub fn add_session_info(&mut self, config: &Config, model: String, cwd: PathBuf) {
|
||||
self.add_to_history(HistoryCell::new_session_info(config, model, cwd));
|
||||
pub fn add_session_info(
|
||||
&mut self,
|
||||
model: String,
|
||||
cwd: std::path::PathBuf,
|
||||
approval_policy: codex_core::protocol::AskForApproval,
|
||||
) {
|
||||
self.add_to_history(HistoryCell::new_session_info(model, cwd, approval_policy));
|
||||
}
|
||||
|
||||
pub fn add_active_exec_command(&mut self, call_id: String, command: Vec<String>) {
|
||||
@@ -238,7 +240,7 @@ impl WidgetRef for ConversationHistoryWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let (title, border_style) = if self.has_input_focus {
|
||||
(
|
||||
"Messages (↑/↓ or j/k = line, b/space = page)",
|
||||
"Messages (↑/↓ or j/k = line, b/u = PgUp, space/d = PgDn)",
|
||||
Style::default().fg(Color::LightYellow),
|
||||
)
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::protocol::FileChange;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Color;
|
||||
@@ -145,9 +144,9 @@ impl HistoryCell {
|
||||
}
|
||||
|
||||
pub(crate) fn new_session_info(
|
||||
config: &Config,
|
||||
model: String,
|
||||
cwd: std::path::PathBuf,
|
||||
approval_policy: codex_core::protocol::AskForApproval,
|
||||
) -> Self {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
@@ -159,11 +158,7 @@ impl HistoryCell {
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
"↳ approval: ".bold(),
|
||||
format!("{:?}", config.approval_policy).into(),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
"↳ sandbox: ".bold(),
|
||||
format!("{:?}", config.sandbox_policy).into(),
|
||||
format!("{:?}", approval_policy).into(),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
use app::App;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::util::is_inside_git_repo;
|
||||
use log_layer::TuiLogLayer;
|
||||
use std::fs::OpenOptions;
|
||||
@@ -33,53 +31,19 @@ pub use cli::Cli;
|
||||
pub fn run_main(cli: Cli) -> std::io::Result<()> {
|
||||
assert_env_var_set();
|
||||
|
||||
let config = {
|
||||
// Load configuration and support CLI overrides.
|
||||
let overrides = ConfigOverrides {
|
||||
model: cli.model.clone(),
|
||||
approval_policy: cli.approval_policy.map(Into::into),
|
||||
sandbox_policy: cli.sandbox_policy.map(Into::into),
|
||||
disable_response_storage: if cli.disable_response_storage {
|
||||
Some(true)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
};
|
||||
#[allow(clippy::print_stderr)]
|
||||
match Config::load_with_overrides(overrides) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
eprintln!("Error loading configuration: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let log_dir = codex_core::config::log_dir()?;
|
||||
std::fs::create_dir_all(&log_dir)?;
|
||||
// Open (or create) your log file, appending to it.
|
||||
let mut log_file_opts = OpenOptions::new();
|
||||
log_file_opts.create(true).append(true);
|
||||
|
||||
// Ensure the file is only readable and writable by the current user.
|
||||
// Doing the equivalent to `chmod 600` on Windows is quite a bit more code
|
||||
// and requires the Windows API crates, so we can reconsider that when
|
||||
// Codex CLI is officially supported on Windows.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
log_file_opts.mode(0o600);
|
||||
}
|
||||
|
||||
let log_file = log_file_opts.open(log_dir.join("codex-tui.log"))?;
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open("/tmp/codex-rs.log")?;
|
||||
|
||||
// Wrap file in non‑blocking writer.
|
||||
let (non_blocking, _guard) = non_blocking(log_file);
|
||||
let (non_blocking, _guard) = non_blocking(file);
|
||||
|
||||
// use RUST_LOG env var, default to info for codex crates.
|
||||
// use RUST_LOG env var, default to trace for codex crates.
|
||||
let env_filter = || {
|
||||
EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| EnvFilter::new("codex_core=info,codex_tui=info"))
|
||||
.unwrap_or_else(|_| EnvFilter::new("codex=trace,codex_tui=trace"))
|
||||
};
|
||||
|
||||
// Build layered subscriber:
|
||||
@@ -103,7 +67,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
|
||||
// `--allow-no-git-exec` flag.
|
||||
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo();
|
||||
|
||||
try_run_ratatui_app(cli, config, show_git_warning, log_rx);
|
||||
try_run_ratatui_app(cli, show_git_warning, log_rx);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -113,18 +77,16 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
|
||||
)]
|
||||
fn try_run_ratatui_app(
|
||||
cli: Cli,
|
||||
config: Config,
|
||||
show_git_warning: bool,
|
||||
log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
) {
|
||||
if let Err(report) = run_ratatui_app(cli, config, show_git_warning, log_rx) {
|
||||
if let Err(report) = run_ratatui_app(cli, show_git_warning, log_rx) {
|
||||
eprintln!("Error: {report:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn run_ratatui_app(
|
||||
cli: Cli,
|
||||
config: Config,
|
||||
show_git_warning: bool,
|
||||
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
|
||||
) -> color_eyre::Result<()> {
|
||||
@@ -139,8 +101,28 @@ fn run_ratatui_app(
|
||||
let mut terminal = tui::init()?;
|
||||
terminal.clear()?;
|
||||
|
||||
let Cli { prompt, images, .. } = cli;
|
||||
let mut app = App::new(config.clone(), prompt, show_git_warning, images);
|
||||
let Cli {
|
||||
prompt,
|
||||
images,
|
||||
approval_policy,
|
||||
sandbox_policy: sandbox,
|
||||
model,
|
||||
disable_response_storage,
|
||||
..
|
||||
} = cli;
|
||||
|
||||
let approval_policy = approval_policy.into();
|
||||
let sandbox_policy = sandbox.into();
|
||||
|
||||
let mut app = App::new(
|
||||
approval_policy,
|
||||
sandbox_policy,
|
||||
prompt,
|
||||
show_git_warning,
|
||||
images,
|
||||
model,
|
||||
disable_response_storage,
|
||||
);
|
||||
|
||||
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user