Compare commits

...

18 Commits

Author SHA1 Message Date
easong-openai
4d36208931 better? 2025-07-28 18:03:04 -07:00
easong-openai
d276932354 add --experimental-prompt support 2025-07-28 17:32:59 -07:00
Dylan
094d7af8c3 [mcp-server] Populate notifications._meta with requestId (#1704)
## Summary
Per the [latest MCP
spec](https://modelcontextprotocol.io/specification/2025-06-18/basic#meta),
the `_meta` field is reserved for metadata. In the [Typescript
Schema](0695a497eb/schema/2025-06-18/schema.ts (L37-L40)),
`progressToken` is defined as a value to be attached to subsequent
notifications for that request.

The
[CallToolRequestParams](0695a497eb/schema/2025-06-18/schema.ts (L806-L817))
extends this definition but overwrites the params field. This ambiguity
makes our generated type definitions tricky, so I'm going to skip
`progressToken` field for now and just send back the `requestId`
instead.
 
In a future PR, we can clarify, update our `generate_mcp_types.py`
script, and update our progressToken logic accordingly.

## Testing
- [x] Added unit tests
- [x] Manually tested with mcp client
2025-07-28 13:32:09 -07:00
Jeremy Rose
2d2df891bb fix: long lines incorrectly wrapped (#1710)
fix to #1685.
2025-07-28 12:19:03 -07:00
easong-openai
80c19ea77c Fix approval workflow (#1696)
(Hopefully) temporary solution to the invisible approvals problem -
prints commands to history when they need approval and then also prints
the result of the approval. In the near future we should be able to do
some fancy stuff with updating commands before writing them to permanent
history.

Also, ctr-c while in the approval modal now acts as esc (aborts command)
and puts the TUI in the state where one additional ctr-c will exit.
2025-07-28 19:00:06 +00:00
aibrahim-oai
19bef7659f Serializing the eventmsg type to snake_case (#1709)
This was an abrupt change on our clients. We need to serialize as
snake_case.
2025-07-28 10:26:27 -07:00
Michael Bolin
5ebb7dd34c chore: split apply_patch logic out of codex.rs and into apply_patch.rs (#1703)
This is a straight refactor, moving apply-patch-related code from
`codex.rs` and into the new `apply_patch.rs` file. The only "logical"
change is inlining `#[allow(clippy::unwrap_used)]` instead of declaring
`#![allow(clippy::unwrap_used)]` at the top of the file (which is
currently the case in `codex.rs`).

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1703).
* #1705
* __->__ #1703
* #1702
* #1698
* #1697
2025-07-28 09:51:22 -07:00
Michael Bolin
d76f96ce79 fix: support special --codex-run-as-apply-patch arg (#1702)
This introduces some special behavior to the CLIs that are using the
`codex-arg0` crate where if `arg1` is `--codex-run-as-apply-patch`, then
it will run as if `apply_patch arg2` were invoked. This is important
because it means we can do things like:

```
SANDBOX_TYPE=landlock # or seatbelt for macOS
codex debug "${SANDBOX_TYPE}" -- codex --codex-run-as-apply-patch PATCH
```

which gives us a way to run `apply_patch` while ensuring it adheres to
the sandbox the user specified.

While it would be nice to use the `arg0` trick like we are currently
doing for `codex-linux-sandbox`, there is no way to specify the `arg0`
for the underlying command when running under `/usr/bin/sandbox-exec`,
so it will not work for us in this case.

Admittedly, we could have also supported this via a custom environment
variable (e.g., `CODEX_ARG0`), but since environment variables are
inherited by child processes, that seemed like a potentially leakier
abstraction.

This change, as well as our existing reliance on checking `arg0`, place
additional requirements on those who include `codex-core`. Its
`README.md` has been updated to reflect this.

While we could have just added an `apply-patch` subcommand to the
`codex` multitool CLI, that would not be sufficient for the standalone
`codex-exec` CLI, which is something that we distribute as part of our
GitHub releases for those who know they will not be using the TUI and
therefore prefer to use a slightly smaller executable:

https://github.com/openai/codex/releases/tag/rust-v0.10.0

To that end, this PR adds an integration test to ensure that the
`--codex-run-as-apply-patch` option works with the standalone
`codex-exec` CLI.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1702).
* #1705
* #1703
* __->__ #1702
* #1698
* #1697
2025-07-28 09:26:44 -07:00
Michael Bolin
fcd197d596 fix: use std::env::args_os instead of std::env::args (#1698)
Apparently `std::env::args()` will panic during iteration if any
argument to the process is not valid Unicode:

https://doc.rust-lang.org/std/env/fn.args.html

Let's avoid the risk and just go with `std::env::args_os()`.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/openai/codex/pull/1698).
* #1705
* #1703
* #1702
* __->__ #1698
* #1697
2025-07-28 08:52:18 -07:00
Michael Bolin
9102255854 fix: move arg0 handling out of codex-linux-sandbox and into its own crate (#1697) 2025-07-28 08:31:24 -07:00
Jeremy Rose
7ecd3153a8 fix: correctly wrap history items (#1685)
The overall idea here is: skip ratatui for writing into scrollback,
because its primitives are wrong. We want to render full lines of text,
that will be wrapped natively by the terminal, and which we never plan
to update using ratatui (so the `Buffer` struct is overhead and in fact
an inhibition).

Instead, we use ANSI scrolling regions (link reference doc to come).
Essentially, we:
1. Define a scrolling region that extends from the top of the prompt
area all the way to the top of scrollback
2. Scroll that region up by N < (screen_height - viewport_height) lines,
in this PR N=1
3. Put our cursor at the top of the newly empty region
4. Print out our new text like normal

The terminal interactions here (write_spans and its dependencies) are
mostly extracted from ratatui.
2025-07-28 14:45:49 +00:00
Michael Bolin
2405c40026 chore: update Codex::spawn() to return a struct instead of a tuple (#1677)
Also update `init_codex()` to return a `struct` instead of a tuple, as well.
2025-07-27 20:01:35 -07:00
easong-openai
58bed77ba7 Remove tab focus switching (#1694)
Previously pressing tab would switch TUI focus to the history scrollbox - no longer necessary.
2025-07-27 11:04:09 -07:00
aibrahim-oai
5a0079fea2 Changing method in MCP notifications (#1684)
- Changing the codex/event type
2025-07-26 10:35:49 -07:00
Jeremy Rose
c66c99c5b5 fix: crash on resize (#1683)
Without this, resizing the terminal prints "Error: The cursor position
could not be read within a normal duration" and quits the app.
2025-07-25 14:23:38 -07:00
Jeremy Rose
75b4008094 fix: paste with newlines (#1682)
This fixes an issue where pasting multi-line content would break the
composer.
2025-07-25 19:26:40 +00:00
pakrym-oai
7ee87123a6 Optionally run using user profile (#1678) 2025-07-25 11:45:23 -07:00
Michael Bolin
994c9a874d chore: use one write call per item in rollout_writer() (#1679)
Most of the time, we expect the `String` returned by
`serde_json::to_string()` to have extra capacity, so `push('\n')` is
unlikely to allocate, which seems cheaper than an extra `write(2)` call,
on average?
2025-07-25 10:43:36 -07:00
55 changed files with 2219 additions and 949 deletions

133
codex-rs/Cargo.lock generated
View File

@@ -463,18 +463,18 @@ checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53"
[[package]]
name = "castaway"
version = "0.2.3"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5"
checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
dependencies = [
"rustversion",
]
[[package]]
name = "cc"
version = "1.2.29"
version = "1.2.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
dependencies = [
"jobserver",
"libc",
@@ -570,9 +570,9 @@ checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
[[package]]
name = "clipboard-win"
version = "5.4.0"
version = "5.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892"
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
dependencies = [
"error-code",
]
@@ -605,6 +605,18 @@ dependencies = [
"tree-sitter-bash",
]
[[package]]
name = "codex-arg0"
version = "0.0.0"
dependencies = [
"anyhow",
"codex-apply-patch",
"codex-core",
"codex-linux-sandbox",
"dotenvy",
"tokio",
]
[[package]]
name = "codex-chatgpt"
version = "0.0.0"
@@ -628,11 +640,11 @@ dependencies = [
"anyhow",
"clap",
"clap_complete",
"codex-arg0",
"codex-chatgpt",
"codex-common",
"codex-core",
"codex-exec",
"codex-linux-sandbox",
"codex-login",
"codex-mcp-server",
"codex-tui",
@@ -683,6 +695,7 @@ dependencies = [
"serde",
"serde_json",
"sha1",
"shlex",
"strum_macros 0.27.2",
"tempfile",
"thiserror 2.0.12",
@@ -696,6 +709,7 @@ dependencies = [
"tree-sitter-bash",
"uuid",
"walkdir",
"whoami",
"wildmatch",
"wiremock",
]
@@ -705,14 +719,17 @@ name = "codex-exec"
version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"chrono",
"clap",
"codex-arg0",
"codex-common",
"codex-core",
"codex-linux-sandbox",
"owo-colors",
"predicates",
"serde_json",
"shlex",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",
@@ -759,7 +776,6 @@ dependencies = [
"clap",
"codex-common",
"codex-core",
"dotenvy",
"landlock",
"libc",
"seccompiler",
@@ -797,8 +813,8 @@ version = "0.0.0"
dependencies = [
"anyhow",
"assert_cmd",
"codex-arg0",
"codex-core",
"codex-linux-sandbox",
"mcp-types",
"mcp_test_support",
"pretty_assertions",
@@ -824,10 +840,10 @@ dependencies = [
"base64 0.22.1",
"clap",
"codex-ansi-escape",
"codex-arg0",
"codex-common",
"codex-core",
"codex-file-search",
"codex-linux-sandbox",
"codex-login",
"color-eyre",
"crossterm",
@@ -844,6 +860,7 @@ dependencies = [
"shlex",
"strum 0.27.2",
"strum_macros 0.27.2",
"tempfile",
"tokio",
"tracing",
"tracing-appender",
@@ -976,9 +993,9 @@ dependencies = [
[[package]]
name = "crc32fast"
version = "1.4.2"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a97769d94ddab943e4510d138150169a2758b5ef3eb191a9ee688de3e23ef7b3"
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
dependencies = [
"cfg-if",
]
@@ -1525,7 +1542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78"
dependencies = [
"cfg-if",
"rustix 1.0.7",
"rustix 1.0.8",
"windows-sys 0.59.0",
]
@@ -1974,9 +1991,9 @@ dependencies = [
[[package]]
name = "hyper-util"
version = "0.1.15"
version = "0.1.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df"
checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e"
dependencies = [
"base64 0.22.1",
"bytes",
@@ -1990,7 +2007,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.0",
"system-configuration",
"tokio",
"tower-service",
@@ -2243,9 +2260,9 @@ dependencies = [
[[package]]
name = "instability"
version = "0.3.7"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf9fed6d91cfb734e7476a06bde8300a1b94e217e1b523b6f0cd1a01998c71d"
checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a"
dependencies = [
"darling",
"indoc",
@@ -2276,9 +2293,9 @@ dependencies = [
[[package]]
name = "io-uring"
version = "0.7.8"
version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
@@ -2482,9 +2499,9 @@ dependencies = [
[[package]]
name = "libredox"
version = "0.1.4"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1580801010e535496706ba011c15f8532df6b42297d2e471fec38ceadd8c0638"
checksum = "4488594b9328dee448adb906d8b126d9b7deb7cf5c22161ee591610bb1be83c0"
dependencies = [
"bitflags 2.9.1",
"libc",
@@ -3357,8 +3374,7 @@ dependencies = [
[[package]]
name = "ratatui"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b"
source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#bca287ddc5d38fe088c79e2eda22422b96226f2e"
dependencies = [
"bitflags 2.9.1",
"cassowary",
@@ -3463,9 +3479,9 @@ dependencies = [
[[package]]
name = "redox_syscall"
version = "0.5.13"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6"
checksum = "7e8af0dde094006011e6a740d4879319439489813bd0bcdc7d821beaeeff48ec"
dependencies = [
"bitflags 2.9.1",
]
@@ -3613,9 +3629,9 @@ dependencies = [
[[package]]
name = "rgb"
version = "0.8.51"
version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a457e416a0f90d246a4c3288bd7a25b2304ca727f253f95be383dd17af56be8f"
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]]
name = "ring"
@@ -3691,22 +3707,22 @@ dependencies = [
[[package]]
name = "rustix"
version = "1.0.7"
version = "1.0.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266"
checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
dependencies = [
"bitflags 2.9.1",
"errno",
"libc",
"linux-raw-sys 0.9.4",
"windows-sys 0.59.0",
"windows-sys 0.60.2",
]
[[package]]
name = "rustls"
version = "0.23.28"
version = "0.23.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
dependencies = [
"once_cell",
"rustls-pki-types",
@@ -3726,9 +3742,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
version = "0.103.3"
version = "0.103.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3954,9 +3970,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.140"
version = "1.0.141"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373"
checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
dependencies = [
"indexmap 2.10.0",
"itoa",
@@ -4149,6 +4165,16 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.0"
@@ -4440,7 +4466,7 @@ dependencies = [
"fastrand",
"getrandom 0.3.3",
"once_cell",
"rustix 1.0.7",
"rustix 1.0.8",
"windows-sys 0.59.0",
]
@@ -4461,7 +4487,7 @@ version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45c6481c4829e4cc63825e62c49186a34538b7b2750b73b266581ffb612fb5ed"
dependencies = [
"rustix 1.0.7",
"rustix 1.0.8",
"windows-sys 0.59.0",
]
@@ -4607,7 +4633,7 @@ dependencies = [
"pin-project-lite",
"signal-hook-registry",
"slab",
"socket2",
"socket2 0.5.10",
"tokio-macros",
"windows-sys 0.52.0",
]
@@ -4749,9 +4775,9 @@ dependencies = [
[[package]]
name = "toml_writer"
version = "1.0.0"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b679217f2848de74cabd3e8fc5e6d66f40b7da40f8e1954d92054d9010690fd5"
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
[[package]]
name = "tower"
@@ -5128,6 +5154,12 @@ dependencies = [
"wit-bindgen-rt",
]
[[package]]
name = "wasite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
name = "wasm-bindgen"
version = "0.2.100"
@@ -5228,6 +5260,17 @@ version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3"
[[package]]
name = "whoami"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7"
dependencies = [
"redox_syscall",
"wasite",
"web-sys",
]
[[package]]
name = "wildmatch"
version = "2.4.0"
@@ -5556,9 +5599,9 @@ checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
[[package]]
name = "winnow"
version = "0.7.11"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd"
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
dependencies = [
"memchr",
]

View File

@@ -3,6 +3,7 @@ resolver = "2"
members = [
"ansi-escape",
"apply-patch",
"arg0",
"cli",
"common",
"core",
@@ -40,3 +41,8 @@ strip = "symbols"
# See https://github.com/openai/codex/issues/1411 for details.
codegen-units = 1
[patch.crates-io]
# ratatui = { path = "../../ratatui" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }

19
codex-rs/arg0/Cargo.toml Normal file
View File

@@ -0,0 +1,19 @@
[package]
name = "codex-arg0"
version = { workspace = true }
edition = "2024"
[lib]
name = "codex_arg0"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
anyhow = "1"
codex-apply-patch = { path = "../apply-patch" }
codex-core = { path = "../core" }
codex-linux-sandbox = { path = "../linux-sandbox" }
dotenvy = "0.15.7"
tokio = { version = "1", features = ["rt-multi-thread"] }

89
codex-rs/arg0/src/lib.rs Normal file
View File

@@ -0,0 +1,89 @@
use std::future::Future;
use std::path::Path;
use std::path::PathBuf;
/// While we want to deploy the Codex CLI as a single executable for simplicity,
/// we also want to expose some of its functionality as distinct CLIs, so we use
/// the "arg0 trick" to determine which CLI to dispatch. This effectively allows
/// us to simulate deploying multiple executables as a single binary on Mac and
/// Linux (but not Windows).
///
/// When the current executable is invoked through the hard-link or alias named
/// `codex-linux-sandbox` we *directly* execute
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Use [`dotenvy::from_path`] and [`dotenvy::dotenv`] to modify the
/// environment before creating any threads.
/// 2. Construct a Tokio multi-thread runtime.
/// 3. Derive the path to the current executable (so children can re-invoke the
/// sandbox) when running on Linux.
/// 4. Execute the provided async `main_fn` inside that runtime, forwarding any
/// error. Note that `main_fn` receives `codex_linux_sandbox_exe:
/// Option<PathBuf>`, as an argument, which is generally needed as part of
/// constructing [`codex_core::config::Config`].
///
/// This function should be used to wrap any `main()` function in binary crates
/// in this workspace that depends on these helper CLIs.
pub fn arg0_dispatch_or_else<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Option<PathBuf>) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
// Determine if we were invoked via the special alias.
let mut args = std::env::args_os();
let argv0 = args.next().unwrap_or_default();
let exe_name = Path::new(&argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if exe_name == "codex-linux-sandbox" {
// Safety: [`run_main`] never returns.
codex_linux_sandbox::run_main();
}
let argv1 = args.next().unwrap_or_default();
if argv1 == "--codex-run-as-apply-patch" {
let patch_arg = args.next().and_then(|s| s.to_str().map(|s| s.to_owned()));
let exit_code = match patch_arg {
Some(patch_arg) => {
let mut stdout = std::io::stdout();
let mut stderr = std::io::stderr();
match codex_apply_patch::apply_patch(&patch_arg, &mut stdout, &mut stderr) {
Ok(()) => 0,
Err(_) => 1,
}
}
None => {
eprintln!("Error: --codex-run-as-apply-patch requires a UTF-8 PATCH argument.");
1
}
};
std::process::exit(exit_code);
}
// This modifies the environment, which is not thread-safe, so do this
// before creating any threads/the Tokio runtime.
load_dotenv();
// Regular invocation create a Tokio runtime and execute the provided
// async entry-point.
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async move {
let codex_linux_sandbox_exe: Option<PathBuf> = if cfg!(target_os = "linux") {
std::env::current_exe().ok()
} else {
None
};
main_fn(codex_linux_sandbox_exe).await
})
}
/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
fn load_dotenv() {
if let Ok(codex_home) = codex_core::config::find_codex_home() {
dotenvy::from_path(codex_home.join(".env")).ok();
}
dotenvy::dotenv().ok();
}

View File

@@ -18,12 +18,12 @@ workspace = true
anyhow = "1"
clap = { version = "4", features = ["derive"] }
clap_complete = "4"
codex-arg0 = { path = "../arg0" }
codex-chatgpt = { path = "../chatgpt" }
codex-core = { path = "../core" }
codex-common = { path = "../common", features = ["cli"] }
codex-exec = { path = "../exec" }
codex-login = { path = "../login" }
codex-linux-sandbox = { path = "../linux-sandbox" }
codex-mcp-server = { path = "../mcp-server" }
codex-tui = { path = "../tui" }
serde_json = "1"

View File

@@ -2,6 +2,7 @@ use clap::CommandFactory;
use clap::Parser;
use clap_complete::Shell;
use clap_complete::generate;
use codex_arg0::arg0_dispatch_or_else;
use codex_chatgpt::apply_command::ApplyCommand;
use codex_chatgpt::apply_command::run_apply_command;
use codex_cli::LandlockCommand;
@@ -92,7 +93,7 @@ struct LoginCommand {
}
fn main() -> anyhow::Result<()> {
codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
cli_main(codex_linux_sandbox_exe).await?;
Ok(())
})

View File

@@ -4,6 +4,7 @@ use std::sync::Arc;
use clap::Parser;
use codex_common::CliConfigOverrides;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::protocol::Submission;
@@ -35,7 +36,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> {
let config = Config::load_with_cli_overrides(overrides_vec, ConfigOverrides::default())?;
let ctrl_c = notify_on_sigint();
let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await?;
let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await?;
let codex = Arc::new(codex);
// Task that reads JSON lines from stdin and forwards to Submission Queue

View File

@@ -30,6 +30,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha1 = "0.10.6"
shlex = "1.3.0"
strum_macros = "0.27.2"
thiserror = "2.0.12"
time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
@@ -47,6 +48,8 @@ tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"
uuid = { version = "1", features = ["serde", "v4"] }
wildmatch = "2.4.0"
whoami = "1.6.0"
[target.'cfg(target_os = "linux")'.dependencies]
landlock = "0.4.1"

View File

@@ -2,9 +2,18 @@
This crate implements the business logic for Codex. It is designed to be used by the various Codex UIs written in Rust.
Though for non-Rust UIs, we are also working to define a _protocol_ for talking to Codex. See:
## Dependencies
- [Specification](../docs/protocol_v1.md)
- [Rust types](./src/protocol.rs)
Note that `codex-core` makes some assumptions about certain helper utilities being available in the environment. Currently, this
You can use the `proto` subcommand using the executable in the [`cli` crate](../cli) to speak the protocol using newline-delimited-JSON over stdin/stdout.
### macOS
Expects `/usr/bin/sandbox-exec` to be present.
### Linux
Expects the binary containing `codex-core` to run the equivalent of `codex debug landlock` when `arg0` is `codex-linux-sandbox`. See the `codex-arg0` crate for details.
### All Platforms
Expects the binary containing `codex-core` to simulate the virtual `apply_patch` CLI when `arg1` is `--codex-run-as-apply-patch`. See the `codex-arg0` crate for details.

View File

@@ -0,0 +1,406 @@
use crate::codex::Session;
use crate::models::FunctionCallOutputPayload;
use crate::models::ResponseInputItem;
use crate::protocol::Event;
use crate::protocol::EventMsg;
use crate::protocol::FileChange;
use crate::protocol::PatchApplyBeginEvent;
use crate::protocol::PatchApplyEndEvent;
use crate::protocol::ReviewDecision;
use crate::safety::SafetyCheck;
use crate::safety::assess_patch_safety;
use anyhow::Context;
use codex_apply_patch::AffectedPaths;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::print_summary;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
pub(crate) async fn apply_patch(
sess: &Session,
sub_id: String,
call_id: String,
action: ApplyPatchAction,
) -> ResponseInputItem {
let writable_roots_snapshot = {
#[allow(clippy::unwrap_used)]
let guard = sess.writable_roots.lock().unwrap();
guard.clone()
};
let auto_approved = match assess_patch_safety(
&action,
sess.approval_policy,
&writable_roots_snapshot,
&sess.cwd,
) {
SafetyCheck::AutoApprove { .. } => true,
SafetyCheck::AskUser => {
// Compute a readable summary of path changes to include in the
// approval request so the user can make an informed decision.
let rx_approve = sess
.request_patch_approval(sub_id.clone(), call_id.clone(), &action, None, None)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
}
}
SafetyCheck::Reject { reason } => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
};
}
};
// Verify write permissions before touching the filesystem.
let writable_snapshot = {
#[allow(clippy::unwrap_used)]
sess.writable_roots.lock().unwrap().clone()
};
if let Some(offending) = first_offending_path(&action, &writable_snapshot, &sess.cwd) {
let root = offending.parent().unwrap_or(&offending).to_path_buf();
let reason = Some(format!(
"grant write access to {} for this session",
root.display()
));
let rx = sess
.request_patch_approval(
sub_id.clone(),
call_id.clone(),
&action,
reason.clone(),
Some(root.clone()),
)
.await;
if !matches!(
rx.await.unwrap_or_default(),
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
) {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
// user approved, extend writable roots for this session
#[allow(clippy::unwrap_used)]
sess.writable_roots.lock().unwrap().push(root);
}
let _ = sess
.tx_event
.send(Event {
id: sub_id.clone(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: call_id.clone(),
auto_approved,
changes: convert_apply_patch_to_protocol(&action),
}),
})
.await;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
// Enforce writable roots. If a write is blocked, collect offending root
// and prompt the user to extend permissions.
let mut result = apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr);
if let Err(err) = &result {
if err.kind() == std::io::ErrorKind::PermissionDenied {
// Determine first offending path.
let offending_opt = action
.changes()
.iter()
.flat_map(|(path, change)| match change {
ApplyPatchFileChange::Add { .. } => vec![path.as_ref()],
ApplyPatchFileChange::Delete => vec![path.as_ref()],
ApplyPatchFileChange::Update {
move_path: Some(move_path),
..
} => {
vec![path.as_ref(), move_path.as_ref()]
}
ApplyPatchFileChange::Update {
move_path: None, ..
} => vec![path.as_ref()],
})
.find_map(|path: &Path| {
// ApplyPatchAction promises to guarantee absolute paths.
if !path.is_absolute() {
panic!("apply_patch invariant failed: path is not absolute: {path:?}");
}
let writable = {
#[allow(clippy::unwrap_used)]
let roots = sess.writable_roots.lock().unwrap();
roots.iter().any(|root| path.starts_with(root))
};
if writable {
None
} else {
Some(path.to_path_buf())
}
});
if let Some(offending) = offending_opt {
let root = offending.parent().unwrap_or(&offending).to_path_buf();
let reason = Some(format!(
"grant write access to {} for this session",
root.display()
));
let rx = sess
.request_patch_approval(
sub_id.clone(),
call_id.clone(),
&action,
reason.clone(),
Some(root.clone()),
)
.await;
if matches!(
rx.await.unwrap_or_default(),
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
) {
// Extend writable roots.
#[allow(clippy::unwrap_used)]
sess.writable_roots.lock().unwrap().push(root);
stdout.clear();
stderr.clear();
result = apply_changes_from_apply_patch_and_report(
&action,
&mut stdout,
&mut stderr,
);
}
}
}
}
// Emit PatchApplyEnd event.
let success_flag = result.is_ok();
let _ = sess
.tx_event
.send(Event {
id: sub_id.clone(),
msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent {
call_id: call_id.clone(),
stdout: String::from_utf8_lossy(&stdout).to_string(),
stderr: String::from_utf8_lossy(&stderr).to_string(),
success: success_flag,
}),
})
.await;
match result {
Ok(_) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: String::from_utf8_lossy(&stdout).to_string(),
success: None,
},
},
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("error: {e:#}, stderr: {}", String::from_utf8_lossy(&stderr)),
success: Some(false),
},
},
}
}
/// Return the first path in `hunks` that is NOT under any of the
/// `writable_roots` (after normalising). If all paths are acceptable,
/// returns None.
fn first_offending_path(
action: &ApplyPatchAction,
writable_roots: &[PathBuf],
cwd: &Path,
) -> Option<PathBuf> {
let changes = action.changes();
for (path, change) in changes {
let candidate = match change {
ApplyPatchFileChange::Add { .. } => path,
ApplyPatchFileChange::Delete => path,
ApplyPatchFileChange::Update { move_path, .. } => move_path.as_ref().unwrap_or(path),
};
let abs = if candidate.is_absolute() {
candidate.clone()
} else {
cwd.join(candidate)
};
let mut allowed = false;
for root in writable_roots {
let root_abs = if root.is_absolute() {
root.clone()
} else {
cwd.join(root)
};
if abs.starts_with(&root_abs) {
allowed = true;
break;
}
}
if !allowed {
return Some(candidate.clone());
}
}
None
}
pub(crate) fn convert_apply_patch_to_protocol(
action: &ApplyPatchAction,
) -> HashMap<PathBuf, FileChange> {
let changes = action.changes();
let mut result = HashMap::with_capacity(changes.len());
for (path, change) in changes {
let protocol_change = match change {
ApplyPatchFileChange::Add { content } => FileChange::Add {
content: content.clone(),
},
ApplyPatchFileChange::Delete => FileChange::Delete,
ApplyPatchFileChange::Update {
unified_diff,
move_path,
new_content: _new_content,
} => FileChange::Update {
unified_diff: unified_diff.clone(),
move_path: move_path.clone(),
},
};
result.insert(path.clone(), protocol_change);
}
result
}
fn apply_changes_from_apply_patch_and_report(
action: &ApplyPatchAction,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
) -> std::io::Result<()> {
match apply_changes_from_apply_patch(action) {
Ok(affected_paths) => {
print_summary(&affected_paths, stdout)?;
}
Err(err) => {
writeln!(stderr, "{err:?}")?;
}
}
Ok(())
}
fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result<AffectedPaths> {
let mut added: Vec<PathBuf> = Vec::new();
let mut modified: Vec<PathBuf> = Vec::new();
let mut deleted: Vec<PathBuf> = Vec::new();
let changes = action.changes();
for (path, change) in changes {
match change {
ApplyPatchFileChange::Add { content } => {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create parent directories for {}", path.display())
})?;
}
}
std::fs::write(path, content)
.with_context(|| format!("Failed to write file {}", path.display()))?;
added.push(path.clone());
}
ApplyPatchFileChange::Delete => {
std::fs::remove_file(path)
.with_context(|| format!("Failed to delete file {}", path.display()))?;
deleted.push(path.clone());
}
ApplyPatchFileChange::Update {
unified_diff: _unified_diff,
move_path,
new_content,
} => {
if let Some(move_path) = move_path {
if let Some(parent) = move_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create parent directories for {}",
move_path.display()
)
})?;
}
}
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());
}
}
}
}
Ok(AffectedPaths {
added,
modified,
deleted,
})
}
pub(crate) fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
let mut writable_roots = Vec::new();
if cfg!(target_os = "macos") {
// On macOS, $TMPDIR is private to the user.
writable_roots.push(std::env::temp_dir());
// Allow pyenv to update its shims directory. Without this, any tool
// that happens to be managed by `pyenv` will fail with an error like:
//
// pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
//
// which is emitted every time `pyenv` tries to run `rehash` (for
// example, after installing a new Python package that drops an entry
// point). Although the sandbox is intentionally readonly by default,
// writing to the user's local `pyenv` directory is safe because it
// is already userwritable and scoped to the current user account.
if let Ok(home_dir) = std::env::var("HOME") {
let pyenv_dir = PathBuf::from(home_dir).join(".pyenv");
writable_roots.push(pyenv_dir);
}
}
writable_roots.push(cwd.to_path_buf());
writable_roots
}

View File

@@ -4,22 +4,17 @@
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::atomic::AtomicU64;
use std::time::Duration;
use anyhow::Context;
use async_channel::Receiver;
use async_channel::Sender;
use codex_apply_patch::AffectedPaths;
use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange;
use codex_apply_patch::MaybeApplyPatchVerified;
use codex_apply_patch::maybe_parse_apply_patch_verified;
use codex_apply_patch::print_summary;
use futures::prelude::*;
use mcp_types::CallToolResult;
use serde::Serialize;
@@ -34,6 +29,9 @@ use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use crate::apply_patch::convert_apply_patch_to_protocol;
use crate::apply_patch::get_writable_roots;
use crate::apply_patch::{self};
use crate::client::ModelClient;
use crate::client_common::Prompt;
use crate::client_common::ResponseEvent;
@@ -71,11 +69,8 @@ use crate::protocol::EventMsg;
use crate::protocol::ExecApprovalRequestEvent;
use crate::protocol::ExecCommandBeginEvent;
use crate::protocol::ExecCommandEndEvent;
use crate::protocol::FileChange;
use crate::protocol::InputItem;
use crate::protocol::Op;
use crate::protocol::PatchApplyBeginEvent;
use crate::protocol::PatchApplyEndEvent;
use crate::protocol::ReviewDecision;
use crate::protocol::SandboxPolicy;
use crate::protocol::SessionConfiguredEvent;
@@ -84,7 +79,7 @@ use crate::protocol::TaskCompleteEvent;
use crate::rollout::RolloutRecorder;
use crate::safety::SafetyCheck;
use crate::safety::assess_command_safety;
use crate::safety::assess_patch_safety;
use crate::shell;
use crate::user_notification::UserNotification;
use crate::util::backoff;
@@ -96,11 +91,18 @@ pub struct Codex {
rx_event: Receiver<Event>,
}
/// Wrapper returned by [`Codex::spawn`] containing the spawned [`Codex`],
/// the submission id for the initial `ConfigureSession` request and the
/// unique session id.
pub struct CodexSpawnOk {
pub codex: Codex,
pub init_id: String,
pub session_id: Uuid,
}
impl Codex {
/// Spawn a new [`Codex`] and initialize the session. Returns the instance
/// of `Codex` and the ID of the `SessionInitialized` event that was
/// submitted to start the session.
pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<(Codex, String, Uuid)> {
/// Spawn a new [`Codex`] and initialize the session.
pub async fn spawn(config: Config, ctrl_c: Arc<Notify>) -> CodexResult<CodexSpawnOk> {
// experimental resume path (undocumented)
let resume_path = config.experimental_resume.clone();
info!("resume_path: {resume_path:?}");
@@ -138,7 +140,11 @@ impl Codex {
};
let init_id = codex.submit(configure_session).await?;
Ok((codex, init_id, session_id))
Ok(CodexSpawnOk {
codex,
init_id,
session_id,
})
}
/// Submit the `op` wrapped in a `Submission` with a unique ID.
@@ -177,19 +183,19 @@ impl Codex {
/// A session has at most 1 running task at a time, and can be interrupted by user input.
pub(crate) struct Session {
client: ModelClient,
tx_event: Sender<Event>,
pub(crate) tx_event: Sender<Event>,
ctrl_c: Arc<Notify>,
/// The session's current working directory. All relative paths provided by
/// the model as well as sandbox policies are resolved against this path
/// instead of `std::env::current_dir()`.
cwd: PathBuf,
pub(crate) cwd: PathBuf,
base_instructions: Option<String>,
user_instructions: Option<String>,
approval_policy: AskForApproval,
pub(crate) approval_policy: AskForApproval,
sandbox_policy: SandboxPolicy,
shell_environment_policy: ShellEnvironmentPolicy,
writable_roots: Mutex<Vec<PathBuf>>,
pub(crate) writable_roots: Mutex<Vec<PathBuf>>,
disable_response_storage: bool,
/// Manager for external MCP servers/tools.
@@ -204,6 +210,7 @@ pub(crate) struct Session {
rollout: Mutex<Option<RolloutRecorder>>,
state: Mutex<State>,
codex_linux_sandbox_exe: Option<PathBuf>,
user_shell: shell::Shell,
}
impl Session {
@@ -676,6 +683,7 @@ async fn submission_loop(
});
}
}
let default_shell = shell::default_user_shell().await;
sess = Some(Arc::new(Session {
client,
tx_event: tx_event.clone(),
@@ -693,6 +701,7 @@ async fn submission_loop(
rollout: Mutex::new(rollout_recorder),
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
disable_response_storage,
user_shell: default_shell,
}));
// Patch restored state into the newly created session.
@@ -1383,6 +1392,18 @@ fn parse_container_exec_arguments(
}
}
fn maybe_run_with_user_profile(params: ExecParams, sess: &Session) -> ExecParams {
if sess.shell_environment_policy.use_profile {
let command = sess
.user_shell
.format_default_shell_invocation(params.command.clone());
if let Some(command) = command {
return ExecParams { command, ..params };
}
}
params
}
async fn handle_container_exec_with_params(
params: ExecParams,
sess: &Session,
@@ -1392,7 +1413,7 @@ async fn handle_container_exec_with_params(
// check if this was a patch, and apply it if so
match maybe_parse_apply_patch_verified(&params.command, &params.cwd) {
MaybeApplyPatchVerified::Body(changes) => {
return apply_patch(sess, sub_id, call_id, changes).await;
return apply_patch::apply_patch(sess, sub_id, call_id, changes).await;
}
MaybeApplyPatchVerified::CorrectnessError(parse_error) => {
// It looks like an invocation of `apply_patch`, but we
@@ -1469,6 +1490,7 @@ async fn handle_container_exec_with_params(
sess.notify_exec_command_begin(&sub_id, &call_id, &params)
.await;
let params = maybe_run_with_user_profile(params, sess);
let output_result = process_exec_tool_call(
params.clone(),
sandbox_type,
@@ -1640,384 +1662,6 @@ async fn handle_sandbox_error(
}
}
async fn apply_patch(
sess: &Session,
sub_id: String,
call_id: String,
action: ApplyPatchAction,
) -> ResponseInputItem {
let writable_roots_snapshot = {
let guard = sess.writable_roots.lock().unwrap();
guard.clone()
};
let auto_approved = match assess_patch_safety(
&action,
sess.approval_policy,
&writable_roots_snapshot,
&sess.cwd,
) {
SafetyCheck::AutoApprove { .. } => true,
SafetyCheck::AskUser => {
// Compute a readable summary of path changes to include in the
// approval request so the user can make an informed decision.
let rx_approve = sess
.request_patch_approval(sub_id.clone(), call_id.clone(), &action, None, None)
.await;
match rx_approve.await.unwrap_or_default() {
ReviewDecision::Approved | ReviewDecision::ApprovedForSession => false,
ReviewDecision::Denied | ReviewDecision::Abort => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
}
}
SafetyCheck::Reject { reason } => {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("patch rejected: {reason}"),
success: Some(false),
},
};
}
};
// Verify write permissions before touching the filesystem.
let writable_snapshot = { sess.writable_roots.lock().unwrap().clone() };
if let Some(offending) = first_offending_path(&action, &writable_snapshot, &sess.cwd) {
let root = offending.parent().unwrap_or(&offending).to_path_buf();
let reason = Some(format!(
"grant write access to {} for this session",
root.display()
));
let rx = sess
.request_patch_approval(
sub_id.clone(),
call_id.clone(),
&action,
reason.clone(),
Some(root.clone()),
)
.await;
if !matches!(
rx.await.unwrap_or_default(),
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
) {
return ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: "patch rejected by user".to_string(),
success: Some(false),
},
};
}
// user approved, extend writable roots for this session
sess.writable_roots.lock().unwrap().push(root);
}
let _ = sess
.tx_event
.send(Event {
id: sub_id.clone(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: call_id.clone(),
auto_approved,
changes: convert_apply_patch_to_protocol(&action),
}),
})
.await;
let mut stdout = Vec::new();
let mut stderr = Vec::new();
// Enforce writable roots. If a write is blocked, collect offending root
// and prompt the user to extend permissions.
let mut result = apply_changes_from_apply_patch_and_report(&action, &mut stdout, &mut stderr);
if let Err(err) = &result {
if err.kind() == std::io::ErrorKind::PermissionDenied {
// Determine first offending path.
let offending_opt = action
.changes()
.iter()
.flat_map(|(path, change)| match change {
ApplyPatchFileChange::Add { .. } => vec![path.as_ref()],
ApplyPatchFileChange::Delete => vec![path.as_ref()],
ApplyPatchFileChange::Update {
move_path: Some(move_path),
..
} => {
vec![path.as_ref(), move_path.as_ref()]
}
ApplyPatchFileChange::Update {
move_path: None, ..
} => vec![path.as_ref()],
})
.find_map(|path: &Path| {
// ApplyPatchAction promises to guarantee absolute paths.
if !path.is_absolute() {
panic!("apply_patch invariant failed: path is not absolute: {path:?}");
}
let writable = {
let roots = sess.writable_roots.lock().unwrap();
roots.iter().any(|root| path.starts_with(root))
};
if writable {
None
} else {
Some(path.to_path_buf())
}
});
if let Some(offending) = offending_opt {
let root = offending.parent().unwrap_or(&offending).to_path_buf();
let reason = Some(format!(
"grant write access to {} for this session",
root.display()
));
let rx = sess
.request_patch_approval(
sub_id.clone(),
call_id.clone(),
&action,
reason.clone(),
Some(root.clone()),
)
.await;
if matches!(
rx.await.unwrap_or_default(),
ReviewDecision::Approved | ReviewDecision::ApprovedForSession
) {
// Extend writable roots.
sess.writable_roots.lock().unwrap().push(root);
stdout.clear();
stderr.clear();
result = apply_changes_from_apply_patch_and_report(
&action,
&mut stdout,
&mut stderr,
);
}
}
}
}
// Emit PatchApplyEnd event.
let success_flag = result.is_ok();
let _ = sess
.tx_event
.send(Event {
id: sub_id.clone(),
msg: EventMsg::PatchApplyEnd(PatchApplyEndEvent {
call_id: call_id.clone(),
stdout: String::from_utf8_lossy(&stdout).to_string(),
stderr: String::from_utf8_lossy(&stderr).to_string(),
success: success_flag,
}),
})
.await;
match result {
Ok(_) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: String::from_utf8_lossy(&stdout).to_string(),
success: None,
},
},
Err(e) => ResponseInputItem::FunctionCallOutput {
call_id,
output: FunctionCallOutputPayload {
content: format!("error: {e:#}, stderr: {}", String::from_utf8_lossy(&stderr)),
success: Some(false),
},
},
}
}
/// Return the first path in `hunks` that is NOT under any of the
/// `writable_roots` (after normalising). If all paths are acceptable,
/// returns None.
fn first_offending_path(
action: &ApplyPatchAction,
writable_roots: &[PathBuf],
cwd: &Path,
) -> Option<PathBuf> {
let changes = action.changes();
for (path, change) in changes {
let candidate = match change {
ApplyPatchFileChange::Add { .. } => path,
ApplyPatchFileChange::Delete => path,
ApplyPatchFileChange::Update { move_path, .. } => move_path.as_ref().unwrap_or(path),
};
let abs = if candidate.is_absolute() {
candidate.clone()
} else {
cwd.join(candidate)
};
let mut allowed = false;
for root in writable_roots {
let root_abs = if root.is_absolute() {
root.clone()
} else {
cwd.join(root)
};
if abs.starts_with(&root_abs) {
allowed = true;
break;
}
}
if !allowed {
return Some(candidate.clone());
}
}
None
}
fn convert_apply_patch_to_protocol(action: &ApplyPatchAction) -> HashMap<PathBuf, FileChange> {
let changes = action.changes();
let mut result = HashMap::with_capacity(changes.len());
for (path, change) in changes {
let protocol_change = match change {
ApplyPatchFileChange::Add { content } => FileChange::Add {
content: content.clone(),
},
ApplyPatchFileChange::Delete => FileChange::Delete,
ApplyPatchFileChange::Update {
unified_diff,
move_path,
new_content: _new_content,
} => FileChange::Update {
unified_diff: unified_diff.clone(),
move_path: move_path.clone(),
},
};
result.insert(path.clone(), protocol_change);
}
result
}
fn apply_changes_from_apply_patch_and_report(
action: &ApplyPatchAction,
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
) -> std::io::Result<()> {
match apply_changes_from_apply_patch(action) {
Ok(affected_paths) => {
print_summary(&affected_paths, stdout)?;
}
Err(err) => {
writeln!(stderr, "{err:?}")?;
}
}
Ok(())
}
fn apply_changes_from_apply_patch(action: &ApplyPatchAction) -> anyhow::Result<AffectedPaths> {
let mut added: Vec<PathBuf> = Vec::new();
let mut modified: Vec<PathBuf> = Vec::new();
let mut deleted: Vec<PathBuf> = Vec::new();
let changes = action.changes();
for (path, change) in changes {
match change {
ApplyPatchFileChange::Add { content } => {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!("Failed to create parent directories for {}", path.display())
})?;
}
}
std::fs::write(path, content)
.with_context(|| format!("Failed to write file {}", path.display()))?;
added.push(path.clone());
}
ApplyPatchFileChange::Delete => {
std::fs::remove_file(path)
.with_context(|| format!("Failed to delete file {}", path.display()))?;
deleted.push(path.clone());
}
ApplyPatchFileChange::Update {
unified_diff: _unified_diff,
move_path,
new_content,
} => {
if let Some(move_path) = move_path {
if let Some(parent) = move_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).with_context(|| {
format!(
"Failed to create parent directories for {}",
move_path.display()
)
})?;
}
}
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());
}
}
}
}
Ok(AffectedPaths {
added,
modified,
deleted,
})
}
fn get_writable_roots(cwd: &Path) -> Vec<PathBuf> {
let mut writable_roots = Vec::new();
if cfg!(target_os = "macos") {
// On macOS, $TMPDIR is private to the user.
writable_roots.push(std::env::temp_dir());
// Allow pyenv to update its shims directory. Without this, any tool
// that happens to be managed by `pyenv` will fail with an error like:
//
// pyenv: cannot rehash: $HOME/.pyenv/shims isn't writable
//
// which is emitted every time `pyenv` tries to run `rehash` (for
// example, after installing a new Python package that drops an entry
// point). Although the sandbox is intentionally readonly by default,
// writing to the user's local `pyenv` directory is safe because it
// is already userwritable and scoped to the current user account.
if let Ok(home_dir) = std::env::var("HOME") {
let pyenv_dir = PathBuf::from(home_dir).join(".pyenv");
writable_roots.push(pyenv_dir);
}
}
writable_roots.push(cwd.to_path_buf());
writable_roots
}
/// Exec output is a pre-serialized JSON payload
fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String {
#[derive(Serialize)]

View File

@@ -1,6 +1,7 @@
use std::sync::Arc;
use crate::Codex;
use crate::CodexSpawnOk;
use crate::config::Config;
use crate::protocol::Event;
use crate::protocol::EventMsg;
@@ -8,14 +9,27 @@ use crate::util::notify_on_sigint;
use tokio::sync::Notify;
use uuid::Uuid;
/// Represents an active Codex conversation, including the first event
/// (which is [`EventMsg::SessionConfigured`]).
pub struct CodexConversation {
pub codex: Codex,
pub session_id: Uuid,
pub session_configured: Event,
pub ctrl_c: Arc<Notify>,
}
/// Spawn a new [`Codex`] and initialize the session.
///
/// Returns the wrapped [`Codex`] **and** the `SessionInitialized` event that
/// is received as a response to the initial `ConfigureSession` submission so
/// that callers can surface the information to the UI.
pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc<Notify>, Uuid)> {
pub async fn init_codex(config: Config) -> anyhow::Result<CodexConversation> {
let ctrl_c = notify_on_sigint();
let (codex, init_id, session_id) = Codex::spawn(config, ctrl_c.clone()).await?;
let CodexSpawnOk {
codex,
init_id,
session_id,
} = Codex::spawn(config, ctrl_c.clone()).await?;
// The first event must be `SessionInitialized`. Validate and forward it to
// the caller so that they can display it in the conversation history.
@@ -34,5 +48,10 @@ pub async fn init_codex(config: Config) -> anyhow::Result<(Codex, Event, Arc<Not
));
}
Ok((codex, event, ctrl_c, session_id))
Ok(CodexConversation {
codex,
session_id,
session_configured: event,
ctrl_c,
})
}

View File

@@ -130,6 +130,8 @@ pub struct ShellEnvironmentPolicyToml {
/// List of regular expressions.
pub include_only: Option<Vec<String>>,
pub experimental_use_profile: Option<bool>,
}
pub type EnvironmentVariablePattern = WildMatchPattern<'*', '?'>;
@@ -158,6 +160,9 @@ pub struct ShellEnvironmentPolicy {
/// Environment variable names to retain in the environment.
pub include_only: Vec<EnvironmentVariablePattern>,
/// If true, the shell profile will be used to run the command.
pub use_profile: bool,
}
impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
@@ -177,6 +182,7 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
.into_iter()
.map(|s| EnvironmentVariablePattern::new_case_insensitive(&s))
.collect();
let use_profile = toml.experimental_use_profile.unwrap_or(false);
Self {
inherit,
@@ -184,6 +190,7 @@ impl From<ShellEnvironmentPolicyToml> for ShellEnvironmentPolicy {
exclude,
r#set,
include_only,
use_profile,
}
}
}

View File

@@ -17,6 +17,7 @@ use tokio::io::BufReader;
use tokio::process::Child;
use tokio::process::Command;
use tokio::sync::Notify;
use tracing::trace;
use crate::error::CodexErr;
use crate::error::Result;
@@ -82,7 +83,8 @@ pub async fn process_exec_tool_call(
) -> Result<ExecToolCallOutput> {
let start = Instant::now();
let raw_output_result = match sandbox_type {
let raw_output_result: std::result::Result<RawExecToolCallOutput, CodexErr> = match sandbox_type
{
SandboxType::None => exec(params, sandbox_policy, ctrl_c).await,
SandboxType::MacosSeatbelt => {
let ExecParams {
@@ -372,6 +374,10 @@ async fn spawn_child_async(
stdio_policy: StdioPolicy,
env: HashMap<String, String>,
) -> std::io::Result<Child> {
trace!(
"spawn_child_async: {program:?} {args:?} {arg0:?} {cwd:?} {sandbox_policy:?} {stdio_policy:?} {env:?}"
);
let mut cmd = Command::new(&program);
#[cfg(unix)]
cmd.arg0(arg0.map_or_else(|| program.to_string_lossy().to_string(), String::from));

View File

@@ -5,12 +5,14 @@
// the TUI or the tracing stack).
#![deny(clippy::print_stdout, clippy::print_stderr)]
mod apply_patch;
mod bash;
mod chat_completions;
mod client;
mod client_common;
pub mod codex;
pub use codex::Codex;
pub use codex::CodexSpawnOk;
pub mod codex_wrapper;
pub mod config;
pub mod config_profile;
@@ -36,6 +38,7 @@ mod project_doc;
pub mod protocol;
mod rollout;
mod safety;
pub mod shell;
mod user_notification;
pub mod util;

View File

@@ -8,6 +8,7 @@
use std::collections::HashMap;
use std::collections::HashSet;
use std::ffi::OsString;
use std::time::Duration;
use anyhow::Context;
@@ -127,7 +128,12 @@ impl McpConnectionManager {
join_set.spawn(async move {
let McpServerConfig { command, args, env } = cfg;
let client_res = McpClient::new_stdio_client(command, args, env).await;
let client_res = McpClient::new_stdio_client(
command.into(),
args.into_iter().map(OsString::from).collect(),
env,
)
.await;
match client_res {
Ok(client) => {
// Initialize the client.

View File

@@ -278,8 +278,9 @@ pub struct Event {
}
/// Response event from the agent
#[derive(Debug, Clone, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize, Display)]
#[serde(tag = "type", rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum EventMsg {
/// Error while executing a submission
Error(ErrorEvent),

View File

@@ -288,11 +288,13 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
}
async fn rollout_writer(
mut file: tokio::fs::File,
file: tokio::fs::File,
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
) {
) -> std::io::Result<()> {
let mut writer = JsonlWriter { file };
// If we have a meta, collect git info asynchronously and write meta first
if let Some(session_meta) = meta.take() {
let git_info = collect_git_info(&cwd).await;
@@ -302,11 +304,7 @@ async fn rollout_writer(
};
// Write the SessionMeta as the first item in the file
if let Ok(json) = serde_json::to_string(&session_meta_with_git) {
let _ = file.write_all(json.as_bytes()).await;
let _ = file.write_all(b"\n").await;
let _ = file.flush().await;
}
writer.write_line(&session_meta_with_git).await?;
}
// Process rollout commands
@@ -320,15 +318,11 @@ async fn rollout_writer(
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::Reasoning { .. } => {
if let Ok(json) = serde_json::to_string(&item) {
let _ = file.write_all(json.as_bytes()).await;
let _ = file.write_all(b"\n").await;
}
writer.write_line(&item).await?;
}
ResponseItem::Other => {}
}
}
let _ = file.flush().await;
}
RolloutCmd::UpdateState(state) => {
#[derive(Serialize)]
@@ -337,18 +331,32 @@ async fn rollout_writer(
#[serde(flatten)]
state: &'a SessionStateSnapshot,
}
if let Ok(json) = serde_json::to_string(&StateLine {
record_type: "state",
state: &state,
}) {
let _ = file.write_all(json.as_bytes()).await;
let _ = file.write_all(b"\n").await;
let _ = file.flush().await;
}
writer
.write_line(&StateLine {
record_type: "state",
state: &state,
})
.await?;
}
RolloutCmd::Shutdown { ack } => {
let _ = ack.send(());
}
}
}
Ok(())
}
struct JsonlWriter {
file: tokio::fs::File,
}
impl JsonlWriter {
async fn write_line(&mut self, item: &impl serde::Serialize) -> std::io::Result<()> {
let mut json = serde_json::to_string(item)?;
json.push('\n');
let _ = self.file.write_all(json.as_bytes()).await;
self.file.flush().await?;
Ok(())
}
}

204
codex-rs/core/src/shell.rs Normal file
View File

@@ -0,0 +1,204 @@
use shlex;
#[derive(Debug, PartialEq, Eq)]
pub struct ZshShell {
shell_path: String,
zshrc_path: String,
}
#[derive(Debug, PartialEq, Eq)]
pub enum Shell {
Zsh(ZshShell),
Unknown,
}
impl Shell {
pub fn format_default_shell_invocation(&self, command: Vec<String>) -> Option<Vec<String>> {
match self {
Shell::Zsh(zsh) => {
if !std::path::Path::new(&zsh.zshrc_path).exists() {
return None;
}
let mut result = vec![zsh.shell_path.clone(), "-c".to_string()];
if let Ok(joined) = shlex::try_join(command.iter().map(|s| s.as_str())) {
result.push(format!("source {} && ({joined})", zsh.zshrc_path));
} else {
return None;
}
Some(result)
}
Shell::Unknown => None,
}
}
}
#[cfg(target_os = "macos")]
pub async fn default_user_shell() -> Shell {
use tokio::process::Command;
use whoami;
let user = whoami::username();
let home = format!("/Users/{user}");
let output = Command::new("dscl")
.args([".", "-read", &home, "UserShell"])
.output()
.await
.ok();
match output {
Some(o) => {
if !o.status.success() {
return Shell::Unknown;
}
let stdout = String::from_utf8_lossy(&o.stdout);
for line in stdout.lines() {
if let Some(shell_path) = line.strip_prefix("UserShell: ") {
if shell_path.ends_with("/zsh") {
return Shell::Zsh(ZshShell {
shell_path: shell_path.to_string(),
zshrc_path: format!("{home}/.zshrc"),
});
}
}
}
Shell::Unknown
}
_ => Shell::Unknown,
}
}
#[cfg(not(target_os = "macos"))]
pub async fn default_user_shell() -> Shell {
Shell::Unknown
}
#[cfg(test)]
#[cfg(target_os = "macos")]
mod tests {
use super::*;
use std::process::Command;
#[tokio::test]
#[expect(clippy::unwrap_used)]
async fn test_current_shell_detects_zsh() {
let shell = Command::new("sh")
.arg("-c")
.arg("echo $SHELL")
.output()
.unwrap();
let home = std::env::var("HOME").unwrap();
let shell_path = String::from_utf8_lossy(&shell.stdout).trim().to_string();
if shell_path.ends_with("/zsh") {
assert_eq!(
default_user_shell().await,
Shell::Zsh(ZshShell {
shell_path: shell_path.to_string(),
zshrc_path: format!("{home}/.zshrc",),
})
);
}
}
#[tokio::test]
async fn test_run_with_profile_zshrc_not_exists() {
let shell = Shell::Zsh(ZshShell {
shell_path: "/bin/zsh".to_string(),
zshrc_path: "/does/not/exist/.zshrc".to_string(),
});
let actual_cmd = shell.format_default_shell_invocation(vec!["myecho".to_string()]);
assert_eq!(actual_cmd, None);
}
#[expect(clippy::unwrap_used)]
#[tokio::test]
async fn test_run_with_profile_escaping_and_execution() {
let shell_path = "/bin/zsh";
let cases = vec![
(
vec!["myecho"],
vec![shell_path, "-c", "source ZSHRC_PATH && (myecho)"],
Some("It works!\n"),
),
(
vec!["bash", "-lc", "echo 'single' \"double\""],
vec![
shell_path,
"-c",
"source ZSHRC_PATH && (bash -lc \"echo 'single' \\\"double\\\"\")",
],
Some("single double\n"),
),
];
for (input, expected_cmd, expected_output) in cases {
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Notify;
use crate::exec::ExecParams;
use crate::exec::SandboxType;
use crate::exec::process_exec_tool_call;
use crate::protocol::SandboxPolicy;
// create a temp directory with a zshrc file in it
let temp_home = tempfile::tempdir().unwrap();
let zshrc_path = temp_home.path().join(".zshrc");
std::fs::write(
&zshrc_path,
r#"
set -x
function myecho {
echo 'It works!'
}
"#,
)
.unwrap();
let shell = Shell::Zsh(ZshShell {
shell_path: shell_path.to_string(),
zshrc_path: zshrc_path.to_str().unwrap().to_string(),
});
let actual_cmd = shell
.format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect());
let expected_cmd = expected_cmd
.iter()
.map(|s| {
s.replace("ZSHRC_PATH", zshrc_path.to_str().unwrap())
.to_string()
})
.collect();
assert_eq!(actual_cmd, Some(expected_cmd));
// Actually run the command and check output/exit code
let output = process_exec_tool_call(
ExecParams {
command: actual_cmd.unwrap(),
cwd: PathBuf::from(temp_home.path()),
timeout_ms: None,
env: HashMap::from([(
"HOME".to_string(),
temp_home.path().to_str().unwrap().to_string(),
)]),
},
SandboxType::None,
Arc::new(Notify::new()),
&SandboxPolicy::DangerFullAccess,
&None,
)
.await
.unwrap();
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
if let Some(expected) = expected_output {
assert_eq!(
output.stdout, expected,
"input: {input:?} output: {output:?}"
);
}
}
}
}

View File

@@ -64,3 +64,21 @@ pub fn is_inside_git_repo(config: &Config) -> bool {
false
}
/// If `val` is a path to a readable file, return its trimmed contents.
///
/// - When `val` points to a file, this reads the file, trims leading/trailing
/// whitespace and returns `Ok(Some(contents))` unless the trimmed contents are
/// empty in which case it returns `Ok(None)`.
/// - When `val` is not a file path, return `Ok(Some(val.to_string()))` so
/// callers can treat the value as a literal string.
pub fn maybe_read_file(val: &str) -> std::io::Result<Option<String>> {
let p = std::path::Path::new(val);
if p.is_file() {
let s = std::fs::read_to_string(p)?;
let s = s.trim().to_string();
if s.is_empty() { Ok(None) } else { Ok(Some(s)) }
} else {
Ok(Some(val.to_string()))
}
}

View File

@@ -1,4 +1,5 @@
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ModelProviderInfo;
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_core::protocol::EventMsg;
@@ -72,7 +73,7 @@ async fn includes_session_id_and_model_headers_in_request() {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
codex
.submit(Op::UserInput {
@@ -148,7 +149,7 @@ async fn includes_base_instructions_override_in_request() {
config.model_provider = model_provider;
let ctrl_c = std::sync::Arc::new(tokio::sync::Notify::new());
let (codex, ..) = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c.clone()).await.unwrap();
codex
.submit(Op::UserInput {

View File

@@ -20,6 +20,7 @@
use std::time::Duration;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::error::CodexErr;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::ErrorEvent;
@@ -48,7 +49,7 @@ async fn spawn_codex() -> Result<Codex, CodexErr> {
let mut config = load_default_config_for_test(&codex_home);
config.model_provider.request_max_retries = Some(2);
config.model_provider.stream_max_retries = Some(2);
let (agent, _init_id, _session_id) =
let CodexSpawnOk { codex: agent, .. } =
Codex::spawn(config, std::sync::Arc::new(Notify::new())).await?;
Ok(agent)

View File

@@ -4,6 +4,7 @@
use std::time::Duration;
use codex_core::Codex;
use codex_core::CodexSpawnOk;
use codex_core::ModelProviderInfo;
use codex_core::exec::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_core::protocol::EventMsg;
@@ -94,7 +95,7 @@ async fn retries_on_early_close() {
let codex_home = TempDir::new().unwrap();
let mut config = load_default_config_for_test(&codex_home);
config.model_provider = model_provider;
let (codex, _init_id, _session_id) = Codex::spawn(config, ctrl_c).await.unwrap();
let CodexSpawnOk { codex, .. } = Codex::spawn(config, ctrl_c).await.unwrap();
codex
.submit(Op::UserInput {

View File

@@ -18,13 +18,13 @@ workspace = true
anyhow = "1"
chrono = "0.4.40"
clap = { version = "4", features = ["derive"] }
codex-arg0 = { path = "../arg0" }
codex-core = { path = "../core" }
codex-common = { path = "../common", features = [
"cli",
"elapsed",
"sandbox_summary",
] }
codex-linux-sandbox = { path = "../linux-sandbox" }
owo-colors = "4.2.0"
serde_json = "1"
shlex = "1.3.0"
@@ -37,3 +37,8 @@ tokio = { version = "1", features = [
] }
tracing = { version = "0.1.41", features = ["log"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
[dev-dependencies]
assert_cmd = "2"
predicates = "3"
tempfile = "3.13.0"

View File

@@ -63,6 +63,40 @@ pub struct Cli {
/// if `-` is used), instructions are read from stdin.
#[arg(value_name = "PROMPT")]
pub prompt: Option<String>,
/// Override the built-in system prompt (base instructions).
///
/// If the value looks like a path to an existing file, the contents of the
/// file are used. Otherwise, the value itself is used verbatim as the
/// instructions string.
#[arg(long = "experimental-instructions")]
pub experimental_instructions: Option<String>,
}
#[cfg(test)]
mod tests {
use super::Cli;
use clap::CommandFactory;
#[test]
fn help_includes_file_behavior_for_experimental_instructions() {
let mut cmd = Cli::command();
let mut buf: Vec<u8> = Vec::new();
assert!(cmd.write_long_help(&mut buf).is_ok(), "help should render");
let help = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => panic!("invalid utf8: {e}"),
};
assert!(help.contains("Override the built-in system prompt (base instructions)."));
assert!(help.contains(
"If the value looks like a path to an existing file, the contents of the file are used."
));
assert!(
help.contains(
"Otherwise, the value itself is used verbatim as the instructions string."
)
);
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]

View File

@@ -1,4 +1,5 @@
use std::path::Path;
use std::path::PathBuf;
use codex_common::summarize_sandbox_policy;
use codex_core::WireApi;
@@ -20,7 +21,16 @@ pub(crate) trait EventProcessor {
fn process_event(&mut self, event: Event) -> CodexStatus;
}
pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static str, String)> {
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum PromptOrigin {
File(PathBuf),
Literal,
}
pub(crate) fn create_config_summary_entries(
config: &Config,
prompt_origin: Option<&PromptOrigin>,
) -> Vec<(&'static str, String)> {
let mut entries = vec![
("workdir", config.cwd.display().to_string()),
("model", config.model.clone()),
@@ -28,6 +38,16 @@ pub(crate) fn create_config_summary_entries(config: &Config) -> Vec<(&'static st
("approval", config.approval_policy.to_string()),
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
];
if let Some(origin) = prompt_origin {
let prompt_val = match origin {
PromptOrigin::Literal => "experimental".to_string(),
PromptOrigin::File(path) => path
.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string()),
};
entries.push(("prompt_origin", prompt_val));
}
if config.model_provider.wire_api == WireApi::Responses
&& model_supports_reasoning_summaries(config)
{
@@ -68,3 +88,64 @@ fn write_last_message_file(contents: &str, last_message_path: Option<&Path>) {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use std::collections::HashMap;
use tempfile::TempDir;
fn minimal_config() -> Config {
let cwd = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let codex_home = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let cfg = ConfigToml {
..Default::default()
};
let overrides = ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
};
match Config::load_from_base_config_with_overrides(
cfg,
overrides,
codex_home.path().to_path_buf(),
) {
Ok(c) => c,
Err(e) => panic!("config error: {e}"),
}
}
#[test]
fn entries_include_prompt_origin_experimental_for_literal_origin() {
let mut cfg = minimal_config();
cfg.base_instructions = Some("hello".to_string());
let entries = create_config_summary_entries(&cfg, Some(&PromptOrigin::Literal));
let map: HashMap<_, _> = entries.into_iter().collect();
assert_eq!(
map.get("prompt_origin").cloned(),
Some("experimental".to_string())
);
}
#[test]
fn entries_include_prompt_origin_filename_for_file_origin() {
let mut cfg = minimal_config();
cfg.base_instructions = Some("hello".to_string());
let path = PathBuf::from("/tmp/custom_instructions.txt");
let entries = create_config_summary_entries(&cfg, Some(&PromptOrigin::File(path.clone())));
let map: HashMap<_, _> = entries.into_iter().collect();
assert_eq!(
map.get("prompt_origin").cloned(),
Some("custom_instructions.txt".to_string())
);
}
}

View File

@@ -27,6 +27,7 @@ use std::time::Instant;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::PromptOrigin;
use crate::event_processor::create_config_summary_entries;
use crate::event_processor::handle_last_message;
@@ -59,6 +60,7 @@ pub(crate) struct EventProcessorWithHumanOutput {
answer_started: bool,
reasoning_started: bool,
last_message_path: Option<PathBuf>,
prompt_origin: Option<PromptOrigin>,
}
impl EventProcessorWithHumanOutput {
@@ -66,6 +68,7 @@ impl EventProcessorWithHumanOutput {
with_ansi: bool,
config: &Config,
last_message_path: Option<PathBuf>,
prompt_origin: Option<PromptOrigin>,
) -> Self {
let call_id_to_command = HashMap::new();
let call_id_to_patch = HashMap::new();
@@ -87,6 +90,7 @@ impl EventProcessorWithHumanOutput {
answer_started: false,
reasoning_started: false,
last_message_path,
prompt_origin,
}
} else {
Self {
@@ -104,6 +108,7 @@ impl EventProcessorWithHumanOutput {
answer_started: false,
reasoning_started: false,
last_message_path,
prompt_origin,
}
}
}
@@ -150,7 +155,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
VERSION
);
let entries = create_config_summary_entries(config);
let entries = create_config_summary_entries(config, self.prompt_origin.as_ref());
for (key, value) in entries {
println!("{} {}", format!("{key}:").style(self.bold), value);

View File

@@ -9,22 +9,27 @@ use serde_json::json;
use crate::event_processor::CodexStatus;
use crate::event_processor::EventProcessor;
use crate::event_processor::PromptOrigin;
use crate::event_processor::create_config_summary_entries;
use crate::event_processor::handle_last_message;
pub(crate) struct EventProcessorWithJsonOutput {
last_message_path: Option<PathBuf>,
prompt_origin: Option<PromptOrigin>,
}
impl EventProcessorWithJsonOutput {
pub fn new(last_message_path: Option<PathBuf>) -> Self {
Self { last_message_path }
pub fn new(last_message_path: Option<PathBuf>, prompt_origin: Option<PromptOrigin>) -> Self {
Self {
last_message_path,
prompt_origin,
}
}
}
impl EventProcessor for EventProcessorWithJsonOutput {
fn print_config_summary(&mut self, config: &Config, prompt: &str) {
let entries = create_config_summary_entries(config)
let entries = create_config_summary_entries(config, self.prompt_origin.as_ref())
.into_iter()
.map(|(key, value)| (key.to_string(), value))
.collect::<HashMap<String, String>>();

View File

@@ -8,8 +8,10 @@ use std::io::Read;
use std::path::PathBuf;
use std::sync::Arc;
use crate::event_processor::PromptOrigin;
pub use cli::Cli;
use codex_core::codex_wrapper;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::{self};
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config_types::SandboxMode;
@@ -20,6 +22,7 @@ use codex_core::protocol::InputItem;
use codex_core::protocol::Op;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::util::is_inside_git_repo;
use codex_core::util::maybe_read_file;
use event_processor_with_human_output::EventProcessorWithHumanOutput;
use event_processor_with_json_output::EventProcessorWithJsonOutput;
use tracing::debug;
@@ -44,9 +47,38 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
json: json_mode,
sandbox_mode: sandbox_mode_cli_arg,
prompt,
experimental_instructions,
config_overrides,
} = cli;
// Determine how to describe experimental instructions in the summary and
// prepare the effective base instructions. If the flag points at a file,
// read its contents; otherwise use the value verbatim.
let mut prompt_origin = match experimental_instructions.as_deref() {
Some(val) => {
let p = std::path::Path::new(val);
if p.is_file() {
Some(PromptOrigin::File(p.to_path_buf()))
} else {
Some(PromptOrigin::Literal)
}
}
None => None,
};
let experimental_instructions = match experimental_instructions {
Some(val) => match maybe_read_file(&val) {
Ok(Some(contents)) => Some(contents),
Ok(None) => None,
Err(e) => {
eprintln!("Failed to read --experimental-instructions file: {e}");
std::process::exit(1);
}
},
None => None,
};
let has_experimental = experimental_instructions.is_some();
// Determine the prompt based on CLI arg and/or stdin.
let prompt = match prompt {
Some(p) if p != "-" => p,
@@ -110,7 +142,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
cwd: cwd.map(|p| p.canonicalize().unwrap_or(p)),
model_provider: None,
codex_linux_sandbox_exe,
base_instructions: None,
base_instructions: experimental_instructions,
};
// Parse `-c` overrides.
let cli_kv_overrides = match config_overrides.parse_overrides() {
@@ -122,13 +154,21 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
};
let config = Config::load_with_cli_overrides(cli_kv_overrides, overrides)?;
if !has_experimental {
prompt_origin = None;
}
let mut event_processor: Box<dyn EventProcessor> = if json_mode {
Box::new(EventProcessorWithJsonOutput::new(last_message_file.clone()))
Box::new(EventProcessorWithJsonOutput::new(
last_message_file.clone(),
prompt_origin.clone(),
))
} else {
Box::new(EventProcessorWithHumanOutput::create_with_ansi(
stdout_with_ansi,
&config,
last_message_file.clone(),
prompt_origin,
))
};
@@ -155,9 +195,14 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
.with_writer(std::io::stderr)
.try_init();
let (codex_wrapper, event, ctrl_c, _session_id) = codex_wrapper::init_codex(config).await?;
let CodexConversation {
codex: codex_wrapper,
session_configured,
ctrl_c,
..
} = codex_wrapper::init_codex(config).await?;
let codex = Arc::new(codex_wrapper);
info!("Codex initialized with event: {event:?}");
info!("Codex initialized with event: {session_configured:?}");
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
{
@@ -239,3 +284,53 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
Ok(())
}
#[cfg(test)]
mod tests {
use codex_core::util::maybe_read_file;
use std::fs;
use tempfile::NamedTempFile;
#[test]
fn maybe_read_file_returns_literal_for_non_path() {
let res = match maybe_read_file("You are a helpful assistant.") {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, Some("You are a helpful assistant.".to_string()));
}
#[test]
fn maybe_read_file_reads_and_trims_file_contents() {
let tf = match NamedTempFile::new() {
Ok(t) => t,
Err(e) => panic!("tempfile: {e}"),
};
if let Err(e) = fs::write(tf.path(), " Hello world\n") {
panic!("write temp file: {e}");
}
let path_s = tf.path().to_string_lossy().to_string();
let res = match maybe_read_file(&path_s) {
Ok(v) => v,
Err(e) => panic!("should read file successfully: {e}"),
};
assert_eq!(res, Some("Hello world".to_string()));
}
#[test]
fn maybe_read_file_empty_file_returns_none() {
let tf = match NamedTempFile::new() {
Ok(t) => t,
Err(e) => panic!("tempfile: {e}"),
};
if let Err(e) = fs::write(tf.path(), " \n\t ") {
panic!("write temp file: {e}");
}
let path_s = tf.path().to_string_lossy().to_string();
let res = match maybe_read_file(&path_s) {
Ok(v) => v,
Err(e) => panic!("should read file successfully: {e}"),
};
assert_eq!(res, None);
}
}

View File

@@ -10,6 +10,7 @@
//! This allows us to ship a completely separate set of functionality as part
//! of the `codex-exec` binary.
use clap::Parser;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
use codex_exec::Cli;
use codex_exec::run_main;
@@ -24,7 +25,7 @@ struct TopCli {
}
fn main() -> anyhow::Result<()> {
codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
let top_cli = TopCli::parse();
// Merge root-level overrides into inner CLI struct so downstream logic remains unchanged.
let mut inner = top_cli.inner;

View File

@@ -0,0 +1,38 @@
use anyhow::Context;
use assert_cmd::prelude::*;
use std::fs;
use std::process::Command;
use tempfile::tempdir;
/// While we may add an `apply-patch` subcommand to the `codex` CLI multitool
/// at some point, we must ensure that the smaller `codex-exec` CLI can still
/// emulate the `apply_patch` CLI.
#[test]
fn test_standalone_exec_cli_can_use_apply_patch() -> anyhow::Result<()> {
let tmp = tempdir()?;
let relative_path = "source.txt";
let absolute_path = tmp.path().join(relative_path);
fs::write(&absolute_path, "original content\n")?;
Command::cargo_bin("codex-exec")
.context("should find binary for codex-exec")?
.arg("--codex-run-as-apply-patch")
.arg(
r#"*** Begin Patch
*** Update File: source.txt
@@
-original content
+modified by apply_patch
*** End Patch"#,
)
.current_dir(tmp.path())
.assert()
.success()
.stdout("Success. Updated the following files:\nM source.txt\n")
.stderr(predicates::str::is_empty());
assert_eq!(
fs::read_to_string(absolute_path)?,
"modified by apply_patch\n"
);
Ok(())
}

View File

@@ -14,15 +14,16 @@ path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
[target.'cfg(target_os = "linux")'.dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
codex-common = { path = "../common", features = ["cli"] }
codex-core = { path = "../core" }
dotenvy = "0.15.7"
tokio = { version = "1", features = ["rt-multi-thread"] }
libc = "0.2.172"
landlock = "0.4.1"
seccompiler = "0.5.0"
[dev-dependencies]
[target.'cfg(target_os = "linux")'.dev-dependencies]
tempfile = "3"
tokio = { version = "1", features = [
"io-std",
@@ -31,8 +32,3 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2.172"
landlock = "0.4.1"
seccompiler = "0.5.0"

View File

@@ -4,72 +4,11 @@ mod landlock;
mod linux_run_main;
#[cfg(target_os = "linux")]
pub use linux_run_main::run_main;
use std::future::Future;
use std::path::PathBuf;
/// Helper that consolidates the common boilerplate found in several Codex
/// binaries (`codex`, `codex-exec`, `codex-tui`) around dispatching to the
/// `codex-linux-sandbox` sub-command.
///
/// When the current executable is invoked through the hard-link or alias
/// named `codex-linux-sandbox` we *directly* execute [`run_main`](crate::run_main)
/// (which never returns). Otherwise we:
/// 1. Construct a Tokio multi-thread runtime.
/// 2. Derive the path to the current executable (so children can re-invoke
/// the sandbox) when running on Linux.
/// 3. Execute the provided async `main_fn` inside that runtime, forwarding
/// any error.
///
/// This function eliminates duplicated code across the various `main.rs`
/// entry-points.
pub fn run_with_sandbox<F, Fut>(main_fn: F) -> anyhow::Result<()>
where
F: FnOnce(Option<PathBuf>) -> Fut,
Fut: Future<Output = anyhow::Result<()>>,
{
use std::path::Path;
// Determine if we were invoked via the special alias.
let argv0 = std::env::args().next().unwrap_or_default();
let exe_name = Path::new(&argv0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("");
if exe_name == "codex-linux-sandbox" {
// Safety: [`run_main`] never returns.
crate::run_main();
}
// This modifies the environment, which is not thread-safe, so do this
// before creating any threads/the Tokio runtime.
load_dotenv();
// Regular invocation create a Tokio runtime and execute the provided
// async entry-point.
let runtime = tokio::runtime::Runtime::new()?;
runtime.block_on(async move {
let codex_linux_sandbox_exe: Option<PathBuf> = if cfg!(target_os = "linux") {
std::env::current_exe().ok()
} else {
None
};
main_fn(codex_linux_sandbox_exe).await
})
pub fn run_main() -> ! {
linux_run_main::run_main();
}
#[cfg(not(target_os = "linux"))]
pub fn run_main() -> ! {
panic!("codex-linux-sandbox is only supported on Linux");
}
/// Load env vars from ~/.codex/.env and `$(pwd)/.env`.
fn load_dotenv() {
if let Ok(codex_home) = codex_core::config::find_codex_home() {
dotenvy::from_path(codex_home.join(".env")).ok();
}
dotenvy::dotenv().ok();
}

View File

@@ -10,6 +10,7 @@
//! program. The utility connects, issues a `tools/list` request and prints the
//! server's response as pretty JSON.
use std::ffi::OsString;
use std::time::Duration;
use anyhow::Context;
@@ -37,7 +38,7 @@ async fn main() -> Result<()> {
.try_init();
// Collect command-line arguments excluding the program name itself.
let mut args: Vec<String> = std::env::args().skip(1).collect();
let mut args: Vec<OsString> = std::env::args_os().skip(1).collect();
if args.is_empty() || args[0] == "--help" || args[0] == "-h" {
eprintln!("Usage: mcp-client <program> [args..]\n\nExample: mcp-client codex-mcp-server");

View File

@@ -12,6 +12,7 @@
//! issue requests and receive strongly-typed results.
use std::collections::HashMap;
use std::ffi::OsString;
use std::sync::Arc;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
@@ -82,8 +83,8 @@ impl McpClient {
/// Caller is responsible for sending the `initialize` request. See
/// [`initialize`](Self::initialize) for details.
pub async fn new_stdio_client(
program: String,
args: Vec<String>,
program: OsString,
args: Vec<OsString>,
env: Option<HashMap<String, String>>,
) -> std::io::Result<Self> {
let mut child = Command::new(program)

View File

@@ -16,8 +16,8 @@ workspace = true
[dependencies]
anyhow = "1"
codex-arg0 = { path = "../arg0" }
codex-core = { path = "../core" }
codex-linux-sandbox = { path = "../linux-sandbox" }
mcp-types = { path = "../mcp-types" }
schemars = "0.8.22"
serde = { version = "1", features = ["derive"] }

View File

@@ -6,6 +6,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use codex_core::Codex;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config as CodexConfig;
use codex_core::protocol::AgentMessageEvent;
@@ -26,6 +27,7 @@ use uuid::Uuid;
use crate::exec_approval::handle_exec_approval_request;
use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::OutgoingNotificationMeta;
use crate::patch_approval::handle_patch_approval_request;
pub(crate) const INVALID_PARAMS_ERROR_CODE: i64 = -32602;
@@ -42,7 +44,12 @@ pub async fn run_codex_tool_session(
session_map: Arc<Mutex<HashMap<Uuid, Arc<Codex>>>>,
running_requests_id_to_codex_uuid: Arc<Mutex<HashMap<RequestId, Uuid>>>,
) {
let (codex, first_event, _ctrl_c, session_id) = match init_codex(config).await {
let CodexConversation {
codex,
session_configured,
session_id,
..
} = match init_codex(config).await {
Ok(res) => res,
Err(e) => {
let result = CallToolResult {
@@ -65,8 +72,12 @@ pub async fn run_codex_tool_session(
session_map.lock().await.insert(session_id, codex.clone());
drop(session_map);
// Send initial SessionConfigured event.
outgoing.send_event_as_notification(&first_event).await;
outgoing
.send_event_as_notification(
&session_configured,
Some(OutgoingNotificationMeta::new(Some(id.clone()))),
)
.await;
// Use the original MCP request ID as the `sub_id` for the Codex submission so that
// any events emitted for this tool-call can be correlated with the
@@ -150,7 +161,12 @@ async fn run_codex_tool_session_inner(
loop {
match codex.next_event().await {
Ok(event) => {
outgoing.send_event_as_notification(&event).await;
outgoing
.send_event_as_notification(
&event,
Some(OutgoingNotificationMeta::new(Some(request_id.clone()))),
)
.await;
match event.msg {
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {

View File

@@ -13,6 +13,7 @@ use tokio::sync::mpsc;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing_subscriber::EnvFilter;
mod codex_tool_config;
mod codex_tool_runner;
@@ -43,6 +44,7 @@ pub async fn run_main(codex_linux_sandbox_exe: Option<PathBuf>) -> IoResult<()>
// control the log level with `RUST_LOG`.
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_env_filter(EnvFilter::from_default_env())
.init();
// Set up channels.

View File

@@ -1,7 +1,8 @@
use codex_arg0::arg0_dispatch_or_else;
use codex_mcp_server::run_main;
fn main() -> anyhow::Result<()> {
codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
run_main(codex_linux_sandbox_exe).await?;
Ok(())
})

View File

@@ -18,6 +18,7 @@ use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tracing::warn;
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_request_id: AtomicI64,
sender: mpsc::Sender<OutgoingMessage>,
@@ -78,16 +79,47 @@ impl OutgoingMessageSender {
let _ = self.sender.send(outgoing_message).await;
}
pub(crate) async fn send_event_as_notification(&self, event: &Event) {
#[expect(clippy::expect_used)]
let params = Some(serde_json::to_value(event).expect("Event must serialize"));
pub(crate) async fn send_event_as_notification(
&self,
event: &Event,
meta: Option<OutgoingNotificationMeta>,
) {
#[allow(clippy::expect_used)]
let event_json = serde_json::to_value(event).expect("Event must serialize");
let params = if let Ok(params) = serde_json::to_value(OutgoingNotificationParams {
meta,
event: event_json.clone(),
}) {
params
} else {
warn!("Failed to serialize event as OutgoingNotificationParams");
event_json
};
let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
method: "codex/event".to_string(),
params: Some(params.clone()),
});
let _ = self.sender.send(outgoing_message).await;
self.send_event_as_notification_new_schema(event, Some(params.clone()))
.await;
}
// should be backwards compatible.
// it will replace send_event_as_notification eventually.
async fn send_event_as_notification_new_schema(
&self,
event: &Event,
params: Option<serde_json::Value>,
) {
let outgoing_message = OutgoingMessage::Notification(OutgoingNotification {
method: event.msg.to_string(),
params,
});
let _ = self.sender.send(outgoing_message).await;
}
pub(crate) async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) {
let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error });
let _ = self.sender.send(outgoing_message).await;
@@ -152,6 +184,30 @@ pub(crate) struct OutgoingNotification {
pub params: Option<serde_json::Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingNotificationParams {
#[serde(rename = "_meta", default, skip_serializing_if = "Option::is_none")]
pub meta: Option<OutgoingNotificationMeta>,
#[serde(flatten)]
pub event: serde_json::Value,
}
// Additional mcp-specific data to be added to a [`codex_core::protocol::Event`] as notification.params._meta
// MCP Spec: https://modelcontextprotocol.io/specification/2025-06-18/basic#meta
// Typescript Schema: https://github.com/modelcontextprotocol/modelcontextprotocol/blob/0695a497eb50a804fc0e88c18a93a21a675d6b3e/schema/2025-06-18/schema.ts
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OutgoingNotificationMeta {
pub request_id: Option<RequestId>,
}
impl OutgoingNotificationMeta {
pub(crate) fn new(request_id: Option<RequestId>) -> Self {
Self { request_id }
}
}
#[derive(Debug, Clone, PartialEq, Serialize)]
pub(crate) struct OutgoingResponse {
pub id: RequestId,
@@ -163,3 +219,113 @@ pub(crate) struct OutgoingError {
pub error: JSONRPCErrorError,
pub id: RequestId,
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use codex_core::protocol::EventMsg;
use codex_core::protocol::SessionConfiguredEvent;
use pretty_assertions::assert_eq;
use serde_json::json;
use uuid::Uuid;
use super::*;
#[tokio::test]
async fn test_send_event_as_notification() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingMessage>(2);
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let event = Event {
id: "1".to_string(),
msg: EventMsg::SessionConfigured(SessionConfiguredEvent {
session_id: Uuid::new_v4(),
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
}),
};
outgoing_message_sender
.send_event_as_notification(&event, None)
.await;
let result = outgoing_rx.recv().await.unwrap();
let OutgoingMessage::Notification(OutgoingNotification { method, params }) = result else {
panic!("expected Notification for first message");
};
assert_eq!(method, "codex/event");
let Ok(expected_params) = serde_json::to_value(&event) else {
panic!("Event must serialize");
};
assert_eq!(params, Some(expected_params.clone()));
let result2 = outgoing_rx.recv().await.unwrap();
let OutgoingMessage::Notification(OutgoingNotification {
method: method2,
params: params2,
}) = result2
else {
panic!("expected Notification for second message");
};
assert_eq!(method2, event.msg.to_string());
assert_eq!(params2, Some(expected_params));
}
#[tokio::test]
async fn test_send_event_as_notification_with_meta() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingMessage>(2);
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let session_configured_event = SessionConfiguredEvent {
session_id: Uuid::new_v4(),
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
};
let event = Event {
id: "1".to_string(),
msg: EventMsg::SessionConfigured(session_configured_event.clone()),
};
let meta = OutgoingNotificationMeta {
request_id: Some(RequestId::String("123".to_string())),
};
outgoing_message_sender
.send_event_as_notification(&event, Some(meta))
.await;
let result = outgoing_rx.recv().await.unwrap();
let OutgoingMessage::Notification(OutgoingNotification { method, params }) = result else {
panic!("expected Notification for first message");
};
assert_eq!(method, "codex/event");
let expected_params = json!({
"_meta": {
"requestId": "123",
},
"id": "1",
"msg": {
"session_id": session_configured_event.session_id,
"model": session_configured_event.model,
"history_log_id": session_configured_event.history_log_id,
"history_entry_count": session_configured_event.history_entry_count,
"type": "session_configured",
}
});
assert_eq!(params.unwrap(), expected_params);
let result2 = outgoing_rx.recv().await.unwrap();
let OutgoingMessage::Notification(OutgoingNotification {
method: method2,
params: params2,
}) = result2
else {
panic!("expected Notification for second message");
};
assert_eq!(method2, event.msg.to_string());
assert_eq!(params2.unwrap(), expected_params);
}
}

View File

@@ -270,27 +270,49 @@ impl McpProcess {
pub async fn read_stream_until_configured_response_message(
&mut self,
) -> anyhow::Result<String> {
let mut sid_old: Option<String> = None;
let mut sid_new: Option<String> = None;
loop {
let message = self.read_jsonrpc_message().await?;
eprint!("message: {message:?}");
match message {
JSONRPCMessage::Notification(notification) => {
if notification.method == "codex/event" {
if let Some(params) = notification.params {
if let Some(params) = notification.params {
// Back-compat schema: method == "codex/event" and msg.type == "session_configured"
if notification.method == "codex/event" {
if let Some(msg) = params.get("msg") {
if let Some(msg_type) = msg.get("type") {
if msg_type == "session_configured" {
if let Some(session_id) = msg.get("session_id") {
return Ok(session_id
.to_string()
.trim_matches('"')
.to_string());
}
if msg.get("type").and_then(|v| v.as_str())
== Some("session_configured")
{
if let Some(session_id) =
msg.get("session_id").and_then(|v| v.as_str())
{
sid_old = Some(session_id.to_string());
}
}
}
}
// New schema: method is the Display of EventMsg::SessionConfigured => "SessionConfigured"
if notification.method == "session_configured" {
if let Some(msg) = params.get("msg") {
if let Some(session_id) =
msg.get("session_id").and_then(|v| v.as_str())
{
sid_new = Some(session_id.to_string());
}
}
}
}
if sid_old.is_some() && sid_new.is_some() {
// Both seen, they must match
assert_eq!(
sid_old.as_ref().unwrap(),
sid_new.as_ref().unwrap(),
"session_id mismatch between old and new schema"
);
return Ok(sid_old.unwrap());
}
}
JSONRPCMessage::Request(_) => {

View File

@@ -19,6 +19,7 @@ anyhow = "1"
base64 = "0.22.1"
clap = { version = "4", features = ["derive"] }
codex-ansi-escape = { path = "../ansi-escape" }
codex-arg0 = { path = "../arg0" }
codex-core = { path = "../core" }
codex-common = { path = "../common", features = [
"cli",
@@ -26,7 +27,6 @@ codex-common = { path = "../common", features = [
"sandbox_summary",
] }
codex-file-search = { path = "../file-search" }
codex-linux-sandbox = { path = "../linux-sandbox" }
codex-login = { path = "../login" }
color-eyre = "0.6.3"
crossterm = { version = "0.28.1", features = ["bracketed-paste"] }
@@ -35,8 +35,9 @@ lazy_static = "1"
mcp-types = { path = "../mcp-types" }
path-clean = "1.0.1"
ratatui = { version = "0.29.0", features = [
"unstable-widget-ref",
"scrolling-regions",
"unstable-rendered-line-info",
"unstable-widget-ref",
] }
ratatui-image = "8.0.0"
regex-lite = "0.1"
@@ -64,3 +65,4 @@ uuid = "1"
[dev-dependencies]
insta = "1.43.1"
pretty_assertions = "1"
tempfile = "3.13.0"

View File

@@ -68,6 +68,7 @@ struct ChatWidgetArgs {
config: Config,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
prompt_label: Option<String>,
}
impl App<'_> {
@@ -77,6 +78,7 @@ impl App<'_> {
show_login_screen: bool,
show_git_warning: bool,
initial_images: Vec<std::path::PathBuf>,
prompt_label: Option<String>,
) -> Self {
let (app_event_tx, app_event_rx) = channel();
let app_event_tx = AppEventSender::new(app_event_tx);
@@ -88,32 +90,51 @@ impl App<'_> {
{
let app_event_tx = app_event_tx.clone();
std::thread::spawn(move || {
while let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
crossterm::event::Event::Resize(_, _) => {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
..
}) => {
scroll_event_helper.scroll_up();
}
crossterm::event::Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
..
}) => {
scroll_event_helper.scroll_down();
}
crossterm::event::Event::Paste(pasted) => {
app_event_tx.send(AppEvent::Paste(pasted));
}
_ => {
// Ignore any other events.
loop {
// This timeout is necessary to avoid holding the event lock
// that crossterm::event::read() acquires. In particular,
// reading the cursor position (crossterm::cursor::position())
// needs to acquire the event lock, and so will fail if it
// can't acquire it within 2 sec. Resizing the terminal
// crashes the app if the cursor position can't be read.
if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) {
if let Ok(event) = crossterm::event::read() {
match event {
crossterm::event::Event::Key(key_event) => {
app_event_tx.send(AppEvent::KeyEvent(key_event));
}
crossterm::event::Event::Resize(_, _) => {
app_event_tx.send(AppEvent::RequestRedraw);
}
crossterm::event::Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollUp,
..
}) => {
scroll_event_helper.scroll_up();
}
crossterm::event::Event::Mouse(MouseEvent {
kind: MouseEventKind::ScrollDown,
..
}) => {
scroll_event_helper.scroll_down();
}
crossterm::event::Event::Paste(pasted) => {
// Many terminals convert newlines to \r when
// pasting, e.g. [iTerm2][]. But [tui-textarea
// expects \n][tui-textarea]. This seems like a bug
// in tui-textarea IMO, but work around it for now.
// [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783
// [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216
let pasted = pasted.replace("\r", "\n");
app_event_tx.send(AppEvent::Paste(pasted));
}
_ => {
// Ignore any other events.
}
}
}
} else {
// Timeout expired, no `Event` is available
}
}
});
@@ -128,6 +149,7 @@ impl App<'_> {
config: config.clone(),
initial_prompt,
initial_images,
prompt_label: prompt_label.clone(),
}),
)
} else if show_git_warning {
@@ -139,6 +161,7 @@ impl App<'_> {
config: config.clone(),
initial_prompt,
initial_images,
prompt_label: prompt_label.clone(),
}),
)
} else {
@@ -147,6 +170,7 @@ impl App<'_> {
app_event_tx.clone(),
initial_prompt,
initial_images,
prompt_label.clone(),
);
(
AppState::Chat {
@@ -282,6 +306,7 @@ impl App<'_> {
self.app_event_tx.clone(),
None,
Vec::new(),
None,
));
self.app_state = AppState::Chat { widget: new_widget };
self.app_event_tx.send(AppEvent::RequestRedraw);
@@ -373,6 +398,7 @@ impl App<'_> {
self.app_event_tx.clone(),
args.initial_prompt,
args.initial_images,
args.prompt_label,
));
self.app_state = AppState::Chat { widget };
self.app_event_tx.send(AppEvent::RequestRedraw);

View File

@@ -9,6 +9,7 @@ use crate::user_approval_widget::UserApprovalWidget;
use super::BottomPane;
use super::BottomPaneView;
use super::CancellationEvent;
/// Modal overlay asking the user to approve/deny a sequence of requests.
pub(crate) struct ApprovalModalView<'a> {
@@ -46,6 +47,12 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
self.maybe_advance();
}
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
self.current.on_ctrl_c();
self.queue.clear();
CancellationEvent::Handled
}
fn is_complete(&self) -> bool {
self.current.is_complete() && self.queue.is_empty()
}
@@ -59,3 +66,39 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use std::path::PathBuf;
use std::sync::mpsc::channel;
fn make_exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "test".to_string(),
command: vec!["echo".to_string(), "hi".to_string()],
cwd: PathBuf::from("/tmp"),
reason: None,
}
}
#[test]
fn ctrl_c_aborts_and_clears_queue() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let first = make_exec_request();
let mut view = ApprovalModalView::new(first, tx);
view.enqueue_request(make_exec_request());
let (tx_raw2, _rx2) = channel::<AppEvent>();
let mut pane = BottomPane::new(super::super::BottomPaneParams {
app_event_tx: AppEventSender::new(tx_raw2),
has_input_focus: true,
});
assert_eq!(CancellationEvent::Handled, view.on_ctrl_c(&mut pane));
assert!(view.queue.is_empty());
assert!(view.current.is_complete());
assert!(view.is_complete());
}
}

View File

@@ -4,6 +4,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::BottomPane;
use super::CancellationEvent;
/// Type to use for a method that may require a redraw of the UI.
pub(crate) enum ConditionalUpdate {
@@ -22,6 +23,11 @@ pub(crate) trait BottomPaneView<'a> {
false
}
/// Handle Ctrl-C while this view is active.
fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent {
CancellationEvent::Ignored
}
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);

View File

@@ -127,10 +127,6 @@ impl ChatComposer<'_> {
.on_entry_response(log_id, offset, entry, &mut self.textarea)
}
pub fn set_input_focus(&mut self, has_focus: bool) {
self.update_border(has_focus);
}
pub fn handle_paste(&mut self, pasted: String) -> bool {
let char_count = pasted.chars().count();
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
@@ -638,13 +634,6 @@ impl ChatComposer<'_> {
.border_style(bs.border_style),
);
}
pub(crate) fn is_popup_visible(&self) -> bool {
match self.active_popup {
ActivePopup::Command(_) | ActivePopup::File(_) => true,
ActivePopup::None => false,
}
}
}
impl WidgetRef for &ChatComposer<'_> {

View File

@@ -20,6 +20,12 @@ mod command_popup;
mod file_search_popup;
mod status_indicator_view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum CancellationEvent {
Ignored,
Handled,
}
pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult;
@@ -80,6 +86,33 @@ impl BottomPane<'_> {
}
}
/// Handle Ctrl-C in the bottom pane. If a modal view is active it gets a
/// chance to consume the event (e.g. to dismiss itself).
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
let mut view = match self.active_view.take() {
Some(view) => view,
None => return CancellationEvent::Ignored,
};
let event = view.on_ctrl_c(self);
match event {
CancellationEvent::Handled => {
if !view.is_complete() {
self.active_view = Some(view);
} else if self.is_task_running {
self.active_view = Some(Box::new(StatusIndicatorView::new(
self.app_event_tx.clone(),
)));
}
self.show_ctrl_c_quit_hint();
}
CancellationEvent::Ignored => {
self.active_view = Some(view);
}
}
event
}
pub fn handle_paste(&mut self, pasted: String) {
if self.active_view.is_none() {
let needs_redraw = self.composer.handle_paste(pasted);
@@ -104,12 +137,6 @@ impl BottomPane<'_> {
}
}
/// Update the UI to reflect whether this `BottomPane` has input focus.
pub(crate) fn set_input_focus(&mut self, has_focus: bool) {
self.has_input_focus = has_focus;
self.composer.set_input_focus(has_focus);
}
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
self.ctrl_c_quit_hint = true;
self.composer
@@ -203,11 +230,6 @@ impl BottomPane<'_> {
self.app_event_tx.send(AppEvent::RequestRedraw)
}
/// Returns true when a popup inside the composer is visible.
pub(crate) fn is_popup_visible(&self) -> bool {
self.active_view.is_none() && self.composer.is_popup_visible()
}
// --- History helpers ---
pub(crate) fn set_history_metadata(&mut self, log_id: u64, entry_count: usize) {
@@ -245,3 +267,34 @@ impl WidgetRef for &BottomPane<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app_event::AppEvent;
use std::path::PathBuf;
use std::sync::mpsc::channel;
fn exec_request() -> ApprovalRequest {
ApprovalRequest::Exec {
id: "1".to_string(),
command: vec!["echo".into(), "ok".into()],
cwd: PathBuf::from("."),
reason: None,
}
}
#[test]
fn ctrl_c_on_modal_consumes_and_shows_quit_hint() {
let (tx_raw, _rx) = channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut pane = BottomPane::new(BottomPaneParams {
app_event_tx: tx,
has_input_focus: true,
});
pane.push_approval_request(exec_request());
assert_eq!(CancellationEvent::Handled, pane.on_ctrl_c());
assert!(pane.ctrl_c_quit_hint_visible());
assert_eq!(CancellationEvent::Ignored, pane.on_ctrl_c());
}
}

View File

@@ -1,6 +1,7 @@
use std::path::PathBuf;
use std::sync::Arc;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config;
use codex_core::protocol::AgentMessageDeltaEvent;
@@ -33,8 +34,10 @@ use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPane;
use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::conversation_history_widget::ConversationHistoryWidget;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell::PatchEventType;
use crate::user_approval_widget::ApprovalRequest;
use codex_file_search::FileMatch;
@@ -44,7 +47,6 @@ pub(crate) struct ChatWidget<'a> {
codex_op_tx: UnboundedSender<Op>,
conversation_history: ConversationHistoryWidget,
bottom_pane: BottomPane<'a>,
input_focus: InputFocus,
config: Config,
initial_user_message: Option<UserMessage>,
token_usage: TokenUsage,
@@ -53,12 +55,7 @@ pub(crate) struct ChatWidget<'a> {
// We wait for the final AgentMessage event and then emit the full text
// at once into scrollback so the history contains a single message.
answer_buffer: String,
}
#[derive(Clone, Copy, Eq, PartialEq)]
enum InputFocus {
HistoryPane,
BottomPane,
prompt_label: Option<String>,
}
struct UserMessage {
@@ -89,6 +86,7 @@ impl ChatWidget<'_> {
app_event_tx: AppEventSender,
initial_prompt: Option<String>,
initial_images: Vec<PathBuf>,
prompt_label: Option<String>,
) -> Self {
let (codex_op_tx, mut codex_op_rx) = unbounded_channel::<Op>();
@@ -96,19 +94,22 @@ impl ChatWidget<'_> {
// Create the Codex asynchronously so the UI loads as quickly as possible.
let config_for_agent_loop = config.clone();
tokio::spawn(async move {
let (codex, session_event, _ctrl_c, _session_id) =
match init_codex(config_for_agent_loop).await {
Ok(vals) => vals,
Err(e) => {
// TODO: surface this error to the user.
tracing::error!("failed to initialize codex: {e}");
return;
}
};
let CodexConversation {
codex,
session_configured,
..
} = match init_codex(config_for_agent_loop).await {
Ok(vals) => vals,
Err(e) => {
// TODO: surface this error to the user.
tracing::error!("failed to initialize codex: {e}");
return;
}
};
// Forward the captured `SessionInitialized` event that was consumed
// inside `init_codex()` so it can be rendered in the UI.
app_event_tx_clone.send(AppEvent::CodexEvent(session_event.clone()));
app_event_tx_clone.send(AppEvent::CodexEvent(session_configured.clone()));
let codex = Arc::new(codex);
let codex_clone = codex.clone();
tokio::spawn(async move {
@@ -133,7 +134,6 @@ impl ChatWidget<'_> {
app_event_tx,
has_input_focus: true,
}),
input_focus: InputFocus::BottomPane,
config,
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
@@ -142,49 +142,23 @@ impl ChatWidget<'_> {
token_usage: TokenUsage::default(),
reasoning_buffer: String::new(),
answer_buffer: String::new(),
prompt_label,
}
}
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) {
self.bottom_pane.clear_ctrl_c_quit_hint();
// Special-case <Tab>: normally toggles focus between history and bottom panes.
// However, when the slash-command popup is visible we forward the key
// to the bottom pane so it can handle auto-completion.
if matches!(key_event.code, crossterm::event::KeyCode::Tab)
&& !self.bottom_pane.is_popup_visible()
{
self.input_focus = match self.input_focus {
InputFocus::HistoryPane => InputFocus::BottomPane,
InputFocus::BottomPane => InputFocus::HistoryPane,
};
self.conversation_history
.set_input_focus(self.input_focus == InputFocus::HistoryPane);
self.bottom_pane
.set_input_focus(self.input_focus == InputFocus::BottomPane);
self.request_redraw();
return;
}
match self.input_focus {
InputFocus::HistoryPane => {
let needs_redraw = self.conversation_history.handle_key_event(key_event);
if needs_redraw {
self.request_redraw();
}
match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
self.submit_user_message(text.into());
}
InputFocus::BottomPane => match self.bottom_pane.handle_key_event(key_event) {
InputResult::Submitted(text) => {
self.submit_user_message(text.into());
}
InputResult::None => {}
},
InputResult::None => {}
}
}
pub(crate) fn handle_paste(&mut self, text: String) {
if matches!(self.input_focus, InputFocus::BottomPane) {
self.bottom_pane.handle_paste(text);
}
self.bottom_pane.handle_paste(text);
}
/// Emits the last entry's plain lines from conversation_history, if any.
@@ -238,8 +212,11 @@ impl ChatWidget<'_> {
match msg {
EventMsg::SessionConfigured(event) => {
// Record session information at the top of the conversation.
self.conversation_history
.add_session_info(&self.config, event.clone());
self.conversation_history.add_session_info(
&self.config,
event.clone(),
self.prompt_label.as_deref(),
);
// Immediately surface the session banner / settings summary in
// scrollback so the user can review configuration (model,
// sandbox, approvals, etc.) before interacting.
@@ -332,6 +309,20 @@ impl ChatWidget<'_> {
cwd,
reason,
}) => {
// Print the command to the history so it is visible in the
// transcript *before* the modal asks for approval.
let cmdline = strip_bash_lc_and_escape(&command);
let text = format!(
"command requires approval:\n$ {cmdline}{reason}",
reason = reason
.as_ref()
.map(|r| format!("\n{r}"))
.unwrap_or_default()
);
self.conversation_history.add_background_event(text);
self.emit_last_history_entry();
self.conversation_history.scroll_to_bottom();
let request = ApprovalRequest::Exec {
id,
command,
@@ -339,6 +330,7 @@ impl ChatWidget<'_> {
reason,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
}
EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: _,
@@ -484,21 +476,25 @@ impl ChatWidget<'_> {
}
/// Handle Ctrl-C key press.
/// Returns true if the key press was handled, false if it was not.
/// If the key press was not handled, the caller should handle it (likely by exiting the process).
pub(crate) fn on_ctrl_c(&mut self) -> bool {
/// Returns CancellationEvent::Handled if the event was consumed by the UI, or
/// CancellationEvent::Ignored if the caller should handle it (e.g. exit).
pub(crate) fn on_ctrl_c(&mut self) -> CancellationEvent {
match self.bottom_pane.on_ctrl_c() {
CancellationEvent::Handled => return CancellationEvent::Handled,
CancellationEvent::Ignored => {}
}
if self.bottom_pane.is_task_running() {
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.answer_buffer.clear();
self.reasoning_buffer.clear();
false
CancellationEvent::Ignored
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
self.submit_op(Op::Shutdown);
true
CancellationEvent::Handled
} else {
self.bottom_pane.show_ctrl_c_quit_hint();
false
CancellationEvent::Ignored
}
}

View File

@@ -53,4 +53,38 @@ pub struct Cli {
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
/// Override the built-in system prompt (base instructions).
///
/// If the value looks like a path to an existing file, the contents of the
/// file are used. Otherwise, the value itself is used verbatim as the
/// instructions string.
#[arg(long = "experimental-instructions")]
pub experimental_instructions: Option<String>,
}
#[cfg(test)]
mod tests {
use super::Cli;
use clap::CommandFactory;
#[test]
fn help_includes_file_behavior_for_experimental_instructions() {
let mut cmd = Cli::command();
let mut buf: Vec<u8> = Vec::new();
assert!(cmd.write_long_help(&mut buf).is_ok(), "help should render");
let help = match String::from_utf8(buf) {
Ok(s) => s,
Err(e) => panic!("invalid utf8: {e}"),
};
assert!(help.contains("Override the built-in system prompt (base instructions)."));
assert!(help.contains(
"If the value looks like a path to an existing file, the contents of the file are used."
));
assert!(
help.contains(
"Otherwise, the value itself is used verbatim as the instructions string."
)
);
}
}

View File

@@ -5,8 +5,6 @@ use crate::history_cell::PatchEventType;
use codex_core::config::Config;
use codex_core::protocol::FileChange;
use codex_core::protocol::SessionConfiguredEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use ratatui::prelude::*;
use ratatui::style::Style;
use ratatui::widgets::*;
@@ -47,33 +45,6 @@ impl ConversationHistoryWidget {
}
}
pub(crate) fn set_input_focus(&mut self, has_input_focus: bool) {
self.has_input_focus = has_input_focus;
}
/// Returns true if it needs a redraw.
pub(crate) fn handle_key_event(&mut self, key_event: KeyEvent) -> bool {
match key_event.code {
KeyCode::Up | KeyCode::Char('k') => {
self.scroll_up(1);
true
}
KeyCode::Down | KeyCode::Char('j') => {
self.scroll_down(1);
true
}
KeyCode::PageUp | KeyCode::Char('b') => {
self.scroll_page_up();
true
}
KeyCode::PageDown | KeyCode::Char(' ') => {
self.scroll_page_down();
true
}
_ => false,
}
}
/// Negative delta scrolls up; positive delta scrolls down.
pub(crate) fn scroll(&mut self, delta: i32) {
match delta.cmp(&0) {
@@ -122,60 +93,18 @@ impl ConversationHistoryWidget {
}
}
/// Scroll up by one full viewport height (Page Up).
fn scroll_page_up(&mut self) {
let viewport_height = self.last_viewport_height.get().max(1);
// If we are currently in the "stick to bottom" mode, first convert the
// implicit scroll position (`usize::MAX`) into an explicit offset that
// represents the very bottom of the scroll region. This mirrors the
// logic from `scroll_up()`.
if self.scroll_position == usize::MAX {
self.scroll_position = self
.num_rendered_lines
.get()
.saturating_sub(viewport_height);
}
// Move up by a full page.
self.scroll_position = self.scroll_position.saturating_sub(viewport_height);
}
/// Scroll down by one full viewport height (Page Down).
fn scroll_page_down(&mut self) {
// Nothing to do if we're already stuck to the bottom.
if self.scroll_position == usize::MAX {
return;
}
let viewport_height = self.last_viewport_height.get().max(1);
let num_lines = self.num_rendered_lines.get();
// Calculate the maximum explicit scroll offset that is still within
// range. This matches the logic in `scroll_down()` and the render
// method.
let max_scroll = num_lines.saturating_sub(viewport_height);
// Attempt to move down by a full page.
let new_pos = self.scroll_position.saturating_add(viewport_height);
if new_pos >= max_scroll {
// We have reached (or passed) the bottom switch back to
// automatic sticktobottom mode so that subsequent output keeps
// the viewport pinned.
self.scroll_position = usize::MAX;
} else {
self.scroll_position = new_pos;
}
}
pub fn scroll_to_bottom(&mut self) {
self.scroll_position = usize::MAX;
}
/// 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, event: SessionConfiguredEvent) {
pub fn add_session_info(
&mut self,
config: &Config,
event: SessionConfiguredEvent,
prompt_label: Option<&str>,
) {
// In practice, SessionConfiguredEvent should always be the first entry
// in the history, but it is possible that an error could be sent
// before the session info.
@@ -187,6 +116,7 @@ impl ConversationHistoryWidget {
config,
event,
!has_welcome_message,
prompt_label,
));
}

View File

@@ -151,6 +151,7 @@ impl HistoryCell {
config: &Config,
event: SessionConfiguredEvent,
is_first_event: bool,
prompt_label: Option<&str>,
) -> Self {
let SessionConfiguredEvent {
model,
@@ -183,6 +184,9 @@ impl HistoryCell {
("approval", config.approval_policy.to_string()),
("sandbox", summarize_sandbox_policy(&config.sandbox_policy)),
];
if let Some(label) = prompt_label {
entries.push(("prompt", label.to_string()));
}
if config.model_provider.wire_api == WireApi::Responses
&& model_supports_reasoning_summaries(config)
{
@@ -581,6 +585,88 @@ impl HistoryCell {
}
}
#[cfg(test)]
mod tests {
use super::*;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
use uuid::Uuid;
use tempfile::TempDir;
fn minimal_config() -> Config {
let cwd = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let codex_home = match TempDir::new() {
Ok(t) => t,
Err(e) => panic!("tempdir error: {e}"),
};
let cfg = ConfigToml {
..Default::default()
};
let overrides = ConfigOverrides {
cwd: Some(cwd.path().to_path_buf()),
..Default::default()
};
match Config::load_from_base_config_with_overrides(
cfg,
overrides,
codex_home.path().to_path_buf(),
) {
Ok(c) => c,
Err(e) => panic!("config error: {e}"),
}
}
fn lines_to_strings(lines: &[Line<'static>]) -> Vec<String> {
lines
.iter()
.map(|line| line.spans.iter().map(|s| s.content.to_string()).collect())
.collect()
}
#[test]
fn welcome_includes_prompt_label_experimental() {
let cfg = minimal_config();
let event = SessionConfiguredEvent {
session_id: Uuid::nil(),
model: cfg.model.clone(),
history_log_id: 0,
history_entry_count: 0,
};
let cell = HistoryCell::new_session_info(&cfg, event, true, Some("experimental"));
let lines = cell.plain_lines();
let strings = lines_to_strings(&lines);
assert!(
strings.iter().any(|s| s.contains("prompt: experimental")),
"welcome should include prompt label; got: {strings:?}"
);
}
#[test]
fn welcome_includes_prompt_label_filename() {
let cfg = minimal_config();
let event = SessionConfiguredEvent {
session_id: Uuid::nil(),
model: cfg.model.clone(),
history_log_id: 0,
history_entry_count: 0,
};
let cell = HistoryCell::new_session_info(&cfg, event, true, Some("instructions.md"));
let lines = cell.plain_lines();
let strings = lines_to_strings(&lines);
assert!(
strings
.iter()
.any(|s| s.contains("prompt: instructions.md")),
"welcome should include filename prompt label; got: {strings:?}"
);
}
}
// ---------------------------------------------------------------------------
// `CellWidget` implementation most variants delegate to their internal
// `TextBlock`. Variants that need custom painting can add their own logic in

View File

@@ -1,178 +1,245 @@
use std::fmt;
use std::io;
use std::io::Write;
use crate::tui;
use ratatui::layout::Rect;
use ratatui::style::Style;
use crossterm::Command;
use crossterm::queue;
use crossterm::style::Color as CColor;
use crossterm::style::Colors;
use crossterm::style::Print;
use crossterm::style::SetAttribute;
use crossterm::style::SetBackgroundColor;
use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use ratatui::layout::Position;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use unicode_width::UnicodeWidthChar;
/// Insert a batch of history lines into the terminal scrollback above the
/// inline viewport.
///
/// The incoming `lines` are the logical lines supplied by the
/// `ConversationHistory`. They may contain embedded newlines and arbitrary
/// runs of whitespace inside individual [`Span`]s. All of that must be
/// normalised before writing to the backing terminal buffer because the
/// ratatui [`Paragraph`] widget does not perform softwrapping when used in
/// conjunction with [`Terminal::insert_before`].
///
/// This function performs a minimal wrapping / normalisation pass:
///
/// * A terminal width is determined via `Terminal::size()` (falling back to
/// 80 columns if the size probe fails).
/// * Each logical line is broken into words and whitespace. Consecutive
/// whitespace is collapsed to a single space; leading whitespace is
/// discarded.
/// * Words that do not fit on the current line cause a soft wrap. Extremely
/// long words (longer than the terminal width) are split character by
/// character so they still populate the display instead of overflowing the
/// line.
/// * Explicit `\n` characters inside a span force a hard line break.
/// * Empty lines (including a trailing newline at the end of the batch) are
/// preserved so vertical spacing remains faithful to the logical history.
///
/// Finally the physical lines are rendered directly into the terminal's
/// scrollback region using [`Terminal::insert_before`]. Any backend error is
/// ignored: failing to insert history is nonfatal and a subsequent redraw
/// will eventually repaint a consistent view.
fn display_width(s: &str) -> usize {
s.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum()
}
struct LineBuilder {
term_width: usize,
spans: Vec<Span<'static>>,
width: usize,
}
impl LineBuilder {
fn new(term_width: usize) -> Self {
Self {
term_width,
spans: Vec::new(),
width: 0,
}
}
fn flush_line(&mut self, out: &mut Vec<Line<'static>>) {
out.push(Line::from(std::mem::take(&mut self.spans)));
self.width = 0;
}
fn push_segment(&mut self, text: String, style: Style) {
self.width += display_width(&text);
self.spans.push(Span::styled(text, style));
}
fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
if word.is_empty() {
return;
}
let w_len = display_width(word);
if self.width > 0 && self.width + w_len > self.term_width {
self.flush_line(out);
}
if w_len > self.term_width && self.width == 0 {
// Split an overlong word across multiple lines.
let mut cur = String::new();
let mut cur_w = 0;
for ch in word.chars() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
if cur_w + ch_w > self.term_width && cur_w > 0 {
self.push_segment(cur.clone(), style);
self.flush_line(out);
cur.clear();
cur_w = 0;
}
cur.push(ch);
cur_w += ch_w;
}
if !cur.is_empty() {
self.push_segment(cur, style);
}
} else {
self.push_segment(word.clone(), style);
}
word.clear();
}
fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
if ws.is_empty() {
return;
}
let space_w = display_width(ws);
if self.width > 0 && self.width + space_w > self.term_width {
self.flush_line(out);
}
if self.width > 0 {
self.push_segment(" ".to_string(), style);
}
ws.clear();
}
}
/// Insert `lines` above the viewport.
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
let mut physical: Vec<Line<'static>> = Vec::new();
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
for logical in lines.into_iter() {
if logical.spans.is_empty() {
physical.push(logical);
continue;
}
let mut area = terminal.get_frame().area();
let mut builder = LineBuilder::new(term_width);
let mut buf_space = String::new();
let wrapped_lines = wrapped_line_count(&lines, area.width);
let cursor_top = if area.bottom() < screen_size.height {
// If the viewport is not at the bottom of the screen, scroll it down to make room.
// Don't scroll it past the bottom of the screen.
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
terminal
.backend_mut()
.scroll_region_down(area.top()..screen_size.height, scroll_amount)
.ok();
let cursor_top = area.top() - 1;
area.y += scroll_amount;
terminal.set_viewport_area(area);
cursor_top
} else {
area.top() - 1
};
for span in logical.spans.into_iter() {
let style = span.style;
let mut buf_word = String::new();
// Limit the scroll region to the lines from the top of the screen to the
// top of the viewport. With this in place, when we add lines inside this
// area, only the lines in this area will be scrolled. We place the cursor
// at the end of the scroll region, and add lines starting there.
//
// ┌─Screen───────────────────────┐
// │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│
// │┆ ┆│
// │┆ ┆│
// │┆ ┆│
// │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│
// │╭─Viewport───────────────────╮│
// ││ ││
// │╰────────────────────────────╯│
// └──────────────────────────────┘
queue!(std::io::stdout(), SetScrollRegion(1..area.top())).ok();
for ch in span.content.chars() {
if ch == '\n' {
builder.push_word(&mut buf_word, style, &mut physical);
buf_space.clear();
builder.flush_line(&mut physical);
continue;
}
if ch.is_whitespace() {
builder.push_word(&mut buf_word, style, &mut physical);
buf_space.push(ch);
} else {
builder.consume_whitespace(&mut buf_space, style, &mut physical);
buf_word.push(ch);
}
if builder.width >= term_width {
builder.flush_line(&mut physical);
}
}
builder.push_word(&mut buf_word, style, &mut physical);
// whitespace intentionally left to allow collapsing across spans
}
if !builder.spans.is_empty() {
physical.push(Line::from(std::mem::take(&mut builder.spans)));
} else {
// Preserve explicit blank line (e.g. due to a trailing newline).
physical.push(Line::from(Vec::<Span<'static>>::new()));
}
terminal
.set_cursor_position(Position::new(0, cursor_top))
.ok();
for line in lines {
queue!(std::io::stdout(), Print("\r\n")).ok();
write_spans(&mut std::io::stdout(), line.iter()).ok();
}
let total = physical.len() as u16;
terminal
.insert_before(total, |buf| {
let width = buf.area.width;
for (i, line) in physical.into_iter().enumerate() {
let area = Rect {
x: 0,
y: i as u16,
width,
height: 1,
};
Paragraph::new(line).render(area, buf);
}
})
.ok();
queue!(std::io::stdout(), ResetScrollRegion).ok();
}
fn wrapped_line_count(lines: &[Line], width: u16) -> u16 {
let mut count = 0;
for line in lines {
count += line_height(line, width);
}
count
}
fn line_height(line: &Line, width: u16) -> u16 {
use unicode_width::UnicodeWidthStr;
// get the total display width of the line, accounting for double-width chars
let total_width = line
.spans
.iter()
.map(|span| span.content.width())
.sum::<usize>();
// divide by width to get the number of lines, rounding up
if width == 0 {
1
} else {
(total_width as u16).div_ceil(width).max(1)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetScrollRegion(pub std::ops::Range<u16>);
impl Command for SetScrollRegion {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
write!(f, "\x1b[{};{}r", self.0.start, self.0.end)
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
panic!("tried to execute SetScrollRegion command using WinAPI, use ANSI instead");
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
// TODO(nornagon): is this supported on Windows?
true
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ResetScrollRegion;
impl Command for ResetScrollRegion {
fn write_ansi(&self, f: &mut impl fmt::Write) -> fmt::Result {
write!(f, "\x1b[r")
}
#[cfg(windows)]
fn execute_winapi(&self) -> std::io::Result<()> {
panic!("tried to execute ResetScrollRegion command using WinAPI, use ANSI instead");
}
#[cfg(windows)]
fn is_ansi_code_supported(&self) -> bool {
// TODO(nornagon): is this supported on Windows?
true
}
}
struct ModifierDiff {
pub from: Modifier,
pub to: Modifier,
}
impl ModifierDiff {
fn queue<W>(self, mut w: W) -> io::Result<()>
where
W: io::Write,
{
use crossterm::style::Attribute as CAttribute;
let removed = self.from - self.to;
if removed.contains(Modifier::REVERSED) {
queue!(w, SetAttribute(CAttribute::NoReverse))?;
}
if removed.contains(Modifier::BOLD) {
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
if self.to.contains(Modifier::DIM) {
queue!(w, SetAttribute(CAttribute::Dim))?;
}
}
if removed.contains(Modifier::ITALIC) {
queue!(w, SetAttribute(CAttribute::NoItalic))?;
}
if removed.contains(Modifier::UNDERLINED) {
queue!(w, SetAttribute(CAttribute::NoUnderline))?;
}
if removed.contains(Modifier::DIM) {
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
}
if removed.contains(Modifier::CROSSED_OUT) {
queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
}
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
queue!(w, SetAttribute(CAttribute::NoBlink))?;
}
let added = self.to - self.from;
if added.contains(Modifier::REVERSED) {
queue!(w, SetAttribute(CAttribute::Reverse))?;
}
if added.contains(Modifier::BOLD) {
queue!(w, SetAttribute(CAttribute::Bold))?;
}
if added.contains(Modifier::ITALIC) {
queue!(w, SetAttribute(CAttribute::Italic))?;
}
if added.contains(Modifier::UNDERLINED) {
queue!(w, SetAttribute(CAttribute::Underlined))?;
}
if added.contains(Modifier::DIM) {
queue!(w, SetAttribute(CAttribute::Dim))?;
}
if added.contains(Modifier::CROSSED_OUT) {
queue!(w, SetAttribute(CAttribute::CrossedOut))?;
}
if added.contains(Modifier::SLOW_BLINK) {
queue!(w, SetAttribute(CAttribute::SlowBlink))?;
}
if added.contains(Modifier::RAPID_BLINK) {
queue!(w, SetAttribute(CAttribute::RapidBlink))?;
}
Ok(())
}
}
fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
where
I: Iterator<Item = &'a Span<'a>>,
{
let mut fg = Color::Reset;
let mut bg = Color::Reset;
let mut modifier = Modifier::empty();
for span in content {
let mut next_modifier = modifier;
next_modifier.insert(span.style.add_modifier);
next_modifier.remove(span.style.sub_modifier);
if next_modifier != modifier {
let diff = ModifierDiff {
from: modifier,
to: next_modifier,
};
diff.queue(&mut writer)?;
modifier = next_modifier;
}
let next_fg = span.style.fg.unwrap_or(Color::Reset);
let next_bg = span.style.bg.unwrap_or(Color::Reset);
if next_fg != fg || next_bg != bg {
queue!(
writer,
SetColors(Colors::new(next_fg.into(), next_bg.into()))
)?;
fg = next_fg;
bg = next_bg;
}
queue!(writer, Print(span.content.clone()))?;
}
queue!(
writer,
SetForegroundColor(CColor::Reset),
SetBackgroundColor(CColor::Reset),
SetAttribute(crossterm::style::Attribute::Reset),
)
}

View File

@@ -11,9 +11,11 @@ use codex_core::openai_api_key::get_openai_api_key;
use codex_core::openai_api_key::set_openai_api_key;
use codex_core::protocol::AskForApproval;
use codex_core::util::is_inside_git_repo;
use codex_core::util::maybe_read_file;
use codex_login::try_read_openai_api_key;
use log_layer::TuiLogLayer;
use std::fs::OpenOptions;
use std::path::Path;
use std::path::PathBuf;
use tracing_appender::non_blocking;
use tracing_subscriber::EnvFilter;
@@ -68,8 +70,50 @@ pub fn run_main(
)
};
let config = {
// Capture any read error for experimental instructions so we can log it
// after the tracing subscriber has been initialized.
let mut experimental_read_error: Option<String> = None;
let (config, experimental_prompt_label) = {
// Load configuration and support CLI overrides.
// If the experimental instructions flag points at a file, read its
// contents; otherwise use the value verbatim. Avoid printing to stdout
// or stderr in this library crate fallback to the raw string on
// errors.
let base_instructions =
cli.experimental_instructions
.as_deref()
.and_then(|s| match maybe_read_file(s) {
Ok(v) => v,
Err(e) => {
experimental_read_error = Some(format!(
"Failed to read experimental instructions from '{s}': {e}"
));
Some(s.to_string())
}
});
// Derive a label shown in the welcome banner describing the origin of
// the experimental instructions: filename for file paths and
// "experimental" for literals.
let experimental_prompt_label = cli.experimental_instructions.as_deref().map(|s| {
let p = Path::new(s);
if p.is_file() {
p.file_name()
.map(|os| os.to_string_lossy().to_string())
.unwrap_or_else(|| s.to_string())
} else {
"experimental".to_string()
}
});
// Do not show a label if the file was empty (base_instructions is None).
let experimental_prompt_label = if base_instructions.is_some() {
experimental_prompt_label
} else {
None
};
let overrides = ConfigOverrides {
model: cli.model.clone(),
approval_policy,
@@ -78,7 +122,7 @@ pub fn run_main(
model_provider: None,
config_profile: cli.config_profile.clone(),
codex_linux_sandbox_exe,
base_instructions: None,
base_instructions,
};
// Parse `-c` overrides from the CLI.
let cli_kv_overrides = match cli.config_overrides.parse_overrides() {
@@ -92,7 +136,7 @@ pub fn run_main(
#[allow(clippy::print_stderr)]
match Config::load_with_cli_overrides(cli_kv_overrides, overrides) {
Ok(config) => config,
Ok(config) => (config, experimental_prompt_label),
Err(err) => {
eprintln!("Error loading configuration: {err}");
std::process::exit(1);
@@ -142,6 +186,12 @@ pub fn run_main(
.with(tui_layer)
.try_init();
if let Some(msg) = experimental_read_error {
// Now that logging is initialized, record a warning so the user
// can see that Codex fell back to using the literal string.
tracing::warn!("{msg}");
}
let show_login_screen = should_show_login_screen(&config);
// Determine whether we need to display the "not a git repo" warning
@@ -150,8 +200,15 @@ pub fn run_main(
// `--allow-no-git-exec` flag.
let show_git_warning = !cli.skip_git_repo_check && !is_inside_git_repo(&config);
run_ratatui_app(cli, config, show_login_screen, show_git_warning, log_rx)
.map_err(|err| std::io::Error::other(err.to_string()))
run_ratatui_app(
cli,
config,
show_login_screen,
show_git_warning,
experimental_prompt_label,
log_rx,
)
.map_err(|err| std::io::Error::other(err.to_string()))
}
fn run_ratatui_app(
@@ -159,6 +216,7 @@ fn run_ratatui_app(
config: Config,
show_login_screen: bool,
show_git_warning: bool,
experimental_prompt_label: Option<String>,
mut log_rx: tokio::sync::mpsc::UnboundedReceiver<String>,
) -> color_eyre::Result<codex_core::protocol::TokenUsage> {
color_eyre::install()?;
@@ -178,6 +236,7 @@ fn run_ratatui_app(
show_login_screen,
show_git_warning,
images,
experimental_prompt_label,
);
// Bridge log receiver into the AppEvent channel so latest log lines update the UI.
@@ -244,3 +303,56 @@ fn is_in_need_of_openai_api_key(config: &Config) -> bool {
.unwrap_or(false);
is_using_openai_key && get_openai_api_key().is_none()
}
#[cfg(test)]
mod tests {
use codex_core::util::maybe_read_file;
use std::fs;
use std::path::PathBuf;
use uuid::Uuid;
fn temp_path() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!("codex_tui_test_{}.txt", Uuid::new_v4()));
p
}
#[test]
fn maybe_read_file_returns_literal_for_non_path() {
let res = match maybe_read_file("Base instructions as a string") {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, Some("Base instructions as a string".to_string()));
}
#[test]
fn maybe_read_file_reads_and_trims_file_contents() {
let p = temp_path();
if let Err(e) = fs::write(&p, " file text \n") {
panic!("write temp file: {e}");
}
let p_s = p.to_string_lossy().to_string();
let res = match maybe_read_file(&p_s) {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, Some("file text".to_string()));
let _ = std::fs::remove_file(&p);
}
#[test]
fn maybe_read_file_empty_file_returns_none() {
let p = temp_path();
if let Err(e) = fs::write(&p, " \n\t") {
panic!("write temp file: {e}");
}
let p_s = p.to_string_lossy().to_string();
let res = match maybe_read_file(&p_s) {
Ok(v) => v,
Err(e) => panic!("error: {e}"),
};
assert_eq!(res, None);
let _ = std::fs::remove_file(&p);
}
}

View File

@@ -1,4 +1,5 @@
use clap::Parser;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
use codex_tui::Cli;
use codex_tui::run_main;
@@ -13,7 +14,7 @@ struct TopCli {
}
fn main() -> anyhow::Result<()> {
codex_linux_sandbox::run_with_sandbox(|codex_linux_sandbox_exe| async move {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
let top_cli = TopCli::parse();
let mut inner = top_cli.inner;
inner

View File

@@ -203,6 +203,12 @@ impl UserApprovalWidget<'_> {
}
}
/// Handle Ctrl-C pressed by the user while the modal is visible.
/// Behaves like pressing Escape: abort the request and close the modal.
pub(crate) fn on_ctrl_c(&mut self) {
self.send_decision(ReviewDecision::Abort);
}
fn handle_select_key(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Up => {
@@ -265,7 +271,28 @@ impl UserApprovalWidget<'_> {
self.send_decision_with_feedback(decision, String::new())
}
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, _feedback: String) {
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
let mut lines: Vec<Line<'static>> = Vec::new();
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
lines.push(Line::from("approval decision"));
lines.push(Line::from(format!("$ {cmd}")));
lines.push(Line::from(format!("decision: {decision:?}")));
}
ApprovalRequest::ApplyPatch { .. } => {
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
}
}
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
lines.push(Line::from(""));
self.app_event_tx.send(AppEvent::InsertHistory(lines));
let op = match &self.approval_request {
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
id: id.clone(),
@@ -277,12 +304,6 @@ impl UserApprovalWidget<'_> {
},
};
// Ignore feedback for now the current `Op` variants do not carry it.
// Forward the Op to the agent. The caller (ChatWidget) will trigger a
// redraw after it processes the resulting state change, so we avoid
// issuing an extra Redraw here to prevent a transient frame where the
// modal is still visible.
self.app_event_tx.send(AppEvent::CodexOp(op));
self.done = true;
}