Compare commits

...

21 Commits

Author SHA1 Message Date
Michael Bolin
5122fe647f chore: fix errors in .github/workflows/rust-release.yml and prep 0.0.2504292006 release (#745)
Apparently I made two key mistakes in
https://github.com/openai/codex/pull/740 (fixed in this PR):

* I forgot to redefine `$dest` in the `Stage Linux-only artifacts` step
* I did not define the `if` check correctly in the `Stage Linux-only
artifacts` step

This fixes both of those issues and bumps the workspace version to
`0.0.2504292006` in preparation for another release attempt.
2025-04-29 20:12:23 -07:00
Michael Bolin
1a39568e03 chore: set Cargo workspace to version 0.0.2504291954 to create a scratch release (#744) 2025-04-29 19:56:30 -07:00
Michael Bolin
efb0acc152 fix: primary output of the codex-cli crate is named codex, not codex-cli (#743)
I just got a bunch of failures in the release workflow:

https://github.com/openai/codex/actions/runs/14745492805/job/41391926707

along the lines of:

```
cp: cannot stat 'target/aarch64-unknown-linux-gnu/release/codex-cli': No such file or directory
```
2025-04-29 19:53:29 -07:00
Michael Bolin
85999d7277 chore: set Cargo workspace to version 0.0.2504291926 to create a scratch release (#741)
Needed to exercise the new release process in
https://github.com/openai/codex/pull/671.
2025-04-29 19:35:37 -07:00
Michael Bolin
411bfeb410 feat: codex-linux-sandbox standalone executable (#740)
This introduces a standalone executable that run the equivalent of the
`codex debug landlock` subcommand and updates `rust-release.yml` to
include it in the release.

The idea is that we will include this small binary with the TypeScript
CLI to provide support for Linux sandboxing.
2025-04-29 19:21:26 -07:00
Michael Bolin
27bc4516bf feat: bring back -s option to specify sandbox permissions (#739) 2025-04-29 18:42:52 -07:00
oai-ragona
cb0b0259f4 [codex-rs] Add rust-release action (#671)
Taking a pass at building artifacts per platform so we can consider
different distribution strategies that don't require users to install
the full `cargo` toolchain.

Right now this grabs just the `codex-repl` and `codex-tui` bins for 5
different targets and bundles them into a draft release. I think a
clearly marked pre-release set of artifacts will unblock the next step
of testing.
2025-04-29 16:38:47 -07:00
Michael Bolin
0a00b5ed29 fix: overhaul SandboxPolicy and config loading in Rust (#732)
Previous to this PR, `SandboxPolicy` was a bit difficult to work with:


237f8a11e1/codex-rs/core/src/protocol.rs (L98-L108)

Specifically:

* It was an `enum` and therefore options were mutually exclusive as
opposed to additive.
* It defined things in terms of what the agent _could not_ do as opposed
to what they _could_ do. This made things hard to support because we
would prefer to build up a sandbox config by starting with something
extremely restrictive and only granting permissions for things the user
as explicitly allowed.

This PR changes things substantially by redefining the policy in terms
of two concepts:

* A `SandboxPermission` enum that defines permissions that can be
granted to the agent/sandbox.
* A `SandboxPolicy` that internally stores a `Vec<SandboxPermission>`,
but externally exposes a simpler API that can be used to configure
Seatbelt/Landlock.

Previous to this PR, we supported a `--sandbox` flag that effectively
mapped to an enum value in `SandboxPolicy`. Though now that
`SandboxPolicy` is a wrapper around `Vec<SandboxPermission>`, the single
`--sandbox` flag no longer makes sense. While I could have turned it
into a flag that the user can specify multiple times, I think the
current values to use with such a flag are long and potentially messy,
so for the moment, I have dropped support for `--sandbox` altogether and
we can bring it back once we have figured out the naming thing.

Since `--sandbox` is gone, users now have to specify `--full-auto` to
get a sandbox that allows writes in `cwd`. Admittedly, there is no clean
way to specify the equivalent of `--full-auto` in your `config.toml`
right now, so we will have to revisit that, as well.

Because `Config` presents a `SandboxPolicy` field and `SandboxPolicy`
changed considerably, I had to overhaul how config loading works, as
well. There are now two distinct concepts, `ConfigToml` and `Config`:

* `ConfigToml` is the deserialization of `~/.codex/config.toml`. As one
might expect, every field is `Optional` and it is `#[derive(Deserialize,
Default)]`. Consistent use of `Optional` makes it clear what the user
has specified explicitly.
* `Config` is the "normalized config" and is produced by merging
`ConfigToml` with `ConfigOverrides`. Where `ConfigToml` contains a raw
`Option<Vec<SandboxPermission>>`, `Config` presents only the final
`SandboxPolicy`.

The changes to `core/src/exec.rs` and `core/src/linux.rs` merit extra
special attention to ensure we are faithfully mapping the
`SandboxPolicy` to the Seatbelt and Landlock configs, respectively.

Also, take note that `core/src/seatbelt_readonly_policy.sbpl` has been
renamed to `codex-rs/core/src/seatbelt_base_policy.sbpl` and that
`(allow file-read*)` has been removed from the `.sbpl` file as now this
is added to the policy in `core/src/exec.rs` when
`sandbox_policy.has_full_disk_read_access()` is `true`.
2025-04-29 15:01:16 -07:00
Matan Yemini
237f8a11e1 feat: add common package registries domains to allowed-domains list (#414)
feat: add common package registries domains to allowed-domains list
2025-04-29 12:07:00 -07:00
Kevin Alwell
a6ed7ff103 Fixes issue #726 by adding config to configToSave object (#728)
The saveConfig() function only includes a hardcoded subset of properties
when writing the config file. Any property not explicitly listed (like
disableResponseStorage) will be dropped.
I have added `disableResponseStorage` to the `configToSave` object as
the immediate fix.

[Linking Issue this fixes.](https://github.com/openai/codex/issues/726)
2025-04-29 13:10:16 -04:00
Michael Bolin
3b39964f81 feat: improve output of exec subcommand (#719) 2025-04-29 09:59:35 -07: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
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
54 changed files with 1815 additions and 576 deletions

37
.github/dotslash-config.json vendored Normal file
View File

@@ -0,0 +1,37 @@
{
"outputs": {
"codex-repl": {
"platforms": {
"macos-aarch64": { "regex": "^codex-repl-aarch64-apple-darwin\\.zst$", "path": "codex-repl" },
"macos-x86_64": { "regex": "^codex-repl-x86_64-apple-darwin\\.zst$", "path": "codex-repl" },
"linux-x86_64": { "regex": "^codex-repl-x86_64-unknown-linux-musl\\.zst$", "path": "codex-repl" },
"linux-aarch64": { "regex": "^codex-repl-aarch64-unknown-linux-gnu\\.zst$", "path": "codex-repl" }
}
},
"codex-exec": {
"platforms": {
"macos-aarch64": { "regex": "^codex-exec-aarch64-apple-darwin\\.zst$", "path": "codex-exec" },
"macos-x86_64": { "regex": "^codex-exec-x86_64-apple-darwin\\.zst$", "path": "codex-exec" },
"linux-x86_64": { "regex": "^codex-exec-x86_64-unknown-linux-musl\\.zst$", "path": "codex-exec" },
"linux-aarch64": { "regex": "^codex-exec-aarch64-unknown-linux-gnu\\.zst$", "path": "codex-exec" }
}
},
"codex": {
"platforms": {
"macos-aarch64": { "regex": "^codex-aarch64-apple-darwin\\.zst$", "path": "codex" },
"macos-x86_64": { "regex": "^codex-x86_64-apple-darwin\\.zst$", "path": "codex" },
"linux-x86_64": { "regex": "^codex-x86_64-unknown-linux-musl\\.zst$", "path": "codex" },
"linux-aarch64": { "regex": "^codex-aarch64-unknown-linux-gnu\\.zst$", "path": "codex" }
}
},
"codex-linux-sandbox": {
"platforms": {
"linux-x86_64": { "regex": "^codex-linux-sandbox-x86_64-unknown-linux-musl\\.zst$", "path": "codex-linux-sandbox" },
"linux-aarch64": { "regex": "^codex-linux-sandbox-aarch64-unknown-linux-gnu\\.zst$", "path": "codex-linux-sandbox" }
}
}
}
}

156
.github/workflows/rust-release.yml vendored Normal file
View File

@@ -0,0 +1,156 @@
# Release workflow for codex-rs.
# To release, follow a workflow like:
# ```
# git tag -a rust-v0.1.0 -m "Release 0.1.0"
# git push origin rust-v0.1.0
# ```
name: rust-release
on:
push:
tags:
- "rust-v.*.*.*"
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
env:
TAG_REGEX: '^rust-v\.[0-9]+\.[0-9]+\.[0-9]+$'
jobs:
tag-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate tag matches Cargo.toml version
shell: bash
run: |
set -euo pipefail
echo "::group::Tag validation"
# 1. Must be a tag and match the regex
[[ "${GITHUB_REF_TYPE}" == "tag" ]] \
|| { echo "❌ Not a tag push"; exit 1; }
[[ "${GITHUB_REF_NAME}" =~ ${TAG_REGEX} ]] \
|| { echo "❌ Tag '${GITHUB_REF_NAME}' != ${TAG_REGEX}"; exit 1; }
# 2. Extract versions
tag_ver="${GITHUB_REF_NAME#rust-v.}"
cargo_ver="$(grep -m1 '^version' codex-rs/Cargo.toml \
| sed -E 's/version *= *"([^"]+)".*/\1/')"
# 3. Compare
[[ "${tag_ver}" == "${cargo_ver}" ]] \
|| { echo "❌ Tag ${tag_ver} ≠ Cargo.toml ${cargo_ver}"; exit 1; }
echo "✅ Tag and Cargo.toml agree (${tag_ver})"
echo "::endgroup::"
build:
needs: tag-check
name: ${{ matrix.runner }} - ${{ matrix.target }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
defaults:
run:
working-directory: codex-rs
strategy:
fail-fast: false
matrix:
include:
- runner: macos-14
target: aarch64-apple-darwin
- runner: macos-14
target: x86_64-apple-darwin
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
${{ github.workspace }}/codex-rs/target/
key: cargo-release-${{ matrix.runner }}-${{ matrix.target }}-${{ hashFiles('**/Cargo.lock') }}
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' }}
name: Install musl build tools
run: |
sudo apt install -y musl-tools pkg-config
- name: Cargo build
run: cargo build --target ${{ matrix.target }} --release --all-targets --all-features
- name: Stage artifacts
shell: bash
run: |
dest="dist/${{ matrix.target }}"
mkdir -p "$dest"
cp target/${{ matrix.target }}/release/codex-repl "$dest/codex-repl-${{ matrix.target }}"
cp target/${{ matrix.target }}/release/codex-exec "$dest/codex-exec-${{ matrix.target }}"
cp target/${{ matrix.target }}/release/codex "$dest/codex-${{ matrix.target }}"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-gnu' }}
name: Stage Linux-only artifacts
shell: bash
run: |
dest="dist/${{ matrix.target }}"
cp target/${{ matrix.target }}/release/codex-linux-sandbox "$dest/codex-linux-sandbox-${{ matrix.target }}"
- name: Compress artifacts
shell: bash
run: |
zstd -T0 -19 --rm "$dest"/*
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.target }}
path: codex-rs/dist/${{ matrix.target }}/*
release:
needs: build
name: release
runs-on: ubuntu-24.04
env:
RELEASE_TAG: codex-rs-${{ github.sha }}-${{ github.run_attempt }}-${{ github.ref_name }}
steps:
- uses: actions/download-artifact@v4
with:
path: dist
- name: List
run: ls -R dist/
- uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
files: dist/**
# TODO(ragona): I'm going to leave these as prerelease/draft for now.
# It gives us 1) clarity that these are not yet a stable version, and
# 2) allows a human step to review the release before publishing the draft.
prerelease: true
draft: true
- uses: facebook/dotslash-publish-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ env.RELEASE_TAG }}
config: .github/dotslash-config.json

View File

@@ -2,6 +2,26 @@
set -euo pipefail # Exit on error, undefined vars, and pipeline failures
IFS=$'\n\t' # Stricter word splitting
# Read allowed domains from file
ALLOWED_DOMAINS_FILE="/etc/codex/allowed_domains.txt"
if [ -f "$ALLOWED_DOMAINS_FILE" ]; then
ALLOWED_DOMAINS=()
while IFS= read -r domain; do
ALLOWED_DOMAINS+=("$domain")
done < "$ALLOWED_DOMAINS_FILE"
echo "Using domains from file: ${ALLOWED_DOMAINS[*]}"
else
# Fallback to default domains
ALLOWED_DOMAINS=("api.openai.com")
echo "Domains file not found, using default: ${ALLOWED_DOMAINS[*]}"
fi
# Ensure we have at least one domain
if [ ${#ALLOWED_DOMAINS[@]} -eq 0 ]; then
echo "ERROR: No allowed domains specified"
exit 1
fi
# Flush existing rules and delete existing ipsets
iptables -F
iptables -X
@@ -24,8 +44,7 @@ iptables -A OUTPUT -o lo -j ACCEPT
ipset create allowed-domains hash:net
# Resolve and add other allowed domains
for domain in \
"api.openai.com"; do
for domain in "${ALLOWED_DOMAINS[@]}"; do
echo "Resolving $domain..."
ips=$(dig +short A "$domain")
if [ -z "$ips" ]; then
@@ -87,7 +106,7 @@ else
echo "Firewall verification passed - unable to reach https://example.com as expected"
fi
# Verify OpenAI API access
# Always verify OpenAI API access is working
if ! curl --connect-timeout 5 https://api.openai.com >/dev/null 2>&1; then
echo "ERROR: Firewall verification failed - unable to reach https://api.openai.com"
exit 1

View File

@@ -10,6 +10,8 @@ set -e
# Default the work directory to WORKSPACE_ROOT_DIR if not provided.
WORK_DIR="${WORKSPACE_ROOT_DIR:-$(pwd)}"
# Default allowed domains - can be overridden with OPENAI_ALLOWED_DOMAINS env var
OPENAI_ALLOWED_DOMAINS="${OPENAI_ALLOWED_DOMAINS:-api.openai.com}"
# Parse optional flag.
if [ "$1" = "--work_dir" ]; then
@@ -45,6 +47,12 @@ if [ -z "$WORK_DIR" ]; then
exit 1
fi
# Verify that OPENAI_ALLOWED_DOMAINS is not empty
if [ -z "$OPENAI_ALLOWED_DOMAINS" ]; then
echo "Error: OPENAI_ALLOWED_DOMAINS is empty."
exit 1
fi
# Kill any existing container for the working directory using cleanup(), centralizing removal logic.
cleanup
@@ -57,8 +65,25 @@ docker run --name "$CONTAINER_NAME" -d \
codex \
sleep infinity
# Initialize the firewall inside the container with root privileges.
docker exec --user root "$CONTAINER_NAME" /usr/local/bin/init_firewall.sh
# Write the allowed domains to a file in the container
docker exec --user root "$CONTAINER_NAME" bash -c "mkdir -p /etc/codex"
for domain in $OPENAI_ALLOWED_DOMAINS; do
# Validate domain format to prevent injection
if [[ ! "$domain" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "Error: Invalid domain format: $domain"
exit 1
fi
echo "$domain" | docker exec --user root -i "$CONTAINER_NAME" bash -c "cat >> /etc/codex/allowed_domains.txt"
done
# Set proper permissions on the domains file
docker exec --user root "$CONTAINER_NAME" bash -c "chmod 444 /etc/codex/allowed_domains.txt && chown root:root /etc/codex/allowed_domains.txt"
# Initialize the firewall inside the container as root user
docker exec --user root "$CONTAINER_NAME" bash -c "/usr/local/bin/init_firewall.sh"
# Remove the firewall script after running it
docker exec --user root "$CONTAINER_NAME" bash -c "rm -f /usr/local/bin/init_firewall.sh"
# Execute the provided command in the container, ensuring it runs in the work directory.
# We use a parameterized bash command to safely handle the command and directory.

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

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

21
codex-rs/Cargo.lock generated
View File

@@ -469,13 +469,12 @@ dependencies = [
[[package]]
name = "codex-cli"
version = "0.1.0"
version = "0.0.2504292006"
dependencies = [
"anyhow",
"clap",
"codex-core",
"codex-exec",
"codex-interactive",
"codex-repl",
"codex-tui",
"serde_json",
@@ -505,6 +504,7 @@ dependencies = [
"mime_guess",
"openssl-sys",
"patch",
"path-absolutize",
"predicates",
"rand",
"reqwest",
@@ -524,11 +524,14 @@ dependencies = [
[[package]]
name = "codex-exec"
version = "0.1.0"
version = "0.0.2504292006"
dependencies = [
"anyhow",
"chrono",
"clap",
"codex-core",
"owo-colors 4.2.0",
"shlex",
"tokio",
"tracing",
"tracing-subscriber",
@@ -554,19 +557,9 @@ dependencies = [
"tempfile",
]
[[package]]
name = "codex-interactive"
version = "0.1.0"
dependencies = [
"anyhow",
"clap",
"codex-core",
"tokio",
]
[[package]]
name = "codex-repl"
version = "0.1.0"
version = "0.0.2504292006"
dependencies = [
"anyhow",
"clap",

View File

@@ -7,7 +7,15 @@ members = [
"core",
"exec",
"execpolicy",
"interactive",
"repl",
"tui",
]
[workspace.package]
version = "0.0.2504292006"
[profile.release]
lto = "fat"
# Because we bundle some of these executables with the TypeScript CLI, we
# remove everything to make the binary as small as possible.
strip = "symbols"

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

@@ -1,18 +1,25 @@
[package]
name = "codex-cli"
version = "0.1.0"
version = { workspace = true }
edition = "2021"
[[bin]]
name = "codex"
path = "src/main.rs"
[[bin]]
name = "codex-linux-sandbox"
path = "src/linux-sandbox/main.rs"
[lib]
name = "codex_cli"
path = "src/lib.rs"
[dependencies]
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,37 @@
//! `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::process;
use std::process::Command;
use std::process::ExitStatus;
/// Execute `command` in a Linux sandbox (Landlock + seccomp) the way Codex
/// would.
pub fn run_landlock(command: Vec<String>, sandbox_policy: SandboxPolicy) -> 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> {
codex_core::linux::apply_sandbox_policy_to_current_thread(sandbox_policy)?;
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);
}
}

47
codex-rs/cli/src/lib.rs Normal file
View File

@@ -0,0 +1,47 @@
#[cfg(target_os = "linux")]
pub mod landlock;
pub mod proto;
pub mod seatbelt;
use clap::Parser;
use codex_core::protocol::SandboxPolicy;
use codex_core::SandboxPermissionOption;
#[derive(Debug, Parser)]
pub struct SeatbeltCommand {
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Full command args to run under seatbelt.
#[arg(trailing_var_arg = true)]
pub command: Vec<String>,
}
#[derive(Debug, Parser)]
pub struct LandlockCommand {
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Full command args to run under landlock.
#[arg(trailing_var_arg = true)]
pub command: Vec<String>,
}
pub fn create_sandbox_policy(full_auto: bool, sandbox: SandboxPermissionOption) -> SandboxPolicy {
if full_auto {
SandboxPolicy::new_full_auto_policy()
} else {
match sandbox.permissions.map(Into::into) {
Some(sandbox_policy) => sandbox_policy,
None => SandboxPolicy::new_read_only_policy(),
}
}
}

View File

@@ -0,0 +1,22 @@
#[cfg(not(target_os = "linux"))]
fn main() -> anyhow::Result<()> {
eprintln!("codex-linux-sandbox is not supported on this platform.");
std::process::exit(1);
}
#[cfg(target_os = "linux")]
fn main() -> anyhow::Result<()> {
use clap::Parser;
use codex_cli::create_sandbox_policy;
use codex_cli::landlock;
use codex_cli::LandlockCommand;
let LandlockCommand {
full_auto,
sandbox,
command,
} = LandlockCommand::parse();
let sandbox_policy = create_sandbox_policy(full_auto, sandbox);
landlock::run_landlock(command, sandbox_policy)?;
Ok(())
}

View File

@@ -1,13 +1,10 @@
mod proto;
mod seatbelt;
use std::path::PathBuf;
use clap::ArgAction;
use clap::Parser;
use codex_core::SandboxModeCliArg;
use codex_cli::create_sandbox_policy;
use codex_cli::proto;
use codex_cli::seatbelt;
use codex_cli::LandlockCommand;
use codex_cli::SeatbeltCommand;
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 +22,7 @@ use crate::proto::ProtoCli;
)]
struct MultitoolCli {
#[clap(flatten)]
interactive: InteractiveCli,
interactive: TuiCli,
#[clap(subcommand)]
subcommand: Option<Subcommand>,
@@ -37,10 +34,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,21 +56,9 @@ struct DebugArgs {
enum DebugCommand {
/// Run a command under Seatbelt (macOS only).
Seatbelt(SeatbeltCommand),
}
#[derive(Debug, Parser)]
struct SeatbeltCommand {
/// 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>,
/// Run a command under Landlock+seccomp (Linux only).
Landlock(LandlockCommand),
}
#[derive(Debug, Parser)]
@@ -89,14 +70,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?;
}
@@ -106,10 +84,24 @@ async fn main() -> anyhow::Result<()> {
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
DebugCommand::Seatbelt(SeatbeltCommand {
command,
sandbox_policy,
writable_roots,
sandbox,
full_auto,
}) => {
seatbelt::run_seatbelt(command, sandbox_policy.into(), writable_roots).await?;
let sandbox_policy = create_sandbox_policy(full_auto, sandbox);
seatbelt::run_seatbelt(command, sandbox_policy).await?;
}
#[cfg(target_os = "linux")]
DebugCommand::Landlock(LandlockCommand {
command,
sandbox,
full_auto,
}) => {
let sandbox_policy = create_sandbox_policy(full_auto, sandbox);
codex_cli::landlock::run_landlock(command, sandbox_policy)?;
}
#[cfg(not(target_os = "linux"))]
DebugCommand::Landlock(_) => {
anyhow::bail!("Landlock is only supported on Linux.");
}
},
}

View File

@@ -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(
pub 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, &sandbox_policy);
let status = tokio::process::Command::new(seatbelt_command[0].clone())
.args(&seatbelt_command[1..])
.spawn()

View File

@@ -21,6 +21,7 @@ fs-err = "3.1.0"
futures = "0.3"
mime_guess = "2.0"
patch = "0.7"
path-absolutize = "3.1.1"
rand = "0.9"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }

View File

@@ -1,10 +1,14 @@
//! Standard type to use with the `--approval-mode` CLI option.
//! Available when the `cli` feature is enabled for the crate.
use std::path::PathBuf;
use clap::ArgAction;
use clap::Parser;
use clap::ValueEnum;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::protocol::SandboxPermission;
#[derive(Clone, Copy, Debug, ValueEnum)]
#[value(rename_all = "kebab-case")]
@@ -24,19 +28,6 @@ pub enum ApprovalModeCliArg {
Never,
}
#[derive(Clone, Copy, Debug, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum SandboxModeCliArg {
/// Network syscalls will be blocked
NetworkRestricted,
/// Filesystem writes will be restricted
FileWriteRestricted,
/// Network and filesystem writes will be restricted
NetworkAndFileWriteRestricted,
/// No restrictions; full "unsandboxed" mode
DangerousNoRestrictions,
}
impl From<ApprovalModeCliArg> for AskForApproval {
fn from(value: ApprovalModeCliArg) -> Self {
match value {
@@ -47,15 +38,83 @@ impl From<ApprovalModeCliArg> for AskForApproval {
}
}
impl From<SandboxModeCliArg> for SandboxPolicy {
fn from(value: SandboxModeCliArg) -> Self {
match value {
SandboxModeCliArg::NetworkRestricted => SandboxPolicy::NetworkRestricted,
SandboxModeCliArg::FileWriteRestricted => SandboxPolicy::FileWriteRestricted,
SandboxModeCliArg::NetworkAndFileWriteRestricted => {
SandboxPolicy::NetworkAndFileWriteRestricted
#[derive(Parser, Debug)]
pub struct SandboxPermissionOption {
/// Specify this flag multiple times to specify the full set of permissions
/// to grant to Codex.
///
/// ```shell
/// codex -s disk-full-read-access \
/// -s disk-write-cwd \
/// -s disk-write-platform-user-temp-folder \
/// -s disk-write-platform-global-temp-folder
/// ```
///
/// Note disk-write-folder takes a value:
///
/// ```shell
/// -s disk-write-folder=$HOME/.pyenv/shims
/// ```
///
/// These permissions are quite broad and should be used with caution:
///
/// ```shell
/// -s disk-full-write-access
/// -s network-full-access
/// ```
#[arg(long = "sandbox-permission", short = 's', action = ArgAction::Append, value_parser = parse_sandbox_permission)]
pub permissions: Option<Vec<SandboxPermission>>,
}
/// Custom value-parser so we can keep the CLI surface small *and*
/// still handle the parameterised `disk-write-folder` case.
fn parse_sandbox_permission(raw: &str) -> std::io::Result<SandboxPermission> {
let base_path = std::env::current_dir()?;
parse_sandbox_permission_with_base_path(raw, base_path)
}
pub(crate) fn parse_sandbox_permission_with_base_path(
raw: &str,
base_path: PathBuf,
) -> std::io::Result<SandboxPermission> {
use SandboxPermission::*;
if let Some(path) = raw.strip_prefix("disk-write-folder=") {
return if path.is_empty() {
Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"--sandbox-permission disk-write-folder=<PATH> requires a non-empty PATH",
))
} else {
use path_absolutize::*;
let file = PathBuf::from(path);
let absolute_path = if file.is_relative() {
file.absolutize_from(base_path)
} else {
file.absolutize()
}
SandboxModeCliArg::DangerousNoRestrictions => SandboxPolicy::DangerousNoRestrictions,
}
.map(|path| path.into_owned())?;
Ok(DiskWriteFolder {
folder: absolute_path,
})
};
}
match raw {
"disk-full-read-access" => Ok(DiskFullReadAccess),
"disk-write-platform-user-temp-folder" => Ok(DiskWritePlatformUserTempFolder),
"disk-write-platform-global-temp-folder" => Ok(DiskWritePlatformGlobalTempFolder),
"disk-write-cwd" => Ok(DiskWriteCwd),
"disk-full-write-access" => Ok(DiskFullWriteAccess),
"network-full-access" => Ok(NetworkFullAccess),
_ => Err(
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"`{raw}` is not a recognised permission.\nRun with `--help` to see the accepted values."
),
)
),
}
}

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;
@@ -863,7 +861,7 @@ async fn handle_function_call(
assess_command_safety(
&params.command,
sess.approval_policy,
sess.sandbox_policy,
&sess.sandbox_policy,
&state.approved_commands,
)
};
@@ -918,14 +916,11 @@ async fn handle_function_call(
)
.await;
let roots_snapshot = { sess.writable_roots.lock().unwrap().clone() };
let output_result = process_exec_tool_call(
params.clone(),
sandbox_type,
&roots_snapshot,
sess.ctrl_c.clone(),
sess.sandbox_policy,
&sess.sandbox_policy,
)
.await;
@@ -1008,16 +1003,13 @@ async fn handle_function_call(
)
.await;
let retry_roots = { sess.writable_roots.lock().unwrap().clone() };
// This is an escalated retry; the policy will not be
// examined and the sandbox has been set to `None`.
let retry_output_result = process_exec_tool_call(
params.clone(),
SandboxType::None,
&retry_roots,
sess.ctrl_c.clone(),
sess.sandbox_policy,
&sess.sandbox_policy,
)
.await;
@@ -1346,6 +1338,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 +1393,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 +1408,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

@@ -1,5 +1,7 @@
use crate::approval_mode_cli_arg::parse_sandbox_permission_with_base_path;
use crate::flags::OPENAI_DEFAULT_MODEL;
use crate::protocol::AskForApproval;
use crate::protocol::SandboxPermission;
use crate::protocol::SandboxPolicy;
use dirs::home_dir;
use serde::Deserialize;
@@ -11,60 +13,53 @@ use std::path::PathBuf;
const EMBEDDED_INSTRUCTIONS: &str = include_str!("../prompt.md");
/// Application configuration loaded from disk and merged with overrides.
#[derive(Deserialize, Debug, Clone)]
#[derive(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)]
/// Approval policy for executing commands.
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).
pub disable_response_storage: bool,
/// System instructions.
pub instructions: Option<String>,
}
/// Optional overrides for user configuration (e.g., from CLI flags).
#[derive(Default, Debug, Clone)]
pub struct ConfigOverrides {
/// Base config deserialized from ~/.codex/config.toml.
#[derive(Deserialize, Debug, Clone, Default)]
pub struct ConfigToml {
/// Optional override of model selection.
pub model: Option<String>,
/// Default approval policy for executing commands.
pub approval_policy: Option<AskForApproval>,
pub sandbox_policy: Option<SandboxPolicy>,
// The `default` attribute ensures that the field is treated as `None` when
// the key is omitted from the TOML. Without it, Serde treats the field as
// required because we supply a custom deserializer.
#[serde(default, deserialize_with = "deserialize_sandbox_permissions")]
pub sandbox_permissions: Option<Vec<SandboxPermission>>,
/// 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).
pub disable_response_storage: Option<bool>,
/// System instructions.
pub instructions: Option<String>,
}
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:?}");
// 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,
} = 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;
}
Ok(cfg)
}
/// Attempt to parse the file at `~/.codex/config.toml` into a Config.
impl ConfigToml {
/// Attempt to parse the file at `~/.codex/config.toml`. If it does not
/// exist, return a default config. Though if it exists and cannot be
/// parsed, report that to the user and force them to fix it.
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) {
@@ -74,7 +69,7 @@ impl Config {
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("config.toml not found, using defaults");
Ok(Self::load_default_config())
Ok(Self::default())
}
Err(e) => {
tracing::error!("Failed to read config.toml: {e}");
@@ -82,17 +77,92 @@ impl Config {
}
}
}
}
/// 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 deserialize_sandbox_permissions<'de, D>(
deserializer: D,
) -> Result<Option<Vec<SandboxPermission>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let permissions: Option<Vec<String>> = Option::deserialize(deserializer)?;
match permissions {
Some(raw_permissions) => {
let base_path = codex_dir().map_err(serde::de::Error::custom)?;
let converted = raw_permissions
.into_iter()
.map(|raw| {
parse_sandbox_permission_with_base_path(&raw, base_path.clone())
.map_err(serde::de::Error::custom)
})
.collect::<Result<Vec<_>, D::Error>>()?;
Ok(Some(converted))
}
None => Ok(None),
}
}
/// 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 cfg: ConfigToml = ConfigToml::load_from_toml()?;
tracing::warn!("Config parsed from config.toml: {cfg:?}");
Ok(Self::load_from_base_config_with_overrides(cfg, overrides))
}
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_base_config_with_overrides(cfg: ConfigToml, overrides: ConfigOverrides) -> Self {
// Instructions: user-provided instructions.md > embedded default.
let 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;
let sandbox_policy = match sandbox_policy {
Some(sandbox_policy) => sandbox_policy,
None => {
// Derive a SandboxPolicy from the permissions in the config.
match cfg.sandbox_permissions {
// Note this means the user can explicitly set permissions
// to the empty list in the config file, granting it no
// permissions whatsoever.
Some(permissions) => SandboxPolicy::from(permissions),
// Default to read only rather than completely locked down.
None => SandboxPolicy::new_read_only_policy(),
}
}
};
Self {
model: model.or(cfg.model).unwrap_or_else(default_model),
approval_policy: approval_policy
.or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default),
sandbox_policy,
disable_response_storage: disable_response_storage
.or(cfg.disable_response_storage)
.unwrap_or(false),
instructions,
}
}
fn load_instructions() -> Option<String> {
@@ -100,6 +170,15 @@ impl Config {
p.push("instructions.md");
std::fs::read_to_string(&p).ok()
}
/// 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_from_base_config_with_overrides(
ConfigToml::default(),
ConfigOverrides::default(),
)
}
}
fn default_model() -> String {
@@ -126,3 +205,60 @@ pub fn log_dir() -> std::io::Result<PathBuf> {
p.push("log");
Ok(p)
}
#[cfg(test)]
mod tests {
use super::*;
/// Verify that the `sandbox_permissions` field on `ConfigToml` correctly
/// differentiates between a value that is completely absent in the
/// provided TOML (i.e. `None`) and one that is explicitly specified as an
/// empty array (i.e. `Some(vec![])`). This ensures that downstream logic
/// that treats these two cases differently (default read-only policy vs a
/// fully locked-down sandbox) continues to function.
#[test]
fn test_sandbox_permissions_none_vs_empty_vec() {
// Case 1: `sandbox_permissions` key is *absent* from the TOML source.
let toml_source_without_key = "";
let cfg_without_key: ConfigToml = toml::from_str(toml_source_without_key)
.expect("TOML deserialization without key should succeed");
assert!(cfg_without_key.sandbox_permissions.is_none());
// Case 2: `sandbox_permissions` is present but set to an *empty array*.
let toml_source_with_empty = "sandbox_permissions = []";
let cfg_with_empty: ConfigToml = toml::from_str(toml_source_with_empty)
.expect("TOML deserialization with empty array should succeed");
assert_eq!(Some(vec![]), cfg_with_empty.sandbox_permissions);
// Case 3: `sandbox_permissions` contains a non-empty list of valid values.
let toml_source_with_values = r#"
sandbox_permissions = ["disk-full-read-access", "network-full-access"]
"#;
let cfg_with_values: ConfigToml = toml::from_str(toml_source_with_values)
.expect("TOML deserialization with valid permissions should succeed");
assert_eq!(
Some(vec![
SandboxPermission::DiskFullReadAccess,
SandboxPermission::NetworkFullAccess
]),
cfg_with_values.sandbox_permissions
);
}
/// Deserializing a TOML string containing an *invalid* permission should
/// fail with a helpful error rather than silently defaulting or
/// succeeding.
#[test]
fn test_sandbox_permissions_illegal_value() {
let toml_bad = r#"sandbox_permissions = ["not-a-real-permission"]"#;
let err = toml::from_str::<ConfigToml>(toml_bad)
.expect_err("Deserialization should fail for invalid permission");
// Make sure the error message contains the invalid value so users have
// useful feedback.
let msg = err.to_string();
assert!(msg.contains("not-a-real-permission"));
}
}

View File

@@ -1,7 +1,6 @@
use std::io;
#[cfg(target_family = "unix")]
use std::os::unix::process::ExitStatusExt;
use std::path::PathBuf;
use std::process::ExitStatus;
use std::process::Stdio;
use std::sync::Arc;
@@ -33,7 +32,13 @@ const DEFAULT_TIMEOUT_MS: u64 = 10_000;
const SIGKILL_CODE: i32 = 9;
const TIMEOUT_CODE: i32 = 64;
const MACOS_SEATBELT_READONLY_POLICY: &str = include_str!("seatbelt_readonly_policy.sbpl");
const MACOS_SEATBELT_BASE_POLICY: &str = include_str!("seatbelt_base_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 {
@@ -61,19 +66,17 @@ pub enum SandboxType {
#[cfg(target_os = "linux")]
async fn exec_linux(
params: ExecParams,
writable_roots: &[PathBuf],
ctrl_c: Arc<Notify>,
sandbox_policy: SandboxPolicy,
sandbox_policy: &SandboxPolicy,
) -> Result<RawExecToolCallOutput> {
crate::linux::exec_linux(params, writable_roots, ctrl_c, sandbox_policy).await
crate::linux::exec_linux(params, ctrl_c, sandbox_policy).await
}
#[cfg(not(target_os = "linux"))]
async fn exec_linux(
_params: ExecParams,
_writable_roots: &[PathBuf],
_ctrl_c: Arc<Notify>,
_sandbox_policy: SandboxPolicy,
_sandbox_policy: &SandboxPolicy,
) -> Result<RawExecToolCallOutput> {
Err(CodexErr::Io(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -84,9 +87,8 @@ async fn exec_linux(
pub async fn process_exec_tool_call(
params: ExecParams,
sandbox_type: SandboxType,
writable_roots: &[PathBuf],
ctrl_c: Arc<Notify>,
sandbox_policy: SandboxPolicy,
sandbox_policy: &SandboxPolicy,
) -> Result<ExecToolCallOutput> {
let start = Instant::now();
@@ -98,7 +100,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, sandbox_policy);
exec(
ExecParams {
command: seatbelt_command,
@@ -109,9 +111,7 @@ pub async fn process_exec_tool_call(
)
.await
}
SandboxType::LinuxSeccomp => {
exec_linux(params, writable_roots, ctrl_c, sandbox_policy).await
}
SandboxType::LinuxSeccomp => exec_linux(params, ctrl_c, sandbox_policy).await,
};
let duration = start.elapsed();
match raw_output_result {
@@ -156,41 +156,61 @@ pub async fn process_exec_tool_call(
pub fn create_seatbelt_command(
command: Vec<String>,
sandbox_policy: SandboxPolicy,
writable_roots: &[PathBuf],
sandbox_policy: &SandboxPolicy,
) -> Vec<String> {
let (policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
.iter()
.enumerate()
.map(|(index, root)| {
let param_name = format!("WRITABLE_ROOT_{index}");
let policy: String = format!("(subpath (param \"{param_name}\"))");
let cli_arg = format!("-D{param_name}={}", root.to_string_lossy());
(policy, cli_arg)
})
.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 {
let scoped_write_policy = format!("(allow file-write*\n{}\n)", policies.join(" "));
format!("{MACOS_SEATBELT_READONLY_POLICY}\n{scoped_write_policy}")
let (file_write_policy, extra_cli_args) = {
if sandbox_policy.has_full_disk_write_access() {
// Allegedly, this is more permissive than `(allow file-write*)`.
(
r#"(allow file-write* (regex #"^/"))"#.to_string(),
Vec::<String>::new(),
)
} else {
let writable_roots = sandbox_policy.get_writable_roots();
let (writable_folder_policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
.iter()
.enumerate()
.map(|(index, root)| {
let param_name = format!("WRITABLE_ROOT_{index}");
let policy: String = format!("(subpath (param \"{param_name}\"))");
let cli_arg = format!("-D{param_name}={}", root.to_string_lossy());
(policy, cli_arg)
})
.unzip();
if writable_folder_policies.is_empty() {
("".to_string(), Vec::<String>::new())
} else {
let file_write_policy = format!(
"(allow file-write*\n{}\n)",
writable_folder_policies.join(" ")
);
(file_write_policy, cli_args)
}
}
};
let file_read_policy = if sandbox_policy.has_full_disk_read_access() {
"; allow read-only file operations\n(allow file-read*)"
} else {
""
};
// TODO(mbolin): apply_patch calls must also honor the SandboxPolicy.
let network_policy = if sandbox_policy.has_full_network_access() {
"(allow network-outbound)\n(allow network-inbound)\n(allow system-socket)"
} else {
""
};
let full_policy = format!(
"{MACOS_SEATBELT_BASE_POLICY}\n{file_read_policy}\n{file_write_policy}\n{network_policy}"
);
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(),
full_policy,
];
seatbelt_command.extend(cli_args);
seatbelt_command.extend(extra_cli_args);
seatbelt_command.push("--".to_string());
seatbelt_command.extend(command);
seatbelt_command

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;
@@ -28,4 +28,4 @@ mod approval_mode_cli_arg;
#[cfg(feature = "cli")]
pub use approval_mode_cli_arg::ApprovalModeCliArg;
#[cfg(feature = "cli")]
pub use approval_mode_cli_arg::SandboxModeCliArg;
pub use approval_mode_cli_arg::SandboxPermissionOption;

View File

@@ -32,14 +32,13 @@ use tokio::sync::Notify;
pub async fn exec_linux(
params: ExecParams,
writable_roots: &[PathBuf],
ctrl_c: Arc<Notify>,
sandbox_policy: SandboxPolicy,
sandbox_policy: &SandboxPolicy,
) -> Result<RawExecToolCallOutput> {
// Allow READ on /
// Allow WRITE on /dev/null
let ctrl_c_copy = ctrl_c.clone();
let writable_roots_copy = writable_roots.to_vec();
let sandbox_policy = sandbox_policy.clone();
// Isolate thread to run the sandbox from
let tool_call_output = std::thread::spawn(move || {
@@ -49,14 +48,7 @@ pub async fn exec_linux(
.expect("Failed to create runtime");
rt.block_on(async {
if sandbox_policy.is_network_restricted() {
install_network_seccomp_filter_on_current_thread()?;
}
if sandbox_policy.is_file_write_restricted() {
install_filesystem_landlock_rules_on_current_thread(writable_roots_copy)?;
}
apply_sandbox_policy_to_current_thread(sandbox_policy)?;
exec(params, ctrl_c_copy).await
})
})
@@ -72,6 +64,30 @@ pub async fn exec_linux(
}
}
/// Apply sandbox policies inside this thread so only the child inherits
/// them, not the entire CLI process.
pub fn apply_sandbox_policy_to_current_thread(sandbox_policy: SandboxPolicy) -> Result<()> {
if !sandbox_policy.has_full_network_access() {
install_network_seccomp_filter_on_current_thread()?;
}
if !sandbox_policy.has_full_disk_write_access() {
let writable_roots = sandbox_policy.get_writable_roots();
install_filesystem_landlock_rules_on_current_thread(writable_roots)?;
}
// TODO(ragona): Add appropriate restrictions if
// `sandbox_policy.has_full_disk_read_access()` is `false`.
Ok(())
}
/// 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.
fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathBuf>) -> Result<()> {
let abi = ABI::V5;
let access_rw = AccessFs::from_all(abi);
@@ -98,6 +114,8 @@ fn install_filesystem_landlock_rules_on_current_thread(writable_roots: Vec<PathB
Ok(())
}
/// Installs a seccomp filter that blocks outbound network access except for
/// AF_UNIX domain sockets.
fn install_network_seccomp_filter_on_current_thread() -> std::result::Result<(), SandboxErr> {
// Build rule map.
let mut rules: BTreeMap<i64, Vec<SeccompRule>> = BTreeMap::new();
@@ -174,15 +192,14 @@ mod tests_linux {
workdir: None,
timeout_ms: Some(timeout_ms),
};
let res = process_exec_tool_call(
params,
SandboxType::LinuxSeccomp,
writable_roots,
Arc::new(Notify::new()),
SandboxPolicy::NetworkAndFileWriteRestricted,
)
.await
.unwrap();
let sandbox_policy =
SandboxPolicy::new_read_only_policy_with_writable_roots(writable_roots);
let ctrl_c = Arc::new(Notify::new());
let res =
process_exec_tool_call(params, SandboxType::LinuxSeccomp, ctrl_c, &sandbox_policy)
.await
.unwrap();
if res.exit_code != 0 {
println!("stdout:\n{}", res.stdout);
@@ -225,7 +242,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;
}
@@ -249,14 +268,11 @@ mod tests_linux {
timeout_ms: Some(2_000),
};
let result = process_exec_tool_call(
params,
SandboxType::LinuxSeccomp,
&[],
Arc::new(Notify::new()),
SandboxPolicy::NetworkRestricted,
)
.await;
let sandbox_policy = SandboxPolicy::new_read_only_policy();
let ctrl_c = Arc::new(Notify::new());
let result =
process_exec_tool_call(params, SandboxType::LinuxSeccomp, ctrl_c, &sandbox_policy)
.await;
let (exit_code, stdout, stderr) = match result {
Ok(output) => (output.exit_code, output.stdout, output.stderr),

View File

@@ -93,44 +93,159 @@ pub enum AskForApproval {
}
/// Determines execution restrictions for model shell commands
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
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,
pub struct SandboxPolicy {
permissions: Vec<SandboxPermission>,
}
impl From<Vec<SandboxPermission>> for SandboxPolicy {
fn from(permissions: Vec<SandboxPermission>) -> Self {
Self { permissions }
}
}
impl SandboxPolicy {
pub fn is_dangerous(&self) -> bool {
match self {
SandboxPolicy::NetworkRestricted => false,
SandboxPolicy::FileWriteRestricted => false,
SandboxPolicy::NetworkAndFileWriteRestricted => false,
SandboxPolicy::DangerousNoRestrictions => true,
pub fn new_read_only_policy() -> Self {
Self {
permissions: vec![SandboxPermission::DiskFullReadAccess],
}
}
pub fn is_network_restricted(&self) -> bool {
matches!(
self,
SandboxPolicy::NetworkRestricted | SandboxPolicy::NetworkAndFileWriteRestricted
)
pub fn new_read_only_policy_with_writable_roots(writable_roots: &[PathBuf]) -> Self {
let mut permissions = Self::new_read_only_policy().permissions;
permissions.extend(writable_roots.iter().map(|folder| {
SandboxPermission::DiskWriteFolder {
folder: folder.clone(),
}
}));
Self { permissions }
}
pub fn is_file_write_restricted(&self) -> bool {
matches!(
self,
SandboxPolicy::FileWriteRestricted | SandboxPolicy::NetworkAndFileWriteRestricted
)
pub fn new_full_auto_policy() -> Self {
Self {
permissions: vec![
SandboxPermission::DiskFullReadAccess,
SandboxPermission::DiskWritePlatformUserTempFolder,
SandboxPermission::DiskWriteCwd,
],
}
}
pub fn has_full_disk_read_access(&self) -> bool {
self.permissions
.iter()
.any(|perm| matches!(perm, SandboxPermission::DiskFullReadAccess))
}
pub fn has_full_disk_write_access(&self) -> bool {
self.permissions
.iter()
.any(|perm| matches!(perm, SandboxPermission::DiskFullWriteAccess))
}
pub fn has_full_network_access(&self) -> bool {
self.permissions
.iter()
.any(|perm| matches!(perm, SandboxPermission::NetworkFullAccess))
}
pub fn get_writable_roots(&self) -> Vec<PathBuf> {
let mut writable_roots = Vec::<PathBuf>::new();
for perm in &self.permissions {
use SandboxPermission::*;
match perm {
DiskWritePlatformUserTempFolder => {
if cfg!(target_os = "macos") {
if let Some(tempdir) = std::env::var_os("TMPDIR") {
// Likely something that starts with /var/folders/...
let tmpdir_path = PathBuf::from(&tempdir);
if tmpdir_path.is_absolute() {
writable_roots.push(tmpdir_path.clone());
match tmpdir_path.canonicalize() {
Ok(canonicalized) => {
// Likely something that starts with /private/var/folders/...
if canonicalized != tmpdir_path {
writable_roots.push(canonicalized);
}
}
Err(e) => {
tracing::error!("Failed to canonicalize TMPDIR: {e}");
}
}
} else {
tracing::error!("TMPDIR is not an absolute path: {tempdir:?}");
}
}
}
// For Linux, should this be XDG_RUNTIME_DIR, /run/user/<uid>, or something else?
}
DiskWritePlatformGlobalTempFolder => {
if cfg!(unix) {
writable_roots.push(PathBuf::from("/tmp"));
}
}
DiskWriteCwd => match std::env::current_dir() {
Ok(cwd) => writable_roots.push(cwd),
Err(err) => {
tracing::error!("Failed to get current working directory: {err}");
}
},
DiskWriteFolder { folder } => {
writable_roots.push(folder.clone());
}
DiskFullReadAccess | NetworkFullAccess => {}
DiskFullWriteAccess => {
// Currently, we expect callers to only invoke this method
// after verifying has_full_disk_write_access() is false.
}
}
}
writable_roots
}
pub fn is_unrestricted(&self) -> bool {
self.has_full_disk_read_access()
&& self.has_full_disk_write_access()
&& self.has_full_network_access()
}
}
/// Permissions that should be granted to the sandbox in which the agent
/// operates.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SandboxPermission {
/// Is allowed to read all files on disk.
DiskFullReadAccess,
/// Is allowed to write to the operating system's temp dir that
/// is restricted to the user the agent is running as. For
/// example, on macOS, this is generally something under
/// `/var/folders` as opposed to `/tmp`.
DiskWritePlatformUserTempFolder,
/// Is allowed to write to the operating system's shared temp
/// dir. On UNIX, this is generally `/tmp`.
DiskWritePlatformGlobalTempFolder,
/// Is allowed to write to the current working directory (in practice, this
/// is the `cwd` where `codex` was spawned).
DiskWriteCwd,
/// Is allowed to the specified folder. `PathBuf` must be an
/// absolute path, though it is up to the caller to canonicalize
/// it if the path contains symlinks.
DiskWriteFolder { folder: PathBuf },
/// Is allowed to write to any file on disk.
DiskFullWriteAccess,
/// Can make arbitrary network requests.
NetworkFullAccess,
}
/// User input
#[non_exhaustive]
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -65,7 +65,7 @@ pub fn assess_patch_safety(
pub fn assess_command_safety(
command: &[String],
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
sandbox_policy: &SandboxPolicy,
approved: &HashSet<Vec<String>>,
) -> SafetyCheck {
let approve_without_sandbox = || SafetyCheck::AutoApprove {
@@ -81,11 +81,10 @@ pub fn assess_command_safety(
}
// Command was not known-safe or allow-listed
match sandbox_policy {
// Only the dangerous sandbox policy will run arbitrary commands outside a sandbox
SandboxPolicy::DangerousNoRestrictions => approve_without_sandbox(),
// All other policies try to run the command in a sandbox if it is available
_ => match get_platform_sandbox() {
if sandbox_policy.is_unrestricted() {
approve_without_sandbox()
} else {
match get_platform_sandbox() {
// We have a sandbox, so we can approve the command in all modes
Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type },
None => {
@@ -99,7 +98,7 @@ pub fn assess_command_safety(
_ => SafetyCheck::AskUser,
}
}
},
}
}
}

View File

@@ -6,9 +6,6 @@
; start with closed-by-default
(deny default)
; allow read-only file operations
(allow file-read*)
; child processes inherit the policy of their parent
(allow process-exec)
(allow process-fork)

View File

@@ -55,7 +55,7 @@ async fn spawn_codex() -> Codex {
model: config.model,
instructions: None,
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
},
})

View File

@@ -95,7 +95,7 @@ async fn keeps_previous_response_id_between_tasks() {
model: config.model,
instructions: None,
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
},
})

View File

@@ -78,7 +78,7 @@ async fn retries_on_early_close() {
model: config.model,
instructions: None,
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
},
})

View File

@@ -1,6 +1,6 @@
[package]
name = "codex-exec"
version = "0.1.0"
version = { workspace = true }
edition = "2021"
[[bin]]
@@ -13,8 +13,11 @@ path = "src/lib.rs"
[dependencies]
anyhow = "1"
chrono = "0.4.40"
clap = { version = "4", features = ["derive"] }
codex-core = { path = "../core", features = ["cli"] }
owo-colors = "4.2.0"
shlex = "1.3.0"
tokio = { version = "1", features = [
"io-std",
"macros",

View File

@@ -1,5 +1,6 @@
use clap::Parser;
use codex_core::SandboxModeCliArg;
use clap::ValueEnum;
use codex_core::SandboxPermissionOption;
use std::path::PathBuf;
#[derive(Parser, Debug)]
@@ -13,11 +14,12 @@ 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>,
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
@@ -27,6 +29,19 @@ pub struct Cli {
#[arg(long = "disable-response-storage", default_value_t = false)]
pub disable_response_storage: bool,
/// Specifies color settings for use in the output.
#[arg(long = "color", value_enum, default_value_t = Color::Auto)]
pub color: Color,
/// Initial instructions for the agent.
pub prompt: Option<String>,
pub prompt: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum Color {
Always,
Never,
#[default]
Auto,
}

View File

@@ -0,0 +1,307 @@
use chrono::Utc;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::FileChange;
use owo_colors::OwoColorize;
use owo_colors::Style;
use shlex::try_join;
use std::collections::HashMap;
/// This should be configurable. When used in CI, users may not want to impose
/// a limit so they can see the full transcript.
const MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL: usize = 20;
pub(crate) struct EventProcessor {
call_id_to_command: HashMap<String, ExecCommandBegin>,
call_id_to_patch: HashMap<String, PatchApplyBegin>,
// To ensure that --color=never is respected, ANSI escapes _must_ be added
// using .style() with one of these fields. If you need a new style, add a
// new field here.
bold: Style,
dimmed: Style,
magenta: Style,
red: Style,
green: Style,
}
impl EventProcessor {
pub(crate) fn create_with_ansi(with_ansi: bool) -> Self {
let call_id_to_command = HashMap::new();
let call_id_to_patch = HashMap::new();
if with_ansi {
Self {
call_id_to_command,
call_id_to_patch,
bold: Style::new().bold(),
dimmed: Style::new().dimmed(),
magenta: Style::new().magenta(),
red: Style::new().red(),
green: Style::new().green(),
}
} else {
Self {
call_id_to_command,
call_id_to_patch,
bold: Style::new(),
dimmed: Style::new(),
magenta: Style::new(),
red: Style::new(),
green: Style::new(),
}
}
}
}
struct ExecCommandBegin {
command: Vec<String>,
start_time: chrono::DateTime<Utc>,
}
struct PatchApplyBegin {
start_time: chrono::DateTime<Utc>,
auto_approved: bool,
}
macro_rules! ts_println {
($($arg:tt)*) => {{
let now = Utc::now();
let formatted = now.format("%Y-%m-%dT%H:%M:%S").to_string();
print!("[{}] ", formatted);
println!($($arg)*);
}};
}
impl EventProcessor {
pub(crate) fn process_event(&mut self, event: Event) {
let Event { id, msg } = event;
match msg {
EventMsg::Error { message } => {
let prefix = "ERROR:".style(self.red);
ts_println!("{prefix} {message}");
}
EventMsg::BackgroundEvent { message } => {
ts_println!("{}", message.style(self.dimmed));
}
EventMsg::TaskStarted => {
let msg = format!("Task started: {id}");
ts_println!("{}", msg.style(self.dimmed));
}
EventMsg::TaskComplete => {
let msg = format!("Task complete: {id}");
ts_println!("{}", msg.style(self.bold));
}
EventMsg::AgentMessage { message } => {
let prefix = "Agent message:".style(self.bold);
ts_println!("{prefix} {message}");
}
EventMsg::ExecCommandBegin {
call_id,
command,
cwd,
} => {
self.call_id_to_command.insert(
call_id.clone(),
ExecCommandBegin {
command: command.clone(),
start_time: Utc::now(),
},
);
ts_println!(
"{} {} in {}",
"exec".style(self.magenta),
escape_command(&command).style(self.bold),
cwd,
);
}
EventMsg::ExecCommandEnd {
call_id,
stdout,
stderr,
exit_code,
} => {
let exec_command = self.call_id_to_command.remove(&call_id);
let (duration, call) = if let Some(ExecCommandBegin {
command,
start_time,
}) = exec_command
{
(
format_duration(start_time),
format!("{}", escape_command(&command).style(self.bold)),
)
} else {
("".to_string(), format!("exec('{call_id}')"))
};
let output = if exit_code == 0 { stdout } else { stderr };
let truncated_output = output
.lines()
.take(MAX_OUTPUT_LINES_FOR_EXEC_TOOL_CALL)
.collect::<Vec<_>>()
.join("\n");
match exit_code {
0 => {
let title = format!("{call} succeded{duration}:");
ts_println!("{}", title.style(self.green));
}
_ => {
let title = format!("{call} exited {exit_code}{duration}:");
ts_println!("{}", title.style(self.red));
}
}
println!("{}", truncated_output.style(self.dimmed));
}
EventMsg::PatchApplyBegin {
call_id,
auto_approved,
changes,
} => {
// Store metadata so we can calculate duration later when we
// receive the corresponding PatchApplyEnd event.
self.call_id_to_patch.insert(
call_id.clone(),
PatchApplyBegin {
start_time: Utc::now(),
auto_approved,
},
);
ts_println!(
"{} auto_approved={}:",
"apply_patch".style(self.magenta),
auto_approved,
);
// Pretty-print the patch summary with colored diff markers so
// its easy to scan in the terminal output.
for (path, change) in changes.iter() {
match change {
FileChange::Add { content } => {
let header = format!(
"{} {}",
format_file_change(change),
path.to_string_lossy()
);
println!("{}", header.style(self.magenta));
for line in content.lines() {
println!("{}", line.style(self.green));
}
}
FileChange::Delete => {
let header = format!(
"{} {}",
format_file_change(change),
path.to_string_lossy()
);
println!("{}", header.style(self.magenta));
}
FileChange::Update {
unified_diff,
move_path,
} => {
let header = if let Some(dest) = move_path {
format!(
"{} {} -> {}",
format_file_change(change),
path.to_string_lossy(),
dest.to_string_lossy()
)
} else {
format!("{} {}", format_file_change(change), path.to_string_lossy())
};
println!("{}", header.style(self.magenta));
// Colorize diff lines. We keep file header lines
// (--- / +++) without extra coloring so they are
// still readable.
for diff_line in unified_diff.lines() {
if diff_line.starts_with('+') && !diff_line.starts_with("+++") {
println!("{}", diff_line.style(self.green));
} else if diff_line.starts_with('-')
&& !diff_line.starts_with("---")
{
println!("{}", diff_line.style(self.red));
} else {
println!("{diff_line}");
}
}
}
}
}
}
EventMsg::PatchApplyEnd {
call_id,
stdout,
stderr,
success,
} => {
let patch_begin = self.call_id_to_patch.remove(&call_id);
// Compute duration and summary label similar to exec commands.
let (duration, label) = if let Some(PatchApplyBegin {
start_time,
auto_approved,
}) = patch_begin
{
(
format_duration(start_time),
format!("apply_patch(auto_approved={})", auto_approved),
)
} else {
(String::new(), format!("apply_patch('{call_id}')"))
};
let (exit_code, output, title_style) = if success {
(0, stdout, self.green)
} else {
(1, stderr, self.red)
};
let title = format!("{label} exited {exit_code}{duration}:");
ts_println!("{}", title.style(title_style));
for line in output.lines() {
println!("{}", line.style(self.dimmed));
}
}
EventMsg::ExecApprovalRequest { .. } => {
// Should we exit?
}
EventMsg::ApplyPatchApprovalRequest { .. } => {
// Should we exit?
}
_ => {
// Ignore event.
}
}
}
}
fn escape_command(command: &[String]) -> String {
try_join(command.iter().map(|s| s.as_str())).unwrap_or_else(|_| command.join(" "))
}
fn format_file_change(change: &FileChange) -> &'static str {
match change {
FileChange::Add { .. } => "A",
FileChange::Delete => "D",
FileChange::Update {
move_path: Some(_), ..
} => "R",
FileChange::Update {
move_path: None, ..
} => "M",
}
}
fn format_duration(start_time: chrono::DateTime<Utc>) -> String {
let elapsed = Utc::now().signed_duration_since(start_time);
let millis = elapsed.num_milliseconds();
if millis < 1000 {
format!(" in {}ms", millis)
} else {
format!(" in {:.2}s", millis as f64 / 1000.0)
}
}

View File

@@ -1,4 +1,7 @@
mod cli;
mod event_processor;
use std::io::IsTerminal;
use std::sync::Arc;
pub use cli::Cli;
@@ -8,58 +11,79 @@ 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 event_processor::EventProcessor;
use owo_colors::OwoColorize;
use owo_colors::Style;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
let Cli {
images,
model,
full_auto,
sandbox,
skip_git_repo_check,
disable_response_storage,
color,
prompt,
} = cli;
let (stdout_with_ansi, stderr_with_ansi) = match color {
cli::Color::Always => (true, true),
cli::Color::Never => (false, false),
cli::Color::Auto => (
std::io::stdout().is_terminal(),
std::io::stderr().is_terminal(),
),
};
assert_api_key(stderr_with_ansi);
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);
}
// TODO(mbolin): Take a more thoughtful approach to logging.
let default_level = "error";
let allow_ansi = true;
let _ = tracing_subscriber::fmt()
.with_env_filter(
EnvFilter::try_from_default_env()
.or_else(|_| EnvFilter::try_new(default_level))
.unwrap(),
)
.with_ansi(allow_ansi)
.with_ansi(stderr_with_ansi)
.with_writer(std::io::stderr)
.try_init();
let Cli {
images,
model,
sandbox_policy,
skip_git_repo_check,
disable_response_storage,
prompt,
..
} = cli;
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);
} else if images.is_empty() && prompt.is_none() {
eprintln!("No images or prompt specified.");
std::process::exit(1);
}
let sandbox_policy = if full_auto {
Some(SandboxPolicy::new_full_auto_policy())
} else {
sandbox.permissions.clone().map(Into::into)
};
// Load configuration and determine approval policy
let overrides = ConfigOverrides {
model: model.clone(),
model,
// 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),
sandbox_policy,
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:?}");
@@ -85,7 +109,6 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
res = codex.next_event() => match res {
Ok(event) => {
debug!("Received event: {event:?}");
process_event(&event);
if let Err(e) = tx.send(event) {
error!("Error sending event: {e:?}");
break;
@@ -101,8 +124,8 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
});
}
// Send images first, if any.
if !images.is_empty() {
// Send images first.
let items: Vec<InputItem> = images
.into_iter()
.map(|path| InputItem::LocalImage { path })
@@ -116,101 +139,56 @@ pub async fn run_main(cli: Cli) -> anyhow::Result<()> {
}
}
if let Some(prompt) = prompt {
// Send the prompt.
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
let initial_prompt_task_id = codex.submit(Op::UserInput { items }).await?;
info!("Sent prompt with event ID: {initial_prompt_task_id}");
while let Some(event) = rx.recv().await {
if event.id == initial_prompt_task_id && matches!(event.msg, EventMsg::TaskComplete) {
break;
}
// Send the prompt.
let items: Vec<InputItem> = vec![InputItem::Text { text: prompt }];
let initial_prompt_task_id = codex.submit(Op::UserInput { items }).await?;
info!("Sent prompt with event ID: {initial_prompt_task_id}");
// Run the loop until the task is complete.
let mut event_processor = EventProcessor::create_with_ansi(stdout_with_ansi);
while let Some(event) = rx.recv().await {
let last_event =
event.id == initial_prompt_task_id && matches!(event.msg, EventMsg::TaskComplete);
event_processor.process_event(event);
if last_event {
break;
}
}
Ok(())
}
fn process_event(event: &Event) {
let Event { id, msg } = event;
match msg {
EventMsg::Error { message } => {
println!("Error: {message}");
}
EventMsg::BackgroundEvent { .. } => {
// Ignore these for now.
}
EventMsg::TaskStarted => {
println!("Task started: {id}");
}
EventMsg::TaskComplete => {
println!("Task complete: {id}");
}
EventMsg::AgentMessage { message } => {
println!("Agent message: {message}");
}
EventMsg::ExecCommandBegin {
call_id,
command,
cwd,
} => {
println!("exec('{call_id}'): {:?} in {cwd}", command);
}
EventMsg::ExecCommandEnd {
call_id,
stdout,
stderr,
exit_code,
} => {
let output = if *exit_code == 0 { stdout } else { stderr };
let truncated_output = output.lines().take(5).collect::<Vec<_>>().join("\n");
println!("exec('{call_id}') exited {exit_code}:\n{truncated_output}");
}
EventMsg::PatchApplyBegin {
call_id,
auto_approved,
changes,
} => {
let changes = changes
.iter()
.map(|(path, change)| {
format!("{} {}", format_file_change(change), path.to_string_lossy())
})
.collect::<Vec<_>>()
.join("\n");
println!("apply_patch('{call_id}') auto_approved={auto_approved}:\n{changes}");
}
EventMsg::PatchApplyEnd {
call_id,
stdout,
stderr,
success,
} => {
let (exit_code, output) = if *success { (0, stdout) } else { (1, stderr) };
let truncated_output = output.lines().take(5).collect::<Vec<_>>().join("\n");
println!("apply_patch('{call_id}') exited {exit_code}:\n{truncated_output}");
}
EventMsg::ExecApprovalRequest { .. } => {
// Should we exit?
}
EventMsg::ApplyPatchApprovalRequest { .. } => {
// Should we exit?
}
_ => {
// Ignore event.
}
/// If a valid API key is not present in the environment, print an error to
/// stderr and exits with 1; otherwise, does nothing.
fn assert_api_key(stderr_with_ansi: bool) {
if !has_api_key() {
let (msg_style, var_style, url_style) = if stderr_with_ansi {
(
Style::new().red(),
Style::new().bold(),
Style::new().bold().underline(),
)
} else {
(Style::new(), Style::new(), Style::new())
};
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.".style(msg_style),
var = "OPENAI_API_KEY".style(var_style),
url = "https://platform.openai.com/account/api-keys".style(url_style),
);
std::process::exit(1);
}
}
fn format_file_change(change: &FileChange) -> &'static str {
match change {
FileChange::Add { .. } => "A",
FileChange::Delete => "D",
FileChange::Update {
move_path: Some(_), ..
} => "R",
FileChange::Update {
move_path: None, ..
} => "M",
}
/// Returns `true` if a recognized API key is present in the environment.
///
/// At present we only support `OPENAI_API_KEY`, mirroring the behavior 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)
}

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

@@ -1,6 +1,6 @@
[package]
name = "codex-repl"
version = "0.1.0"
version = { workspace = true }
edition = "2021"
[[bin]]

View File

@@ -1,7 +1,7 @@
use clap::ArgAction;
use clap::Parser;
use codex_core::ApprovalModeCliArg;
use codex_core::SandboxModeCliArg;
use codex_core::SandboxPermissionOption;
use std::path::PathBuf;
/// Commandline arguments.
@@ -37,11 +37,12 @@ pub struct Cli {
#[arg(long = "ask-for-approval", short = 'a')]
pub approval_policy: Option<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>,
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Allow running Codex outside a Git repository. By default the CLI
/// aborts early when the current working directory is **not** inside a

View File

@@ -6,7 +6,9 @@ use std::sync::Arc;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::FileChange;
use codex_core::protocol::SandboxPolicy;
use codex_core::util::is_inside_git_repo;
use codex_core::util::notify_on_sigint;
use codex_core::Codex;
@@ -76,11 +78,26 @@ 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);
let (sandbox_policy, approval_policy) = if cli.full_auto {
(
Some(SandboxPolicy::new_full_auto_policy()),
Some(AskForApproval::OnFailure),
)
} else {
let sandbox_policy = cli.sandbox.permissions.clone().map(Into::into);
(sandbox_policy, cli.approval_policy.map(Into::into))
};
// 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),
approval_policy,
sandbox_policy,
disable_response_storage: if cli.disable_response_storage {
Some(true)
} else {
None
},
};
let config = Config::load_with_overrides(overrides)?;
@@ -104,7 +121,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

@@ -1,6 +1,6 @@
use clap::Parser;
use codex_core::ApprovalModeCliArg;
use codex_core::SandboxModeCliArg;
use codex_core::SandboxPermissionOption;
use std::path::PathBuf;
#[derive(Parser, Debug)]
@@ -21,11 +21,12 @@ pub struct Cli {
#[arg(long = "ask-for-approval", short = 'a')]
pub approval_policy: Option<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>,
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(flatten)]
pub sandbox: SandboxPermissionOption,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
@@ -34,12 +35,4 @@ pub struct Cli {
/// Disable serverside response storage (sends the full conversation context with every request)
#[arg(long = "disable-response-storage", default_value_t = false)]
pub disable_response_storage: bool,
/// Convenience alias for low-friction sandboxed automatic execution (-a on-failure, -s network-and-file-write-restricted)
#[arg(long = "full-auto", default_value_t = true)]
pub full_auto: bool,
/// Convenience alias for supervised sandboxed execution (-a unless-allow-listed, -s network-and-file-write-restricted)
#[arg(long = "suggest", default_value_t = false)]
pub suggest: bool,
}

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

@@ -6,6 +6,8 @@
use app::App;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::SandboxPolicy;
use codex_core::util::is_inside_git_repo;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
@@ -33,12 +35,27 @@ pub use cli::Cli;
pub fn run_main(cli: Cli) -> std::io::Result<()> {
assert_env_var_set();
let (sandbox_policy, approval_policy) = if cli.full_auto {
(
Some(SandboxPolicy::new_full_auto_policy()),
Some(AskForApproval::OnFailure),
)
} else {
let sandbox_policy = cli.sandbox.permissions.clone().map(Into::into);
(sandbox_policy, cli.approval_policy.map(Into::into))
};
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),
approval_policy,
sandbox_policy,
disable_response_storage: if cli.disable_response_storage {
Some(true)
} else {
None
},
};
#[allow(clippy::print_stderr)]
match Config::load_with_overrides(overrides) {
@@ -134,19 +151,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.
{