Compare commits

...

33 Commits
pr675 ... pr752

Author SHA1 Message Date
Michael Bolin
8e6504b9e7 chore: create a script for the release process 2025-04-30 08:47:59 -07:00
Michael Bolin
f2ed46ceca fix: include x86_64-unknown-linux-gnu in the list of arch to build codex-linux-sandbox (#748) 2025-04-29 21:19:14 -07:00
Michael Bolin
e42dacbdc8 fix: add another place where $dest was missing in rust-release.yml (#747)
I thought https://github.com/openai/codex/pull/745 was the last fix I
needed, but apparently not.
2025-04-29 20:23:54 -07:00
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
Thibault Sottiaux
fa5fa8effc fix: only allow running without sandbox if explicitly marked in safe container (#699)
Signed-off-by: Thibault Sottiaux <tibo@openai.com>
2025-04-28 07:48:38 -07:00
Michael Bolin
4eda4dd772 feat: load defaults into Config and introduce ConfigOverrides (#677)
This changes how instantiating `Config` works and also adds
`approval_policy` and `sandbox_policy` as fields. The idea is:

* All fields of `Config` have appropriate default values.
* `Config` is initially loaded from `~/.codex/config.toml`, so values in
`config.toml` will override those defaults.
* Clients must instantiate `Config` via
`Config::load_with_overrides(ConfigOverrides)` where `ConfigOverrides`
has optional overrides that are expected to be settable based on CLI
flags.

The `Config` should be defined early in the program and then passed
down. Now functions like `init_codex()` take fewer individual parameters
because they can just take a `Config`.

Also, `Config::load()` used to fail silently if `~/.codex/config.toml`
had a parse error and fell back to the default config. This seemed
really bad because it wasn't clear why the values in my `config.toml`
weren't getting picked up. I changed things so that
`load_with_overrides()` returns `Result<Config>` and verified that the
various CLIs print a reasonable error if `config.toml` is malformed.

Finally, I also updated the TUI to show which **sandbox** value is being
used, as we do for other key values like **model** and **approval**.
This was also a reminder that the various values of `--sandbox` are
honored on Linux but not macOS today, so I added some TODOs about fixing
that.
2025-04-27 21:47:50 -07:00
Thibault Sottiaux
e9d16d3c2b fix: check if sandbox-exec is available (#696)
- Introduce `isSandboxExecAvailable()` helper and tidy import ordering
in `handle-exec-command.ts`.
- Add runtime check for the `sandbox-exec` binary on macOS; fall back to
`SandboxType.NONE` with a warning if it’s missing, preventing crashes.

---------

Signed-off-by: Thibault Sottiaux <tibo@openai.com>
Co-authored-by: Fouad Matin <fouad@openai.com>
2025-04-27 17:04:47 -07:00
Fouad Matin
523996b5cb fix: /diff should include untracked files (#686) 2025-04-26 12:43:51 -07:00
Tomas Cupr
bc500d3009 feat: user config api key (#569)
Adds support for reading OPENAI_API_KEY (and other variables) from a
user‑wide dotenv file (~/.codex.config). Precedence order is now:
  1. explicit environment variable
  2. project‑local .env (loaded earlier)
  3. ~/.codex.config

Also adds a regression test that ensures the multiline editor correctly
handles cases where printable text and the CSI‑u Shift+Enter sequence
arrive in the same input chunk.

House‑kept with Prettier; removed stray temp.json artifact.
2025-04-26 10:13:30 -07:00
moppywhip
9b0ccf9aeb fix: duplicate messages in quiet mode (#680)
Addressing #600 and #664 (partially)

## Bug
Codex was staging duplicate items in output running when the same
response item appeared in both the streaming events. Specifically:

1. Items would be staged once when received as a
`response.output_item.done` event
2. The same items would be staged again when included in the final
`response.completed` payload

This duplication would result in each message being sent several times
in the quiet mode output.

## Changes
- Added a Set (`alreadyStagedItemIds`) to track items that have already
been staged
- Modified the `stageItem` function to check if an item's ID is already
in this set before staging it
- Added a regression test (`agent-dedupe-items.test.ts`) that verifies
items with the same ID are only staged once

## Testing
Like other tests, the included test creates a mock OpenAI stream that
emits the same message twice (once as an incremental event and once in
the final response) and verifies the item is only passed to `onItem`
once.
2025-04-26 09:14:50 -07:00
Michael Bolin
b0ba65a936 fix: write logs to ~/.codex/log instead of /tmp (#669)
Previously, the Rust TUI was writing log files to `/tmp`, which is
world-readable and not available on Windows, so that isn't great.

This PR tries to clean things up by adding a function that provides the
path to the "Codex config dir," e.g., `~/.codex` (though I suppose we
could support `$CODEX_HOME` to override this?) and then defines other
paths in terms of the result of `codex_dir()`.

For example, `log_dir()` returns the folder where log files should be
written which is defined in terms of `codex_dir()`. I updated the TUI to
use this function. On UNIX, we even go so far as to `chmod 600` the log
file by default, though as noted in a comment, it's a bit tedious to do
the equivalent on Windows, so we just let that go for now.

This also changes the default logging level to `info` for `codex_core`
and `codex_tui` when `RUST_LOG` is not specified. I'm not really sure if
we should use a more verbose default (it may be helpful when debugging
user issues), though if so, we should probably also set up log rotation?
2025-04-25 17:37:41 -07:00
Fouad Matin
103093f793 bump(version): 0.1.2504251709 (#660)
## `0.1.2504251709`

### 🚀 Features

- Add openai model info configuration (#551)
- Added provider to run quiet mode function (#571)
- Create parent directories when creating new files (#552)
- Print bug report URL in terminal instead of opening browser (#510)
(#528)
- Add support for custom provider configuration in the user config
(#537)
- Add support for OpenAI-Organization and OpenAI-Project headers (#626)
- Add specific instructions for creating API keys in error msg (#581)
- Enhance toCodePoints to prevent potential unicode 14 errors (#615)
- More native keyboard navigation in multiline editor (#655)
- Display error on selection of invalid model (#594)

### 🪲 Bug Fixes

- Model selection (#643)
- Nits in apply patch (#640)
- Input keyboard shortcuts (#676)
- `apply_patch` unicode characters (#625)
- Don't clear turn input before retries (#611)
- More loosely match context for apply_patch (#610)
- Update bug report template - there is no --revision flag (#614)
- Remove outdated copy of text input and external editor feature (#670)
- Remove unreachable "disableResponseStorage" logic flow introduced in
#543 (#573)
- Non-openai mode - fix for gemini content: null, fix 429 to throw
before stream (#563)
- Only allow going up in history when not already in history if input is
empty (#654)
- Do not grant "node" user sudo access when using run_in_container.sh
(#627)
- Update scripts/build_container.sh to use pnpm instead of npm (#631)
- Update lint-staged config to use pnpm --filter (#582)
- Non-openai mode - don't default temp and top_p (#572)
- Fix error catching when checking for updates (#597)
- Close stdin when running an exec tool call (#636)
2025-04-25 17:15:40 -07:00
Fouad Matin
3f4762d969 fix: input keyboard shortcuts (#676)
Fixes keyboard shortcuts:
- ctrl+a/e
- opt+arrow keys
2025-04-25 16:58:09 -07:00
68 changed files with 2595 additions and 660 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" }
}
}
}
}

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

@@ -0,0 +1,157 @@
# 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 == 'x86_64-unknown-linux-gnu' || 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: |
dest="dist/${{ matrix.target }}"
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,41 @@
You can install any of these versions: `npm install -g codex@version`
## `0.1.2504251709`
### 🚀 Features
- Add openai model info configuration (#551)
- Added provider to run quiet mode function (#571)
- Create parent directories when creating new files (#552)
- Print bug report URL in terminal instead of opening browser (#510) (#528)
- Add support for custom provider configuration in the user config (#537)
- Add support for OpenAI-Organization and OpenAI-Project headers (#626)
- Add specific instructions for creating API keys in error msg (#581)
- Enhance toCodePoints to prevent potential unicode 14 errors (#615)
- More native keyboard navigation in multiline editor (#655)
- Display error on selection of invalid model (#594)
### 🪲 Bug Fixes
- Model selection (#643)
- Nits in apply patch (#640)
- Input keyboard shortcuts (#676)
- `apply_patch` unicode characters (#625)
- Don't clear turn input before retries (#611)
- More loosely match context for apply_patch (#610)
- Update bug report template - there is no --revision flag (#614)
- Remove outdated copy of text input and external editor feature (#670)
- Remove unreachable "disableResponseStorage" logic flow introduced in #543 (#573)
- Non-openai mode - fix for gemini content: null, fix 429 to throw before stream (#563)
- Only allow going up in history when not already in history if input is empty (#654)
- Do not grant "node" user sudo access when using run_in_container.sh (#627)
- Update scripts/build_container.sh to use pnpm instead of npm (#631)
- Update lint-staged config to use pnpm --filter (#582)
- Non-openai mode - don't default temp and top_p (#572)
- Fix error catching when checking for updates (#597)
- Close stdin when running an exec tool call (#636)
## `0.1.2504221401`
### 🚀 Features
@@ -9,7 +44,7 @@ You can install any of these versions: `npm install -g codex@version`
- Show actionable errors when api keys are missing (#523)
- Add CLI `--version` flag (#492)
### 🐛 Bug Fixes
### 🪲 Bug Fixes
- Agent loop for ZDR (`disableResponseStorage`) (#543)
- Fix relative `workdir` check for `apply_patch` (#556)
@@ -40,7 +75,7 @@ You can install any of these versions: `npm install -g codex@version`
- Add /command autocomplete (#317)
- Allow multi-line input (#438)
### 🐛 Bug Fixes
### 🪲 Bug Fixes
- `full-auto` support in quiet mode (#374)
- Enable shell option for child process execution (#391)
@@ -64,7 +99,7 @@ You can install any of these versions: `npm install -g codex@version`
- Add `/bug` report command (#312)
- Notify when a newer version is available (#333)
### 🐛 Bug Fixes
### 🪲 Bug Fixes
- Update context left display logic in TerminalChatInput component (#307)
- Improper spawn of sh on Windows Powershell (#318)
@@ -77,7 +112,7 @@ You can install any of these versions: `npm install -g codex@version`
- Add Nix flake for reproducible development environments (#225)
### 🐛 Bug Fixes
### 🪲 Bug Fixes
- Handle invalid commands (#304)
- Raw-exec-process-group.test improve reliability and error handling (#280)
@@ -96,7 +131,7 @@ You can install any of these versions: `npm install -g codex@version`
- `--config`/`-c` flag to open global instructions in nvim (#158)
- Update position of cursor when navigating input history with arrow keys to the end of the text (#255)
### 🐛 Bug Fixes
### 🪲 Bug Fixes
- Correct word deletion logic for trailing spaces (Ctrl+Backspace) (#131)
- Improve Windows compatibility for CLI commands and sandbox (#261)

View File

@@ -35,7 +35,7 @@ conventional_commits = true
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^fix", group = "<!-- 1 -->🪲 Bug Fixes" },
{ message = "^bump", group = "<!-- 6 -->🛳️ Release" },
# Fallback  skip anything that didn't match the above rules.
{ message = ".*", group = "<!-- 10 -->💼 Other" },

View File

@@ -46,6 +46,10 @@ RUN npm install -g codex.tgz \
&& rm -rf /usr/local/share/npm-global/lib/node_modules/codex-cli/tests \
&& rm -rf /usr/local/share/npm-global/lib/node_modules/codex-cli/docs
# Inside the container we consider the environment already sufficiently locked
# down, therefore instruct Codex CLI to allow running without sandboxing.
ENV CODEX_UNSAFE_ALLOW_NO_SANDBOX=1
# Copy and set up firewall script as root.
USER root
COPY scripts/init_firewall.sh /usr/local/bin/

View File

@@ -1,6 +1,6 @@
{
"name": "@openai/codex",
"version": "0.1.2504221401",
"version": "0.1.2504251709",
"license": "Apache-2.0",
"bin": {
"codex": "bin/codex.js"

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",
@@ -184,6 +191,10 @@ const cli = meow(
},
);
// ---------------------------------------------------------------------------
// Global flag handling
// ---------------------------------------------------------------------------
// Handle 'completion' subcommand before any prompting or API calls
if (cli.input[0] === "completion") {
const shell = cli.input[1] || "bash";
@@ -283,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

@@ -106,11 +106,16 @@ export default function TerminalChatInputThinking({
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text>{frameWithSeconds}</Text>
<Box justifyContent="space-between">
<Box gap={2}>
<Text>{frameWithSeconds}</Text>
<Text>
Thinking
{dots}
</Text>
</Box>
<Text>
Thinking
{dots}
Press <Text bold>Esc</Text> twice to interrupt
</Text>
</Box>
{awaitingConfirm && (

View File

@@ -412,7 +412,7 @@ export default function TerminalChatInput({
setInput("");
openApprovalOverlay();
return;
} else if (inputValue === "exit") {
} else if (["exit", "q", ":q"].includes(inputValue)) {
setInput("");
setTimeout(() => {
app.exit();
@@ -881,20 +881,30 @@ function TerminalChatInputThinking({
);
return (
<Box flexDirection="column" gap={1}>
<Box gap={2}>
<Text>{frameWithSeconds}</Text>
<Box width="100%" flexDirection="column" gap={1}>
<Box
flexDirection="row"
width="100%"
justifyContent="space-between"
paddingRight={1}
>
<Box gap={2}>
<Text>{frameWithSeconds}</Text>
<Text>
Thinking
{dots}
</Text>
</Box>
<Text>
Thinking
{dots}
<Text dimColor>press</Text> <Text bold>Esc</Text>{" "}
{awaitingConfirm ? (
<Text bold>again</Text>
) : (
<Text dimColor>twice</Text>
)}{" "}
<Text dimColor>to interrupt</Text>
</Text>
</Box>
{awaitingConfirm && (
<Text dimColor>
Press <Text bold>Esc</Text> again to interrupt and enter a new
instruction
</Text>
)}
</Box>
);
}

View File

@@ -73,7 +73,7 @@ const TerminalHeader: React.FC<TerminalHeaderProps> = ({
</Text>
<Text dimColor>
<Text color="blueBright"></Text> approval:{" "}
<Text bold color={colorsByPolicy[approvalPolicy]} dimColor>
<Text bold color={colorsByPolicy[approvalPolicy]}>
{approvalPolicy}
</Text>
</Text>

View File

@@ -610,6 +610,24 @@ export default class TextBuffer {
}
}
/* ------------------------------------------------------------------
* Document-level navigation helpers
* ---------------------------------------------------------------- */
/** Move caret to *absolute* beginning of the buffer (row-0, col-0). */
private moveToStartOfDocument(): void {
this.preferredCol = null;
this.cursorRow = 0;
this.cursorCol = 0;
}
/** Move caret to *absolute* end of the buffer (last row, last column). */
private moveToEndOfDocument(): void {
this.preferredCol = null;
this.cursorRow = this.lines.length - 1;
this.cursorCol = this.lineLen(this.cursorRow);
}
/* =====================================================================
* Higherlevel helpers
* =================================================================== */
@@ -780,6 +798,18 @@ export default class TextBuffer {
key["rightArrow"]
) {
this.move("wordRight");
}
// Many terminal/OS combinations (e.g. macOS Terminal.app & iTerm2 with
// the default key-bindings) translate ⌥← / ⌥→ into the classic readline
// shortcuts ESC-b / ESC-f rather than an ANSI arrow sequence that Ink
// would tag with `leftArrow` / `rightArrow`. Ink parses those 2-byte
// escape sequences into `input === "b"|"f"` with `key.meta === true`.
// Handle this variant explicitly so that Option+Arrow performs word
// navigation consistently across environments.
else if (key["meta"] && (input === "b" || input === "B")) {
this.move("wordLeft");
} else if (key["meta"] && (input === "f" || input === "F")) {
this.move("wordRight");
} else if (key["home"]) {
this.move("home");
} else if (key["end"]) {
@@ -823,11 +853,11 @@ export default class TextBuffer {
// Emacs/readline-style shortcuts
else if (key["ctrl"] && (input === "a" || input === "\x01")) {
// Ctrl+A or ⌥← → start of line
this.move("home");
// Ctrl+A → start of input (first row, first column)
this.moveToStartOfDocument();
} else if (key["ctrl"] && (input === "e" || input === "\x05")) {
// Ctrl+E or ⌥→ → end of line
this.move("end");
// Ctrl+E → end of input (last row, last column)
this.moveToEndOfDocument();
} else if (key["ctrl"] && (input === "b" || input === "\x02")) {
// Ctrl+B → char left
this.move("left");

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,
);
@@ -46,6 +46,7 @@ export type CommandConfirmation = {
};
const alreadyProcessedResponses = new Set();
const alreadyStagedItemIds = new Set<string>();
type AgentLoopParams = {
model: string;
@@ -562,6 +563,12 @@ export class AgentLoop {
return;
}
// Skip items we've already processed to avoid staging duplicates
if (item.id && alreadyStagedItemIds.has(item.id)) {
return;
}
alreadyStagedItemIds.add(item.id);
// Store the item so the final flush can still operate on a complete list.
// We'll nil out entries once they're delivered.
const idx = staged.push(item) - 1;
@@ -664,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

@@ -1,17 +1,18 @@
import type { CommandConfirmation } from "./agent-loop.js";
import type { AppConfig } from "../config.js";
import type { ExecInput } from "./sandbox/interface.js";
import type { ApplyPatchCommand, ApprovalPolicy } from "../../approvals.js";
import type { ExecInput } from "./sandbox/interface.js";
import type { ResponseInputItem } from "openai/resources/responses/responses.mjs";
import { exec, execApplyPatch } from "./exec.js";
import { ReviewDecision } from "./review.js";
import { FullAutoErrorMode } from "../auto-approval-mode.js";
import { SandboxType } from "./sandbox/interface.js";
import { canAutoApprove } from "../../approvals.js";
import { formatCommandForDisplay } from "../../format-command.js";
import { FullAutoErrorMode } from "../auto-approval-mode.js";
import { CODEX_UNSAFE_ALLOW_NO_SANDBOX, type AppConfig } from "../config.js";
import { exec, execApplyPatch } from "./exec.js";
import { ReviewDecision } from "./review.js";
import { isLoggingEnabled, log } from "../logger/log.js";
import { access } from "fs/promises";
import { SandboxType } from "./sandbox/interface.js";
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.
@@ -217,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();
@@ -270,30 +271,45 @@ async function execCommand(
};
}
const isInLinux = async (): Promise<boolean> => {
try {
await access("/proc/1/cgroup");
return true;
} catch {
return false;
}
};
/** 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> {
if (runInSandbox) {
if (process.platform === "darwin") {
return SandboxType.MACOS_SEATBELT;
} else if (await isInLinux()) {
return SandboxType.NONE;
} else if (process.platform === "win32") {
// On Windows, we don't have a sandbox implementation yet, so we fall back to NONE
// instead of throwing an error, which would crash the application
log(
"WARNING: Sandbox was requested but is not available on Windows. Continuing without sandbox.",
);
// On macOS we rely on the system-provided `sandbox-exec` binary to
// enforce the Seatbelt profile. However, starting with macOS 14 the
// executable may be removed from the default installation or the user
// might be running the CLI on a stripped-down environment (for
// instance, inside certain CI images). Attempting to spawn a missing
// binary makes Node.js throw an *uncaught* `ENOENT` error further down
// the stack which crashes the whole CLI.
if (await isSandboxExecAvailable) {
return SandboxType.MACOS_SEATBELT;
} else {
throw new Error(
"Sandbox was mandated, but 'sandbox-exec' was not found in PATH!",
);
}
} else if (CODEX_UNSAFE_ALLOW_NO_SANDBOX) {
// Allow running without a sandbox if the user has explicitly marked the
// environment as already being sufficiently locked-down.
return SandboxType.NONE;
}
// For other platforms, still throw an error as before
// For all else, we hard fail if the user has requested a sandbox and none is available.
throw new Error("Sandbox was mandated, but no sandbox is available!");
} else {
return SandboxType.NONE;

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,15 +7,42 @@
// compiled `dist/` output used by the published CLI.
import type { FullAutoErrorMode } from "./auto-approval-mode.js";
import type { ReasoningEffort } from "openai/resources.mjs";
import { AutoApprovalMode } from "./auto-approval-mode.js";
import { log } from "./logger/log.js";
import { providers } from "./providers.js";
import { config as loadDotenv } from "dotenv";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { load as loadYaml, dump as dumpYaml } from "js-yaml";
import { homedir } from "os";
import { dirname, join, extname, resolve as resolvePath } from "path";
// ---------------------------------------------------------------------------
// Userwide environment config (~/.codex.env)
// ---------------------------------------------------------------------------
// Load a userlevel dotenv file **after** process.env and any projectlocal
// .env file (loaded via "dotenv/config" in cli.tsx) are in place. We rely on
// dotenv's default behaviour of *not* overriding existing variables so that
// the precedence order becomes:
// 1. Explicit environment variables
// 2. Projectlocal .env (handled in cli.tsx)
// 3. Userwide ~/.codex.env (loaded here)
// This guarantees that users can still override the global key on a perproject
// basis while enjoying the convenience of a persistent default.
// Skip when running inside Vitest to avoid interfering with the FS mocks used
// by tests that stub out `fs` *after* importing this module.
const USER_WIDE_CONFIG_PATH = join(homedir(), ".codex.env");
const isVitest =
typeof (globalThis as { vitest?: unknown }).vitest !== "undefined";
if (!isVitest) {
loadDotenv({ path: USER_WIDE_CONFIG_PATH });
}
export const DEFAULT_AGENTIC_MODEL = "o4-mini";
export const DEFAULT_FULL_CONTEXT_MODEL = "gpt-4.1";
export const DEFAULT_APPROVAL_MODE = AutoApprovalMode.SUGGEST;
@@ -36,9 +63,17 @@ export const OPENAI_TIMEOUT_MS =
parseInt(process.env["OPENAI_TIMEOUT_MS"] || "0", 10) || undefined;
export const OPENAI_BASE_URL = process.env["OPENAI_BASE_URL"] || "";
export let OPENAI_API_KEY = process.env["OPENAI_API_KEY"] || "";
export const DEFAULT_REASONING_EFFORT = "high";
export const OPENAI_ORGANIZATION = process.env["OPENAI_ORGANIZATION"] || "";
export const OPENAI_PROJECT = process.env["OPENAI_PROJECT"] || "";
// Can be set `true` when Codex is running in an environment that is marked as already
// considered sufficiently locked-down so that we allow running wihtout an explicit sandbox.
export const CODEX_UNSAFE_ALLOW_NO_SANDBOX = Boolean(
process.env["CODEX_UNSAFE_ALLOW_NO_SANDBOX"] || "",
);
export function setApiKey(apiKey: string): void {
OPENAI_API_KEY = apiKey;
}
@@ -110,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
@@ -117,7 +155,7 @@ export type StoredConfig = {
// propagating to existing users until they explicitly set a model.
export const EMPTY_STORED_CONFIG: StoredConfig = { model: "" };
// Prestringified JSON variant so we dont stringify repeatedly.
// Prestringified JSON variant so we don't stringify repeatedly.
const EMPTY_CONFIG_JSON = JSON.stringify(EMPTY_STORED_CONFIG, null, 2) + "\n";
export type MemoryConfig = {
@@ -133,6 +171,7 @@ export type AppConfig = {
approvalMode?: AutoApprovalMode;
fullAutoErrorMode?: FullAutoErrorMode;
memory?: MemoryConfig;
reasoningEffort?: ReasoningEffort;
/** Whether to enable desktop notifications for responses */
notify?: boolean;
@@ -284,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)
@@ -333,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,
};
// -----------------------------------------------------------------------
@@ -448,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

@@ -1,5 +1,26 @@
import { execSync } from "node:child_process";
// The objects thrown by `child_process.execSync()` are `Error` instances that
// include additional, undocumented properties such as `status` (exit code) and
// `stdout` (captured standard output). Declare a minimal interface that captures
// just the fields we need so that we can avoid the use of `any` while keeping
// the checks type-safe.
interface ExecSyncError extends Error {
// Exit status code. When a diff is produced, git exits with code 1 which we
// treat as a non-error signal.
status?: number;
// Captured stdout. We rely on this to obtain the diff output when git exits
// with status 1.
stdout?: string;
}
// Type-guard that narrows an unknown value to `ExecSyncError`.
function isExecSyncError(err: unknown): err is ExecSyncError {
return (
typeof err === "object" && err != null && "status" in err && "stdout" in err
);
}
/**
* Returns the current Git diff for the working directory. If the current
* working directory is not inside a Git repository, `isGitRepo` will be
@@ -15,13 +36,86 @@ export function getGitDiff(): {
execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" });
// If the above call didnt throw, we are inside a git repo. Retrieve the
// diff including color codes so that the overlay can render them.
const output = execSync("git diff --color", {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024, // 10 MB ought to be enough for now
});
// diff for tracked files **and** include any untracked files so that the
// `/diff` overlay shows a complete picture of the working tree state.
return { isGitRepo: true, diff: output };
// 1. Diff for tracked files (unchanged behaviour)
let trackedDiff = "";
try {
trackedDiff = execSync("git diff --color", {
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024, // 10 MB ought to be enough for now
});
} catch (err) {
// Exit status 1 simply means that differences were found. Capture the
// diff from stdout in that case. Re-throw for any other status codes.
if (
isExecSyncError(err) &&
err.status === 1 &&
typeof err.stdout === "string"
) {
trackedDiff = err.stdout;
} else {
throw err;
}
}
// 2. Determine untracked files.
// We use `git ls-files --others --exclude-standard` which outputs paths
// relative to the repository root, one per line. These are files that
// are not tracked *and* are not ignored by .gitignore.
const untrackedOutput = execSync(
"git ls-files --others --exclude-standard",
{
encoding: "utf8",
maxBuffer: 10 * 1024 * 1024,
},
);
const untrackedFiles = untrackedOutput
.split("\n")
.map((p) => p.trim())
.filter(Boolean);
let untrackedDiff = "";
const nullDevice = process.platform === "win32" ? "NUL" : "/dev/null";
for (const file of untrackedFiles) {
try {
// `git diff --no-index` produces a diff even outside the index by
// comparing two paths. We compare the file against /dev/null so that
// the file is treated as "new".
//
// `git diff --color --no-index /dev/null <file>` exits with status 1
// when differences are found, so we capture stdout from the thrown
// error object instead of letting it propagate.
execSync(`git diff --color --no-index -- "${nullDevice}" "${file}"`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
maxBuffer: 10 * 1024 * 1024,
});
} catch (err) {
if (
isExecSyncError(err) &&
// Exit status 1 simply means that the two inputs differ, which is
// exactly what we expect here. Any other status code indicates a
// real error (e.g. the file disappeared between the ls-files and
// diff calls), so re-throw those.
err.status === 1 &&
typeof err.stdout === "string"
) {
untrackedDiff += err.stdout;
} else {
throw err;
}
}
}
// Concatenate tracked and untracked diffs.
const combinedDiff = `${trackedDiff}${untrackedDiff}`;
return { isGitRepo: true, diff: combinedDiff };
} catch {
// Either git is not installed or were not inside a repository.
return { isGitRepo: false, diff: "" };

View File

@@ -1,4 +1,4 @@
export const CLI_VERSION = "0.1.2504221401"; // Must be in sync with package.json.
export const CLI_VERSION = "0.1.2504251709"; // Must be in sync with package.json.
export const ORIGIN = "codex_cli_ts";
export type TerminalChatSession = {

View File

@@ -0,0 +1,115 @@
import { describe, it, expect, vi } from "vitest";
// ---------------------------------------------------------------------------
// This regression test ensures that AgentLoop only surfaces each response item
// once even when the same item appears multiple times in the OpenAI streaming
// response (e.g. as an early `response.output_item.done` event *and* again in
// the final `response.completed` payload).
// ---------------------------------------------------------------------------
// Fake OpenAI stream that emits the *same* message twice: first as an
// incremental output event and then again in the turn completion payload.
class FakeStream {
public controller = { abort: vi.fn() };
async *[Symbol.asyncIterator]() {
// 1) Early incremental item.
yield {
type: "response.output_item.done",
item: {
type: "message",
id: "call-dedupe-1",
role: "assistant",
content: [{ type: "input_text", text: "Hello!" }],
},
} as any;
// 2) Turn completion containing the *same* item again.
yield {
type: "response.completed",
response: {
id: "resp-dedupe-1",
status: "completed",
output: [
{
type: "message",
id: "call-dedupe-1",
role: "assistant",
content: [{ type: "input_text", text: "Hello!" }],
},
],
},
} as any;
}
}
// Intercept the OpenAI SDK used inside AgentLoop so we can inject our fake
// streaming implementation.
vi.mock("openai", () => {
class FakeOpenAI {
public responses = {
create: async () => new FakeStream(),
};
}
class APIConnectionTimeoutError extends Error {}
return { __esModule: true, default: FakeOpenAI, APIConnectionTimeoutError };
});
// Stub approvals / formatting helpers not relevant here.
vi.mock("../src/approvals.js", () => ({
__esModule: true,
alwaysApprovedCommands: new Set<string>(),
canAutoApprove: () => ({ type: "auto-approve", runInSandbox: false }) as any,
isSafeCommand: () => null,
}));
vi.mock("../src/format-command.js", () => ({
__esModule: true,
formatCommandForDisplay: (cmd: Array<string>) => cmd.join(" "),
}));
vi.mock("../src/utils/agent/log.js", () => ({
__esModule: true,
log: () => {},
isLoggingEnabled: () => false,
}));
// After the dependency mocks we can import the module under test.
import { AgentLoop } from "../src/utils/agent/agent-loop.js";
describe("AgentLoop deduplicates output items", () => {
it("invokes onItem exactly once for duplicate items with the same id", async () => {
const received: Array<any> = [];
const agent = new AgentLoop({
model: "any",
instructions: "",
config: { model: "any", instructions: "", notify: false },
approvalPolicy: { mode: "auto" } as any,
additionalWritableRoots: [],
onItem: (item) => received.push(item),
onLoading: () => {},
getCommandConfirmation: async () => ({ review: "yes" }) as any,
onLastResponseId: () => {},
});
const userMsg = [
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "hi" }],
},
];
await agent.run(userMsg as any);
// Give the setTimeout(3ms) inside AgentLoop.stageItem a chance to fire.
await new Promise((r) => setTimeout(r, 20));
// Count how many times the duplicate item surfaced.
const appearances = received.filter((i) => i.id === "call-dedupe-1").length;
expect(appearances).toBe(1);
});
});

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

View File

@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, writeFileSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
/**
* Verifies that ~/.codex.env is parsed (lowestpriority) when present.
*/
describe("userwide ~/.codex.env support", () => {
const ORIGINAL_HOME = process.env["HOME"];
const ORIGINAL_API_KEY = process.env["OPENAI_API_KEY"];
let tempHome: string;
beforeEach(() => {
// Create an isolated fake $HOME directory.
tempHome = mkdtempSync(join(tmpdir(), "codex-home-"));
process.env["HOME"] = tempHome;
// Ensure the env var is unset so that the file value is picked up.
delete process.env["OPENAI_API_KEY"];
// Write ~/.codex.env with a dummy key.
writeFileSync(
join(tempHome, ".codex.env"),
"OPENAI_API_KEY=my-home-key\n",
{
encoding: "utf8",
},
);
});
afterEach(() => {
// Cleanup temp directory.
try {
rmSync(tempHome, { recursive: true, force: true });
} catch {
// ignore
}
// Restore original env.
if (ORIGINAL_HOME !== undefined) {
process.env["HOME"] = ORIGINAL_HOME;
} else {
delete process.env["HOME"];
}
if (ORIGINAL_API_KEY !== undefined) {
process.env["OPENAI_API_KEY"] = ORIGINAL_API_KEY;
} else {
delete process.env["OPENAI_API_KEY"];
}
});
it("loads the API key from ~/.codex.env when not set elsewhere", async () => {
// Import the config module AFTER setting up the fake env.
const { getApiKey } = await import("../src/utils/config.js");
expect(getApiKey("openai")).toBe("my-home-key");
});
});

21
codex-rs/Cargo.lock generated
View File

@@ -469,13 +469,12 @@ dependencies = [
[[package]]
name = "codex-cli"
version = "0.1.0"
version = "0.0.2504292236"
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.2504292236"
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.2504292236"
dependencies = [
"anyhow",
"clap",

View File

@@ -7,7 +7,15 @@ members = [
"core",
"exec",
"execpolicy",
"interactive",
"repl",
"tui",
]
[workspace.package]
version = "0.0.2504292236"
[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,12 +1,10 @@
mod proto;
mod seatbelt;
use std::path::PathBuf;
use clap::ArgAction;
use clap::Parser;
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;
@@ -24,7 +22,7 @@ use crate::proto::ProtoCli;
)]
struct MultitoolCli {
#[clap(flatten)]
interactive: InteractiveCli,
interactive: TuiCli,
#[clap(subcommand)]
subcommand: Option<Subcommand>,
@@ -36,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),
@@ -62,17 +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>,
/// 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)]
@@ -84,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?;
}
@@ -101,9 +84,24 @@ async fn main() -> anyhow::Result<()> {
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
DebugCommand::Seatbelt(SeatbeltCommand {
command,
writable_roots,
sandbox,
full_auto,
}) => {
seatbelt::run_seatbelt(command, 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,11 +1,11 @@
use codex_core::exec::create_seatbelt_command;
use std::path::PathBuf;
use codex_core::protocol::SandboxPolicy;
pub(crate) async fn run_seatbelt(
pub async fn run_seatbelt(
command: Vec<String>,
writable_roots: Vec<PathBuf>,
sandbox_policy: SandboxPolicy,
) -> anyhow::Result<()> {
let seatbelt_command = create_seatbelt_command(command, &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,12 +1,16 @@
//! 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, Debug, ValueEnum)]
#[derive(Clone, Copy, Debug, ValueEnum)]
#[value(rename_all = "kebab-case")]
pub enum ApprovalModeCliArg {
/// Run all commands without asking for user approval.
@@ -24,19 +28,6 @@ pub enum ApprovalModeCliArg {
Never,
}
#[derive(Clone, 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;
@@ -36,7 +34,6 @@ use crate::exec::process_exec_tool_call;
use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::flags::OPENAI_DEFAULT_MODEL;
use crate::flags::OPENAI_STREAM_MAX_RETRIES;
use crate::models::ContentItem;
use crate::models::FunctionCallOutputPayload;
@@ -486,7 +483,6 @@ async fn submission_loop(
sandbox_policy,
disable_response_storage,
} => {
let model = model.unwrap_or_else(|| OPENAI_DEFAULT_MODEL.to_string());
info!(model, "Configuring session");
let client = ModelClient::new(model.clone());
@@ -865,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,
)
};
@@ -920,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;
@@ -1010,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;
@@ -1348,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(),
@@ -1402,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() {
@@ -1435,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

@@ -2,39 +2,29 @@ use std::sync::atomic::AtomicU64;
use std::sync::Arc;
use crate::config::Config;
use crate::protocol::AskForApproval;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::Op;
use crate::protocol::SandboxPolicy;
use crate::protocol::Submission;
use crate::util::notify_on_sigint;
use crate::Codex;
use tokio::sync::Notify;
use tracing::debug;
/// Spawn a new [`Codex`] and initialise the session.
///
/// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that
/// is received as a response to the initial `ConfigureSession` submission so
/// that callers can surface the information to the UI.
pub async fn init_codex(
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
disable_response_storage: bool,
model_override: Option<String>,
) -> 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 config = Config::load().unwrap_or_default();
debug!("loaded config: {config:?}");
let codex = CodexWrapper::new(Codex::spawn(ctrl_c.clone())?);
let init_id = codex
.submit(Op::ConfigureSession {
model: model_override.or_else(|| config.model.clone()),
instructions: config.instructions,
approval_policy,
sandbox_policy,
disable_response_storage,
model: config.model.clone(),
instructions: config.instructions.clone(),
approval_policy: config.approval_policy,
sandbox_policy: config.sandbox_policy,
disable_response_storage: config.disable_response_storage,
})
.await?;

View File

@@ -1,42 +1,264 @@
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;
use std::path::PathBuf;
/// Embedded fallback instructions that mirror the TypeScript CLIs default system prompt. These
/// are compiled into the binary so a clean install behaves correctly even if the user has not
/// created `~/.codex/instructions.md`.
/// Embedded fallback instructions that mirror the TypeScript CLIs default
/// system prompt. These are compiled into the binary so a clean install behaves
/// correctly even if the user has not created `~/.codex/instructions.md`.
const EMBEDDED_INSTRUCTIONS: &str = include_str!("../prompt.md");
#[derive(Default, Deserialize, Debug, Clone)]
/// Application configuration loaded from disk and merged with overrides.
#[derive(Debug, Clone)]
pub struct Config {
pub model: Option<String>,
/// Optional override of model selection.
pub model: String,
/// Approval policy for executing commands.
pub approval_policy: AskForApproval,
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>,
}
/// 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>,
// 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 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) {
Ok(contents) => toml::from_str::<Self>(&contents).map_err(|e| {
tracing::error!("Failed to parse config.toml: {e}");
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("config.toml not found, using defaults");
Ok(Self::default())
}
Err(e) => {
tracing::error!("Failed to read config.toml: {e}");
Err(e)
}
}
}
}
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 ~/.codex/config.toml and ~/.codex/instructions.md (if present).
/// Returns `None` if neither file exists.
pub fn load() -> Option<Self> {
let mut cfg: Config = Self::load_from_toml().unwrap_or_default();
// Highest precedence → userprovided ~/.codex/instructions.md (if present)
// Fallback → embedded default instructions baked into the binary
cfg.instructions =
Self::load_instructions().or_else(|| Some(EMBEDDED_INSTRUCTIONS.to_string()));
Some(cfg)
/// 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_from_toml() -> Option<Self> {
let mut p = home_dir()?;
p.push(".codex/config.toml");
let contents = std::fs::read_to_string(&p).ok()?;
toml::from_str(&contents).ok()
fn load_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> {
let mut p = home_dir()?;
p.push(".codex/instructions.md");
let mut p = codex_dir().ok()?;
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 {
OPENAI_DEFAULT_MODEL.to_string()
}
/// Returns the path to the Codex configuration directory, which is `~/.codex`.
/// Does not verify that the directory exists.
pub fn codex_dir() -> std::io::Result<PathBuf> {
let mut p = home_dir().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not find home directory",
)
})?;
p.push(".codex");
Ok(p)
}
/// Returns the path to the folder where Codex logs are stored. Does not verify
/// that the directory exists.
pub fn log_dir() -> std::io::Result<PathBuf> {
let mut p = codex_dir()?;
p.push("log");
Ok(p)
}
#[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, 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 {
@@ -154,31 +154,63 @@ pub async fn process_exec_tool_call(
}
}
pub fn create_seatbelt_command(command: Vec<String>, writable_roots: &[PathBuf]) -> Vec<String> {
let (policies, cli_args): (Vec<String>, Vec<String>) = writable_roots
.iter()
.enumerate()
.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();
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}")
pub fn create_seatbelt_command(
command: Vec<String>,
sandbox_policy: &SandboxPolicy,
) -> Vec<String> {
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

@@ -26,7 +26,7 @@ pub enum Op {
/// Configure the model session.
ConfigureSession {
/// If not specified, server will use its default model.
model: Option<String>,
model: String,
/// Model instructions
instructions: Option<String>,
/// When to escalate for approval for execution
@@ -66,11 +66,13 @@ pub enum Op {
}
/// Determines how liberally commands are autoapproved by the system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum AskForApproval {
/// Under this policy, only “known safe” commands—as determined by
/// `is_safe_command()`—that **only read files** are autoapproved.
/// Everything else will ask the user to approve.
#[default]
UnlessAllowListed,
/// In addition to everything allowed by **`Suggest`**, commands that
@@ -91,42 +93,159 @@ pub enum AskForApproval {
}
/// Determines execution restrictions for model shell commands
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SandboxPolicy {
/// Network syscalls will be blocked
NetworkRestricted,
/// Filesystem writes will be restricted
FileWriteRestricted,
/// Network and filesystem writes will be restricted
NetworkAndFileWriteRestricted,
/// No restrictions; full "unsandboxed" mode
DangerousNoRestrictions,
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "kebab-case")]
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

@@ -17,7 +17,7 @@
use std::time::Duration;
use codex_core::protocol::AskForApproval;
use codex_core::config::Config;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
@@ -47,14 +47,15 @@ async fn spawn_codex() -> Codex {
let agent = Codex::spawn(std::sync::Arc::new(Notify::new())).unwrap();
let config = Config::load_default_config_for_test();
agent
.submit(Submission {
id: "init".into(),
op: Op::ConfigureSession {
model: None,
model: config.model,
instructions: None,
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
},
})

View File

@@ -1,6 +1,6 @@
use std::time::Duration;
use codex_core::protocol::AskForApproval;
use codex_core::config::Config;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
@@ -87,14 +87,15 @@ async fn keeps_previous_response_id_between_tasks() {
let codex = Codex::spawn(std::sync::Arc::new(tokio::sync::Notify::new())).unwrap();
// Init session
let config = Config::load_default_config_for_test();
codex
.submit(Submission {
id: "init".into(),
op: Op::ConfigureSession {
model: None,
model: config.model,
instructions: None,
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
approval_policy: config.approval_policy,
sandbox_policy: SandboxPolicy::new_read_only_policy(),
disable_response_storage: false,
},
})

View File

@@ -3,7 +3,7 @@
use std::time::Duration;
use codex_core::protocol::AskForApproval;
use codex_core::config::Config;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
@@ -70,14 +70,15 @@ async fn retries_on_early_close() {
let codex = Codex::spawn(std::sync::Arc::new(tokio::sync::Notify::new())).unwrap();
let config = Config::load_default_config_for_test();
codex
.submit(Submission {
id: "init".into(),
op: Op::ConfigureSession {
model: None,
model: config.model,
instructions: None,
approval_policy: AskForApproval::OnFailure,
sandbox_policy: SandboxPolicy::NetworkAndFileWriteRestricted,
approval_policy: config.approval_policy,
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" }
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,4 +1,6 @@
use clap::Parser;
use clap::ValueEnum;
use codex_core::SandboxPermissionOption;
use std::path::PathBuf;
#[derive(Parser, Debug)]
@@ -12,6 +14,13 @@ pub struct Cli {
#[arg(long, short = 'm')]
pub model: Option<String>,
/// 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)]
pub skip_git_repo_check: bool,
@@ -20,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,63 +1,89 @@
mod cli;
mod event_processor;
use std::io::IsTerminal;
use std::sync::Arc;
pub use cli::Cli;
use codex_core::codex_wrapper;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::FileChange;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use codex_core::util::is_inside_git_repo;
use 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,
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,
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);
}
// TODO(mbolin): We are reworking the CLI args right now, so this will
// likely come from a new --execution-policy arg.
let approval_policy = AskForApproval::Never;
let sandbox_policy = SandboxPolicy::NetworkAndFileWriteRestricted;
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(
approval_policy,
// This CLI is intended to be headless and has no affordances for asking
// the user for approval.
approval_policy: Some(AskForApproval::Never),
sandbox_policy,
disable_response_storage,
model,
)
.await?;
disable_response_storage: if disable_response_storage {
Some(true)
} else {
None
},
};
let config = Config::load_with_overrides(overrides)?;
let (codex_wrapper, event, ctrl_c) = codex_wrapper::init_codex(config).await?;
let codex = Arc::new(codex_wrapper);
info!("Codex initialized with event: {event:?}");
@@ -83,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;
@@ -99,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 })
@@ -114,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', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)]
pub sandbox_policy: SandboxModeCliArg,
/// Allow running Codex outside a Git repository.
#[arg(long = "skip-git-repo-check", default_value_t = false)]
pub skip_git_repo_check: bool,
/// Initial instructions for the agent.
pub prompt: Option<String>,
}

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.
@@ -34,14 +34,15 @@ pub struct Cli {
pub no_ansi: bool,
/// Configure when the model requires human approval before executing a command.
#[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)]
pub approval_policy: ApprovalModeCliArg,
#[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', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)]
pub sandbox_policy: 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

@@ -4,8 +4,11 @@ use std::io::Write;
use std::sync::Arc;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol;
use codex_core::protocol::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;
@@ -75,12 +78,33 @@ 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 config = Config::load().unwrap_or_default();
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,
sandbox_policy,
disable_response_storage: if cli.disable_response_storage {
Some(true)
} else {
None
},
};
let config = Config::load_with_overrides(overrides)?;
codex_main(cli, config, ctrl_c).await
}
async fn codex_main(mut cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Result<()> {
async fn codex_main(cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::Result<()> {
let mut builder = Codex::builder();
if let Some(path) = cli.record_submissions {
builder = builder.record_submissions(path);
@@ -93,11 +117,11 @@ async fn codex_main(mut cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::R
let init = protocol::Submission {
id: init_id.clone(),
op: protocol::Op::ConfigureSession {
model: cli.model.or(cfg.model),
model: cfg.model,
instructions: cfg.instructions,
approval_policy: cli.approval_policy.into(),
sandbox_policy: cli.sandbox_policy.into(),
disable_response_storage: cli.disable_response_storage,
approval_policy: cfg.approval_policy,
sandbox_policy: cfg.sandbox_policy,
disable_response_storage: cfg.disable_response_storage,
},
};
@@ -133,8 +157,8 @@ async fn codex_main(mut cli: Cli, cfg: Config, ctrl_c: Arc<Notify>) -> anyhow::R
// run loop
let mut reader = InputReader::new(ctrl_c.clone());
loop {
let text = match cli.prompt.take() {
Some(input) => input,
let text = match &cli.prompt {
Some(input) => input.clone(),
None => match reader.request_input().await? {
Some(input) => input,
None => {

View File

@@ -4,10 +4,9 @@ use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::scroll_event_helper::ScrollEventHelper;
use crate::tui;
use codex_core::protocol::AskForApproval;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use color_eyre::eyre::Result;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -34,13 +33,10 @@ pub(crate) struct App<'a> {
impl App<'_> {
pub(crate) fn new(
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
config: Config,
initial_prompt: Option<String>,
show_git_warning: bool,
initial_images: Vec<std::path::PathBuf>,
model: Option<String>,
disable_response_storage: bool,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let scroll_event_helper = ScrollEventHelper::new(app_event_tx.clone());
@@ -80,13 +76,10 @@ impl App<'_> {
}
let chat_widget = ChatWidget::new(
approval_policy,
sandbox_policy,
config,
app_event_tx.clone(),
initial_prompt.clone(),
initial_images,
model,
disable_response_storage,
);
let app_state = if show_git_warning {

View File

@@ -3,12 +3,11 @@ use std::sync::mpsc::Sender;
use std::sync::Arc;
use codex_core::codex_wrapper::init_codex;
use codex_core::protocol::AskForApproval;
use codex_core::config::Config;
use codex_core::protocol::Event;
use codex_core::protocol::EventMsg;
use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::SandboxPolicy;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
@@ -34,7 +33,7 @@ pub(crate) struct ChatWidget<'a> {
conversation_history: ConversationHistoryWidget,
bottom_pane: BottomPane<'a>,
input_focus: InputFocus,
approval_policy: AskForApproval,
config: Config,
cwd: std::path::PathBuf,
}
@@ -46,13 +45,10 @@ enum InputFocus {
impl ChatWidget<'_> {
pub(crate) fn new(
approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
config: Config,
app_event_tx: Sender<AppEvent>,
initial_prompt: Option<String>,
initial_images: Vec<std::path::PathBuf>,
model: Option<String>,
disable_response_storage: bool,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
@@ -63,19 +59,12 @@ impl ChatWidget<'_> {
let app_event_tx_clone = app_event_tx.clone();
// Create the Codex asynchronously so the UI loads as quickly as possible.
let config_for_agent_loop = config.clone();
tokio::spawn(async move {
// Initialize session; storage enabled by default
let (codex, session_event, _ctrl_c) = match init_codex(
approval_policy,
sandbox_policy,
disable_response_storage,
model,
)
.await
{
let (codex, session_event, _ctrl_c) = match init_codex(config_for_agent_loop).await {
Ok(vals) => vals,
Err(e) => {
// TODO(mbolin): This error needs to be surfaced to the user.
// TODO: surface this error to the user.
tracing::error!("failed to initialize codex: {e}");
return;
}
@@ -115,7 +104,7 @@ impl ChatWidget<'_> {
has_input_focus: true,
}),
input_focus: InputFocus::BottomPane,
approval_policy,
config,
cwd: cwd.clone(),
};
@@ -243,11 +232,8 @@ impl ChatWidget<'_> {
match msg {
EventMsg::SessionConfigured { model } => {
// Record session information at the top of the conversation.
self.conversation_history.add_session_info(
model,
self.cwd.clone(),
self.approval_policy,
);
self.conversation_history
.add_session_info(&self.config, model, self.cwd.clone());
self.request_redraw()?;
}
EventMsg::AgentMessage { message } => {

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)]
@@ -18,14 +18,15 @@ pub struct Cli {
pub model: Option<String>,
/// Configure when the model requires human approval before executing a command.
#[arg(long = "ask-for-approval", short = 'a', value_enum, default_value_t = ApprovalModeCliArg::OnFailure)]
pub approval_policy: ApprovalModeCliArg,
#[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', value_enum, default_value_t = SandboxModeCliArg::NetworkAndFileWriteRestricted)]
pub sandbox_policy: 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

@@ -1,6 +1,7 @@
use crate::history_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use codex_core::config::Config;
use codex_core::protocol::FileChange;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
@@ -47,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
}
@@ -181,13 +182,10 @@ impl ConversationHistoryWidget {
self.add_to_history(HistoryCell::new_patch_event(event_type, changes));
}
pub fn add_session_info(
&mut self,
model: String,
cwd: std::path::PathBuf,
approval_policy: codex_core::protocol::AskForApproval,
) {
self.add_to_history(HistoryCell::new_session_info(model, cwd, approval_policy));
/// Note `model` could differ from `config.model` if the agent decided to
/// use a different model than the one requested by the user.
pub fn add_session_info(&mut self, config: &Config, model: String, cwd: PathBuf) {
self.add_to_history(HistoryCell::new_session_info(config, model, cwd));
}
pub fn add_active_exec_command(&mut self, call_id: String, command: Vec<String>) {
@@ -240,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

@@ -1,4 +1,5 @@
use codex_ansi_escape::ansi_escape_line;
use codex_core::config::Config;
use codex_core::protocol::FileChange;
use ratatui::prelude::*;
use ratatui::style::Color;
@@ -144,9 +145,9 @@ impl HistoryCell {
}
pub(crate) fn new_session_info(
config: &Config,
model: String,
cwd: std::path::PathBuf,
approval_policy: codex_core::protocol::AskForApproval,
) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
@@ -158,7 +159,11 @@ impl HistoryCell {
]));
lines.push(Line::from(vec![
"↳ approval: ".bold(),
format!("{:?}", approval_policy).into(),
format!("{:?}", config.approval_policy).into(),
]));
lines.push(Line::from(vec![
"↳ sandbox: ".bold(),
format!("{:?}", config.sandbox_policy).into(),
]));
lines.push(Line::from(""));

View File

@@ -4,6 +4,10 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
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;
@@ -31,19 +35,63 @@ 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,
sandbox_policy,
disable_response_storage: if cli.disable_response_storage {
Some(true)
} else {
None
},
};
#[allow(clippy::print_stderr)]
match Config::load_with_overrides(overrides) {
Ok(config) => config,
Err(err) => {
eprintln!("Error loading configuration: {err}");
std::process::exit(1);
}
}
};
let log_dir = codex_core::config::log_dir()?;
std::fs::create_dir_all(&log_dir)?;
// Open (or create) your log file, appending to it.
let file = OpenOptions::new()
.create(true)
.append(true)
.open("/tmp/codex-rs.log")?;
let mut log_file_opts = OpenOptions::new();
log_file_opts.create(true).append(true);
// Ensure the file is only readable and writable by the current user.
// Doing the equivalent to `chmod 600` on Windows is quite a bit more code
// and requires the Windows API crates, so we can reconsider that when
// Codex CLI is officially supported on Windows.
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
log_file_opts.mode(0o600);
}
let log_file = log_file_opts.open(log_dir.join("codex-tui.log"))?;
// Wrap file in nonblocking writer.
let (non_blocking, _guard) = non_blocking(file);
let (non_blocking, _guard) = non_blocking(log_file);
// use RUST_LOG env var, default to trace for codex crates.
// use RUST_LOG env var, default to info for codex crates.
let env_filter = || {
EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("codex=trace,codex_tui=trace"))
.unwrap_or_else(|_| EnvFilter::new("codex_core=info,codex_tui=info"))
};
// Build layered subscriber:
@@ -67,7 +115,7 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
// `--allow-no-git-exec` flag.
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo();
try_run_ratatui_app(cli, show_git_warning, log_rx);
try_run_ratatui_app(cli, config, show_git_warning, log_rx);
Ok(())
}
@@ -77,16 +125,18 @@ pub fn run_main(cli: Cli) -> std::io::Result<()> {
)]
fn try_run_ratatui_app(
cli: Cli,
config: Config,
show_git_warning: bool,
log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) {
if let Err(report) = run_ratatui_app(cli, show_git_warning, log_rx) {
if let Err(report) = run_ratatui_app(cli, config, show_git_warning, log_rx) {
eprintln!("Error: {report:?}");
}
}
fn run_ratatui_app(
cli: Cli,
config: Config,
show_git_warning: bool,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<()> {
@@ -101,28 +151,8 @@ fn run_ratatui_app(
let mut terminal = tui::init()?;
terminal.clear()?;
let Cli {
prompt,
images,
approval_policy,
sandbox_policy: sandbox,
model,
disable_response_storage,
..
} = cli;
let approval_policy = approval_policy.into();
let sandbox_policy = sandbox.into();
let mut app = App::new(
approval_policy,
sandbox_policy,
prompt,
show_git_warning,
images,
model,
disable_response_storage,
);
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.
{

152
scripts/release_codex.py Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
"""
Automate the release procedure documented in `../README.md → Releasing codex`.
Run this script from the repository *root*:
```bash
python release_codex.py
```
It performs the same steps that the README lists manually:
1. Create and switch to a `bump-version-<timestamp>` branch.
2. Bump the timestamp-based version in `codex-cli/package.json` **and**
`codex-cli/src/utils/session.ts`.
3. Commit with a DCO sign-off.
4. Copy the top-level `README.md` into `codex-cli/` (npm consumers see it).
5. Run `pnpm release` (copies README again, builds, publishes to npm).
6. Push the branch so you can open a PR that merges the version bump.
The current directory can live anywhere; all paths are resolved relative to
this file so moving it elsewhere (e.g. into `scripts/`) still works.
"""
from __future__ import annotations
import datetime as _dt
import json as _json
import os
import re
import shutil
import subprocess as _sp
import sys
from pathlib import Path
# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------
# repo-root/
# ├── codex-cli/
# ├── scripts/ <-- you are here
# └── README.md
REPO_ROOT = Path(__file__).resolve().parent.parent
CODEX_CLI = REPO_ROOT / "codex-cli"
PKG_JSON = CODEX_CLI / "package.json"
SESSION_TS = CODEX_CLI / "src" / "utils" / "session.ts"
README_SRC = REPO_ROOT / "README.md"
README_DST = CODEX_CLI / "README.md"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def sh(cmd: list[str] | str, *, cwd: Path | None = None) -> None:
"""Run *cmd* printing it first and exit on non-zero status."""
if isinstance(cmd, list):
printable = " ".join(cmd)
else:
printable = cmd
print("+", printable)
_sp.run(cmd, cwd=cwd, shell=isinstance(cmd, str), check=True)
def _new_version() -> str:
"""Return a new timestamp version string such as `0.1.2504301234`."""
return "0.1." + _dt.datetime.utcnow().strftime("%y%m%d%H%M")
def bump_version() -> str:
"""Update package.json and session.ts, returning the new version."""
new_ver = _new_version()
# ---- package.json
data = _json.loads(PKG_JSON.read_text())
old_ver = data.get("version", "<unknown>")
data["version"] = new_ver
PKG_JSON.write_text(_json.dumps(data, indent=2) + "\n")
# ---- session.ts
pattern = r'CLI_VERSION = "0\\.1\\.\\d{10}"'
repl = f'CLI_VERSION = "{new_ver}"'
_text = SESSION_TS.read_text()
if re.search(pattern, _text):
SESSION_TS.write_text(re.sub(pattern, repl, _text))
else:
print(
"WARNING: CLI_VERSION constant not found file format may have changed",
file=sys.stderr,
)
print(f"Version bump: {old_ver}{new_ver}")
return new_ver
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None: # noqa: C901 readable top-level flow is desired
# Ensure we can locate required files.
for p in (CODEX_CLI, PKG_JSON, SESSION_TS, README_SRC):
if not p.exists():
sys.exit(f"Required path missing: {p.relative_to(REPO_ROOT)}")
os.chdir(REPO_ROOT)
# ------------------------------- create release branch
branch = "bump-version-" + _dt.datetime.utcnow().strftime("%Y%m%d-%H%M")
sh(["git", "checkout", "-b", branch])
# ------------------------------- bump version + commit
new_ver = bump_version()
sh(
[
"git",
"add",
str(PKG_JSON.relative_to(REPO_ROOT)),
str(SESSION_TS.relative_to(REPO_ROOT)),
]
)
sh(["git", "commit", "-s", "-m", f"chore(release): codex-cli v{new_ver}"])
# ------------------------------- copy README (shown on npmjs.com)
shutil.copyfile(README_SRC, README_DST)
# ------------------------------- build + publish via pnpm script
sh(["pnpm", "install"], cwd=CODEX_CLI)
sh(["pnpm", "release"], cwd=CODEX_CLI)
# ------------------------------- push branch
sh(["git", "push", "-u", "origin", branch])
print("\n✅ Release script finished!")
print(f" • npm publish run by pnpm script (branch: {branch})")
print(" • Open a PR to merge the version bump once CI passes.")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit("\nCancelled by user")