Compare commits

...

24 Commits

Author SHA1 Message Date
Tiffany Citra
980778f529 Fix sandbox retry approvals 2025-10-15 17:04:10 -07:00
Kevin Alwell
6e7d4a4b45 Should fix #751 by adding verbose error message. 2025-04-30 14:55:15 -04:00
Kevin Alwell
841e19b05d fix typescript error 2025-04-29 12:43:52 -04:00
Kevin Alwell
f466a73428 Omit default to fix type errors 2025-04-29 12:30:19 -04:00
Kevin Alwell
1a868d35f3 fix typecheck error 2025-04-29 12:22:41 -04:00
Kevin Alwell
9c563054e0 TypeScript bug bashing 2025-04-29 12:17:31 -04:00
Kevin Alwell
ca7204537c TypeScript bug bashing 2025-04-29 12:14:46 -04:00
Kevin Alwell
132e87cc8c TypeScript bug bashing 2025-04-29 12:13:38 -04:00
Kevin Alwell
8e80716169 fix tests 2025-04-29 12:10:54 -04:00
Kevin Alwell
d28aedb07b fix pipeline errors 2025-04-29 12:05:16 -04:00
Kevin Alwell
586ee0ec71 Merge branch 'main' into issue-726 2025-04-29 11:54:34 -04:00
Kevin Alwell
9065e61455 Ensure disableResponseStorage flag is respected 2025-04-29 11:48:47 -04:00
Kevin Alwell
6686f28338 Ensure disableResponseStorage flag is respected 2025-04-29 11:25:07 -04:00
Rashim
892242ef7c feat: add --reasoning CLI flag (#314)
This PR adds a new CLI flag: `--reasoning`, which allows users to
customize the reasoning effort level (`low`, `medium`, or `high`) used
by OpenAI's `o` models.
By introducing the `--reasoning` flag, users gain more flexibility when
working with the models. It enables optimization for either speed or
depth of reasoning, depending on specific use cases.
This PR resolves #107

- **Flag**: `--reasoning`
- **Accepted Values**: `low`, `medium`, `high`
- **Default Behavior**: If not specified, the model uses the default
reasoning level.

## Example Usage

```bash
codex --reasoning=low "Write a simple function to calculate factorial"

---------

Co-authored-by: Fouad Matin <169186268+fouad-openai@users.noreply.github.com>
Co-authored-by: yashrwealthy <yash.rastogi@wealthy.in>
Co-authored-by: Thibault Sottiaux <tibo@openai.com>
2025-04-29 07:30:49 -07:00
Kevin Alwell
3818df7ba4 Fixes issue #726 by adding config to configToSave object 2025-04-29 10:12:55 -04:00
Fouad Matin
19928bc257 [codex-rs] fix: exit code 1 if no api key (#697) 2025-04-28 21:42:06 -07:00
Michael Bolin
b9bba09819 fix: eliminate runtime dependency on patch(1) for apply_patch (#718)
When processing an `apply_patch` tool call, we were already computing
the new file content in order to compute the unified diff. Before this
PR, we were shelling out to `patch(1)` to apply the unified diff once
the user accepted the change, but this updates the code to just retain
the new file content and use it to write the file when the user accepts.
This simplifies deployment because it no longer assumes `patch(1)` is on
the host.

Note this change is internal to the Codex agent and does not affect
`protocol.rs`.
2025-04-28 21:15:41 -07:00
Thibault Sottiaux
d09dbba7ec feat: lower default retry wait time and increase number of tries (#720)
In total we now guarantee that we will wait for at least 60s before
giving up.

---------

Signed-off-by: Thibault Sottiaux <tibo@openai.com>
2025-04-28 21:11:30 -07:00
Michael Bolin
e79549f039 feat: add debug landlock subcommand comparable to debug seatbelt (#715)
This PR adds a `debug landlock` subcommand to the Codex CLI for testing
how Codex would execute a command using the specified sandbox policy.

Built and ran this code in the `rust:latest` Docker container. In the
container, hitting the network with vanilla `curl` succeeds:

```
$ curl google.com
<HTML><HEAD><meta http-equiv="content-type" content="text/html;charset=utf-8">
<TITLE>301 Moved</TITLE></HEAD><BODY>
<H1>301 Moved</H1>
The document has moved
<A HREF="http://www.google.com/">here</A>.
</BODY></HTML>
```

whereas this fails, as expected:

```
$ cargo run -- debug landlock -s network-restricted -- curl google.com
curl: (6) getaddrinfo() thread failed to start
```
2025-04-28 16:37:05 -07:00
Michael Bolin
e7ad9449ea feat: make it possible to set disable_response_storage = true in config.toml (#714)
https://github.com/openai/codex/pull/642 introduced support for the
`--disable-response-storage` flag, but if you are a ZDR customer, it is
tedious to set this every time, so this PR makes it possible to set this
once in `config.toml` and be done with it.

Incidentally, this tidies things up such that now `init_codex()` takes
only one parameter: `Config`.
2025-04-28 15:39:34 -07:00
Michael Bolin
cca1122ddc fix: make the TUI the default/"interactive" CLI in Rust (#711)
Originally, the `interactive` crate was going to be a placeholder for
building out a UX that was comparable to that of the existing TypeScript
CLI. Though after researching how Ratatui works, that seems difficult to
do because it is designed around the idea that it will redraw the full
screen buffer each time (and so any scrolling should be "internal" to
your Ratatui app) whereas the TypeScript CLI expects to render the full
history of the conversation every time(*) (which is why you can use your
terminal scrollbar to scroll it).

While it is possible to use Ratatui in a way that acts more like what
the TypeScript CLI is doing, it is awkward and seemingly results in
tedious code, so I think we should abandon that approach. As such, this
PR deletes the `interactive/` folder and the code that depended on it.

Further, since we added support for mousewheel scrolling in the TUI in
https://github.com/openai/codex/pull/641, it certainly feels much better
and the need for scroll support via the terminal scrollbar is greatly
diminished. This is now a more appropriate default UX for the
"multitool" CLI.

(*) Incidentally, I haven't verified this, but I think this results in
O(N^2) work in rendering, which seems potentially problematic for long
conversations.
2025-04-28 13:46:22 -07:00
Michael Bolin
40460faf2a fix: tighten up check for /usr/bin/sandbox-exec (#710)
* In both TypeScript and Rust, we now invoke `/usr/bin/sandbox-exec`
explicitly rather than whatever `sandbox-exec` happens to be on the
`PATH`.
* Changed `isSandboxExecAvailable` to use `access()` rather than
`command -v` so that:
  *  We only do the check once over the lifetime of the Codex process.
  * The check is specific to `/usr/bin/sandbox-exec`.
* We now do a syscall rather than incur the overhead of spawning a
process, dealing with timeouts, etc.

I think there is still room for improvement here where we should move
the `isSandboxExecAvailable` check earlier in the CLI, ideally right
after we do arg parsing to verify that we can provide the Seatbelt
sandbox if that is what the user has requested.
2025-04-28 13:42:04 -07:00
Michael Bolin
38575ed8aa fix: increase timeout of test_writable_root (#713)
Although we made some promising fixes in
https://github.com/openai/codex/pull/662, we are still seeing some
flakiness in `test_writable_root()`. If this continues to flake with the
more generous timeout, we should try something other than simply
increasing the timeout.
2025-04-28 13:09:27 -07:00
Michael Bolin
77e2918049 fix: drop d as keyboard shortcut for scrolling in the TUI (#704)
The existing `b` and `space` are sufficient and `d` and `u` default to
half-page scrolling in `less`, so the way we supported `d` and `u`
wasn't faithful to that, anyway:

https://man7.org/linux/man-pages/man1/less.1.html

If we decide to bring `d` and `u` back, they should probably match
`less`?
2025-04-28 10:39:58 -07:00
34 changed files with 583 additions and 221 deletions

View File

@@ -10,6 +10,7 @@ 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";
@@ -160,6 +161,12 @@ 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",
@@ -287,17 +294,22 @@ 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:
cli.flags.disableResponseStorage !== undefined
? Boolean(cli.flags.disableResponseStorage)
: config.disableResponseStorage,
disableResponseStorage,
};
// Check for updates after loading config. This is important because we write state file in

View File

@@ -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"] || "2500",
process.env["OPENAI_RATE_LIMIT_RETRY_WAIT_MS"] || "500",
10,
);
@@ -671,12 +671,12 @@ export class AgentLoop {
let stream;
// Retry loop for transient errors. Up to MAX_RETRIES attempts.
const MAX_RETRIES = 5;
const MAX_RETRIES = 8;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
let reasoning: Reasoning | undefined;
if (this.model.startsWith("o")) {
reasoning = { effort: "high" };
reasoning = { effort: this.config.reasoningEffort ?? "high" };
if (this.model === "o3" || this.model === "o4-mini") {
reasoning.summary = "auto";
}
@@ -1137,7 +1137,7 @@ export class AgentLoop {
content: [
{
type: "input_text",
text: "⚠️ Insufficient quota. Please check your billing details and retry.",
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.`,
},
],
});

View File

@@ -11,8 +11,8 @@ import { exec, execApplyPatch } from "./exec.js";
import { ReviewDecision } from "./review.js";
import { isLoggingEnabled, log } from "../logger/log.js";
import { SandboxType } from "./sandbox/interface.js";
import { access } from "fs/promises";
import { execFile } from "node:child_process";
import { PATH_TO_SEATBELT_EXECUTABLE } from "./sandbox/macos-seatbelt.js";
import fs from "fs/promises";
// ---------------------------------------------------------------------------
// Sessionlevel cache of commands that the user has chosen to always approve.
@@ -218,7 +218,7 @@ async function execCommand(
let { workdir } = execInput;
if (workdir) {
try {
await access(workdir);
await fs.access(workdir);
} catch (e) {
log(`EXEC workdir=${workdir} not found, use process.cwd() instead`);
workdir = process.cwd();
@@ -271,18 +271,19 @@ async function execCommand(
};
}
/**
* Return `true` if the `sandbox-exec` binary can be located. This intentionally does **not**
* spawn the binary we only care about its presence.
*/
export const isSandboxExecAvailable = (): Promise<boolean> =>
new Promise((res) =>
execFile(
"command",
["-v", "sandbox-exec"],
{ signal: AbortSignal.timeout(200) },
(err) => res(!err), // exit 0 ⇒ found
),
/** 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;
},
);
async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
@@ -295,7 +296,7 @@ async function getSandbox(runInSandbox: boolean): Promise<SandboxType> {
// 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()) {
if (await isSandboxExecAvailable) {
return SandboxType.MACOS_SEATBELT;
} else {
throw new Error(

View File

@@ -12,6 +12,14 @@ 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,
@@ -57,7 +65,7 @@ export function execWithSeatbelt(
);
const fullCommand = [
"sandbox-exec",
PATH_TO_SEATBELT_EXECUTABLE,
"-p",
fullPolicy,
...policyTemplateParams,

View File

@@ -7,6 +7,7 @@
// 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";
@@ -62,6 +63,8 @@ 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"] || "";
@@ -142,6 +145,9 @@ 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
@@ -165,6 +171,7 @@ export type AppConfig = {
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
reasoningEffort?: ReasoningEffort;
/** Whether to enable desktop notifications for responses */
notify?: boolean;
@@ -316,6 +323,22 @@ 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)
@@ -365,7 +388,8 @@ export const loadConfig = (
instructions: combinedInstructions,
notify: storedConfig.notify === true,
approvalMode: storedConfig.approvalMode,
disableResponseStorage: storedConfig.disableResponseStorage ?? false,
disableResponseStorage: storedConfig.disableResponseStorage === true,
reasoningEffort: storedConfig.reasoningEffort,
};
// -----------------------------------------------------------------------
@@ -480,6 +504,8 @@ export const saveConfig = (
provider: config.provider,
providers: config.providers,
approvalMode: config.approvalMode,
disableResponseStorage: config.disableResponseStorage,
reasoningEffort: config.reasoningEffort,
};
// Add history settings if they exist

View File

@@ -98,10 +98,8 @@ describe("AgentLoop ratelimit handling", () => {
// is in progress.
const runPromise = agent.run(userMsg as any);
// The agent waits 15 000 ms between retries (ratelimit backoff) and does
// this four times (after attempts 14). Fastforward a bit more to cover
// any additional small `setTimeout` calls inside the implementation.
await vi.advanceTimersByTimeAsync(61_000); // 4 * 15s + 1s safety margin
// Should be done in at most 180 seconds.
await vi.advanceTimersByTimeAsync(180_000);
// Ensure the promise settles without throwing.
await expect(runPromise).resolves.not.toThrow();
@@ -110,8 +108,8 @@ describe("AgentLoop ratelimit handling", () => {
await vi.advanceTimersByTimeAsync(20);
// The OpenAI client should have been called the maximum number of retry
// attempts (5).
expect(openAiState.createSpy).toHaveBeenCalledTimes(5);
// attempts (8).
expect(openAiState.createSpy).toHaveBeenCalledTimes(8);
// Finally, verify that the user sees a helpful system message.
const sysMsg = received.find(

View File

@@ -122,7 +122,7 @@ describe("AgentLoop automatic retry on 5xx errors", () => {
expect(assistant?.content?.[0]?.text).toBe("ok");
});
it("fails after 3 attempts and surfaces system message", async () => {
it("fails after a few 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(5);
expect(openAiState.createSpy).toHaveBeenCalledTimes(8);
const sysMsg = received.find(
(i) =>

View File

@@ -0,0 +1,121 @@
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
});
});

View File

@@ -0,0 +1,93 @@
/**
* 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);
}
});
}
}
});
});

View File

@@ -0,0 +1,43 @@
/**
* 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);
});
});

24
codex-rs/Cargo.lock generated
View File

@@ -475,7 +475,6 @@ dependencies = [
"clap",
"codex-core",
"codex-exec",
"codex-interactive",
"codex-repl",
"codex-tui",
"serde_json",
@@ -529,6 +528,7 @@ dependencies = [
"anyhow",
"clap",
"codex-core",
"owo-colors",
"tokio",
"tracing",
"tracing-subscriber",
@@ -554,16 +554,6 @@ dependencies = [
"tempfile",
]
[[package]]
name = "codex-interactive"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"codex-core",
"tokio",
]
[[package]]
name = "codex-repl"
version = "0.1.0"
@@ -571,7 +561,7 @@ dependencies = [
"anyhow",
"clap",
"codex-core",
"owo-colors 4.2.0",
"owo-colors",
"rand",
"tokio",
"tracing",
@@ -609,7 +599,7 @@ dependencies = [
"eyre",
"indenter",
"once_cell",
"owo-colors 3.5.0",
"owo-colors",
"tracing-error",
]
@@ -620,7 +610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd6be1b2a7e382e2b98b43b2adcca6bb0e465af0bdd38123873ae61eb17a72c2"
dependencies = [
"once_cell",
"owo-colors 3.5.0",
"owo-colors",
"tracing-core",
"tracing-error",
]
@@ -2235,12 +2225,6 @@ 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"

View File

@@ -7,7 +7,6 @@ members = [
"core",
"exec",
"execpolicy",
"interactive",
"repl",
"tui",
]

View File

@@ -17,7 +17,6 @@ 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.

View File

@@ -86,6 +86,8 @@ 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,
},
}
@@ -126,7 +128,10 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
move_path,
chunks,
} => {
let unified_diff = match unified_diff_from_chunks(&path, &chunks) {
let ApplyPatchFileUpdate {
unified_diff,
content: contents,
} = match unified_diff_from_chunks(&path, &chunks) {
Ok(diff) => diff,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(e);
@@ -137,6 +142,7 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String]) -> MaybeApplyPatchVerif
ApplyPatchFileChange::Update {
unified_diff,
move_path,
new_content: contents,
},
);
}
@@ -516,10 +522,17 @@ 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<String, ApplyPatchError> {
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
unified_diff_from_chunks_with_context(path, chunks, 1)
}
@@ -527,13 +540,17 @@ pub fn unified_diff_from_chunks_with_context(
path: &Path,
chunks: &[UpdateFileChunk],
context: usize,
) -> std::result::Result<String, ApplyPatchError> {
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
let AppliedPatch {
original_contents,
new_contents,
} = derive_new_contents_from_chunks(path, chunks)?;
let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
Ok(text_diff.unified_diff().context_radius(context).to_string())
let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
Ok(ApplyPatchFileUpdate {
unified_diff,
content: new_contents,
})
}
/// Print the summary of changes in git-style format.
@@ -898,7 +915,11 @@ PATCH"#,
-qux
+QUX
"#;
assert_eq!(expected_diff, diff);
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nBAR\nbaz\nQUX\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
@@ -930,7 +951,11 @@ PATCH"#,
+FOO
bar
"#;
assert_eq!(expected_diff, diff);
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "FOO\nbar\nbaz\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
@@ -963,7 +988,11 @@ PATCH"#,
-baz
+BAZ
"#;
assert_eq!(expected_diff, diff);
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nBAZ\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
@@ -993,7 +1022,11 @@ PATCH"#,
baz
+quux
"#;
assert_eq!(expected_diff, diff);
let expected = ApplyPatchFileUpdate {
unified_diff: expected_diff.to_string(),
content: "foo\nbar\nbaz\nquux\n".to_string(),
};
assert_eq!(expected, diff);
}
#[test]
@@ -1032,7 +1065,7 @@ PATCH"#,
let diff = unified_diff_from_chunks(&path, chunks).unwrap();
let expected = r#"@@ -1,6 +1,7 @@
let expected_diff = r#"@@ -1,6 +1,7 @@
a
-b
+B
@@ -1044,6 +1077,11 @@ 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();

View File

@@ -12,7 +12,6 @@ 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"

View File

@@ -0,0 +1,51 @@
//! `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);
}
}

View File

@@ -1,3 +1,5 @@
#[cfg(target_os = "linux")]
mod landlock;
mod proto;
mod seatbelt;
@@ -7,7 +9,6 @@ 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;
@@ -25,7 +26,7 @@ use crate::proto::ProtoCli;
)]
struct MultitoolCli {
#[clap(flatten)]
interactive: InteractiveCli,
interactive: TuiCli,
#[clap(subcommand)]
subcommand: Option<Subcommand>,
@@ -37,10 +38,6 @@ 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),
@@ -63,11 +60,14 @@ 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 in full-auto mode (can be specified multiple times).
/// 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>,
@@ -80,6 +80,21 @@ struct SeatbeltCommand {
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 {}
@@ -89,14 +104,11 @@ async fn main() -> anyhow::Result<()> {
match cli.subcommand {
None => {
codex_interactive::run_main(cli.interactive).await?;
codex_tui::run_main(cli.interactive)?;
}
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?;
}
@@ -111,6 +123,18 @@ async fn main() -> anyhow::Result<()> {
}) => {
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.");
}
},
}

View File

@@ -3,8 +3,6 @@ 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;
@@ -984,13 +982,17 @@ async fn handle_function_call(
)
.await;
match rx_approve.await.unwrap_or_default() {
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 {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => {
// Persist this command as preapproved 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,
@@ -1346,6 +1348,7 @@ 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(),
@@ -1400,28 +1403,10 @@ 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() {
@@ -1433,11 +1418,14 @@ 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());
}
}

View File

@@ -15,10 +15,7 @@ use tokio::sync::Notify;
/// 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,
disable_response_storage: bool,
) -> anyhow::Result<(CodexWrapper, Event, Arc<Notify>)> {
pub async fn init_codex(config: Config) -> anyhow::Result<(CodexWrapper, Event, Arc<Notify>)> {
let ctrl_c = notify_on_sigint();
let codex = CodexWrapper::new(Codex::spawn(ctrl_c.clone())?);
let init_id = codex
@@ -27,7 +24,7 @@ pub async fn init_codex(
instructions: config.instructions.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy,
disable_response_storage,
disable_response_storage: config.disable_response_storage,
})
.await?;

View File

@@ -21,6 +21,13 @@ pub struct Config {
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 instructions: Option<String>,
}
@@ -31,6 +38,7 @@ 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 {
@@ -50,6 +58,7 @@ impl Config {
model,
approval_policy,
sandbox_policy,
disable_response_storage,
} = overrides;
if let Some(model) = model {
@@ -61,6 +70,9 @@ impl Config {
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)
}

View File

@@ -35,6 +35,12 @@ 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>,
@@ -186,7 +192,7 @@ pub fn create_seatbelt_command(
};
let mut seatbelt_command: Vec<String> = vec![
"sandbox-exec".to_string(),
MACOS_PATH_TO_SEATBELT_EXECUTABLE.to_string(),
"-p".to_string(),
full_policy.to_string(),
];

View File

@@ -14,7 +14,7 @@ pub mod exec;
mod flags;
mod is_safe_command;
#[cfg(target_os = "linux")]
mod linux;
pub mod linux;
mod models;
pub mod protocol;
mod safety;

View File

@@ -72,7 +72,15 @@ pub async fn exec_linux(
}
}
fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathBuf>) -> Result<()> {
/// 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<()> {
let abi = ABI::V5;
let access_rw = AccessFs::from_all(abi);
let access_ro = AccessFs::from_read(abi);
@@ -98,7 +106,9 @@ fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathB
Ok(())
}
fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
/// 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> {
// Build rule map.
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
@@ -225,7 +235,9 @@ mod tests_linux {
&format!("echo blah > {}", file_path.to_string_lossy()),
],
&[tmpdir.path().to_path_buf()],
500,
// 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,
)
.await;
}

View File

@@ -24,3 +24,4 @@ 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"

View File

@@ -12,11 +12,23 @@ use codex_core::protocol::FileChange;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
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";
@@ -41,6 +53,20 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
..
} = 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);
@@ -56,10 +82,14 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
// 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, disable_response_storage).await?;
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?;
let codex = Arc::new(codex_wrapper);
info!("Codex initialized with event: {event:?}");

View File

@@ -1,24 +0,0 @@
[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",
] }

View File

@@ -1,33 +0,0 @@
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')]
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,
/// Initial instructions for the agent.
pub prompt: Option<String>,
}

View File

@@ -1,7 +0,0 @@
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);
}

View File

@@ -1,11 +0,0 @@
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(())
}

View File

@@ -81,6 +81,11 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
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)?;
@@ -104,7 +109,7 @@ async fn codex_main(cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Resul
instructions: cfg.instructions,
approval_policy: cfg.approval_policy,
sandbox_policy: cfg.sandbox_policy,
disable_response_storage: cli.disable_response_storage,
disable_response_storage: cfg.disable_response_storage,
},
};

View File

@@ -37,7 +37,6 @@ impl App<'_> {
initial_prompt: Option<String>,
show_git_warning: bool,
initial_images: Vec<std::path::PathBuf>,
disable_response_storage: bool,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
@@ -81,7 +80,6 @@ impl App<'_> {
app_event_tx.clone(),
initial_prompt.clone(),
initial_images,
disable_response_storage,
);
let app_state = if show_git_warning {

View File

@@ -49,7 +49,6 @@ impl ChatWidget<'_> {
app_event_tx: Sender<AppEvent>,
initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>,
disable_response_storage: bool,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
@@ -62,15 +61,14 @@ impl ChatWidget<'_> {
// 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, disable_response_storage).await {
Ok(vals) => vals,
Err(e) => {
// TODO: surface this error to the user.
tracing::error!("failed to initialize codex: {e}");
return;
}
};
let (codex, session_event, _ctrl_c) = match init_codex(config_for_agent_loop).await {
Ok(vals) => vals,
Err(e) => {
// TODO: surface this error to the user.
tracing::error!("failed to initialize codex: {e}");
return;
}
};
// Forward the captured `SessionInitialized` event that was consumed
// inside `init_codex()` so it can be rendered in the UI.

View File

@@ -48,11 +48,11 @@ impl ConversationHistoryWidget {
self.scroll_down(1);
true
}
KeyCode::PageUp | KeyCode::Char('b') | KeyCode::Char('u') | KeyCode::Char('U') => {
KeyCode::PageUp | KeyCode::Char('b') => {
self.scroll_page_up();
true
}
KeyCode::PageDown | KeyCode::Char(' ') | KeyCode::Char('d') | KeyCode::Char('D') => {
KeyCode::PageDown | KeyCode::Char(' ') => {
self.scroll_page_down();
true
}
@@ -238,7 +238,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/u = PgUp, space/d = PgDn)",
"Messages (↑/↓ or j/k = line, b/space = page)",
Style::default().fg(Color::LightYellow),
)
} else {

View File

@@ -39,6 +39,11 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
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) {
@@ -134,19 +139,8 @@ fn run_ratatui_app(
let mut terminal = tui::init()?;
terminal.clear()?;
let Cli {
prompt,
images,
disable_response_storage,
..
} = cli;
let mut app = App::new(
config,
prompt,
show_git_warning,
images,
disable_response_storage,
);
let Cli { prompt, images, .. } = cli;
let mut app = App::new(config.clone(), prompt, show_git_warning, images);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
{