From 90725fe3d597c79e1c4563d226ce694eb04027b1 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 2 Sep 2025 23:13:04 -0700 Subject: [PATCH 01/22] docs: update link to point to https://agents.md/ (#3089) Given the link text is "official AGENTS.md documentation," this seems like the right URL. --- docs/getting-started.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/getting-started.md b/docs/getting-started.md index ba99743133..691caae0e7 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -48,7 +48,7 @@ You can give Codex extra instructions and guidance using `AGENTS.md` files. Code 2. `AGENTS.md` at repo root - shared project notes 3. `AGENTS.md` in the current working directory - sub-folder/feature specifics -For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](./agents.md). +For more information on how to use AGENTS.md, see the [official AGENTS.md documentation](https://agents.md/). ### Tips & shortcuts From 3f76220055a011cb4f2086b6b6e0fd4af5dc7426 Mon Sep 17 00:00:00 2001 From: Zhongsheng Ji <9573586@qq.com> Date: Wed, 3 Sep 2025 14:14:57 +0800 Subject: [PATCH 02/22] docs: fix typo of config.md (#3082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed a typo in config.md. I guess TOML doesn't have the “key”: “value” syntax. --- docs/config.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config.md b/docs/config.md index 1d0e8cd741..3e1f93df8f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -90,7 +90,7 @@ http_headers = { "X-Example-Header" = "example-value" } # This will add the HTTP header `X-Example-Features` with the value of the # `EXAMPLE_FEATURES` environment variable to each request to the model provider # _if_ the environment variable is set and its value is non-empty. -env_http_headers = { "X-Example-Features": "EXAMPLE_FEATURES" } +env_http_headers = { "X-Example-Features" = "EXAMPLE_FEATURES" } ``` ### Per-provider network tuning From d489690efe7f678fd156263b1b954999d8983047 Mon Sep 17 00:00:00 2001 From: Mitch Fultz <57411549+fitchmultz@users.noreply.github.com> Date: Wed, 3 Sep 2025 00:33:50 -0600 Subject: [PATCH 03/22] TUI: fix MCP docs hyperlink in empty_mcp_output (#2907) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Summary: - Updated the hardcoded hyperlink shown when no MCP servers are configured to point at the canonical docs section: - From: codex-rs/config.md#mcp_servers (moved/obsolete) - To: docs/config.md#mcp_servers (correct GitHub path) - Rationale: - The TUI link was pointing to a file that only redirects; this makes the link accurate and reduces user confusion. - Validation: - Verified that the target anchor exists at: https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers - UI behavior unchanged otherwise (rendering of link text remains “MCP docs”). - Impact: - One-line change in TUI display logic; no functional behavior change. Co-authored-by: Michael Bolin --- codex-rs/tui/src/history_cell.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index dea72436cb..f8790a0169 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1032,7 +1032,7 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { " • No MCP servers configured.".italic().into(), Line::from(vec![ " See the ".into(), - "\u{1b}]8;;https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), + "\u{1b}]8;;https://github.com/openai/codex/blob/main/docs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}".underlined(), " to configure them.".into(), ]) .style(Style::default().add_modifier(Modifier::DIM)), From 44dce748b63f97dc2b47d6a2a199de9946f955fb Mon Sep 17 00:00:00 2001 From: pchuri Date: Wed, 3 Sep 2025 15:36:40 +0900 Subject: [PATCH 04/22] feat: add Android/Termux support by gating arboard dependency (#2895) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR enables Codex to build and run on Android/Termux environments by conditionally gating the arboard clipboard dependency for Android targets. ## Key Changes - **Android Compatibility**: Gate arboard dependency for Android targets where clipboard access may be restricted - **Build Fixes**: Add missing tempfile::Builder import for image clipboard operations - **Code Cleanup**: Remove unnecessary parentheses to resolve formatting warnings ## Technical Details ### Clipboard Dependency Gating - Uses conditional compilation to exclude arboard on Android targets - Maintains full clipboard functionality on other platforms - Prevents build failures on Android/Termux where system clipboard access is limited ### Import Fixes - Adds missing tempfile::Builder import that was causing compilation errors - Ensures image clipboard operations work correctly when clipboard is available ## Platform Support - ✅ **Linux/macOS/Windows**: Full clipboard functionality maintained - ✅ **Android/Termux**: Builds successfully without clipboard dependency - ✅ **Other Unix platforms**: Unchanged behavior ## Testing - ✅ Builds successfully on Android/Termux - ✅ Maintains clipboard functionality on supported platforms - ✅ No regression in existing functionality This addresses the Android/Termux compatibility issues while keeping clipboard functionality intact for platforms that support it. --- codex-rs/tui/Cargo.toml | 6 +++++- codex-rs/tui/src/clipboard_paste.rs | 23 +++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 21fe3b3d70..9a115d28b0 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -22,7 +22,6 @@ workspace = true [dependencies] anyhow = "1" -arboard = "3" async-stream = "0.3.6" base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } @@ -93,6 +92,11 @@ pathdiff = "0.2" [target.'cfg(unix)'.dependencies] libc = "0.2" +# Clipboard support via `arboard` is not available on Android/Termux. +# Only include it for non-Android targets so the crate builds on Android. +[target.'cfg(not(target_os = "android"))'.dependencies] +arboard = "3" + [dev-dependencies] chrono = { version = "0.4", features = ["serde"] } diff --git a/codex-rs/tui/src/clipboard_paste.rs b/codex-rs/tui/src/clipboard_paste.rs index 5a6a8b2f2e..b15b44bddf 100644 --- a/codex-rs/tui/src/clipboard_paste.rs +++ b/codex-rs/tui/src/clipboard_paste.rs @@ -47,6 +47,7 @@ pub struct PastedImageInfo { } /// Capture image from system clipboard, encode to PNG, and return bytes + info. +#[cfg(not(target_os = "android"))] pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { tracing::debug!("attempting clipboard image read"); let mut cb = arboard::Clipboard::new() @@ -70,10 +71,7 @@ pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageErro .map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?; } - tracing::debug!( - "clipboard image encoded to PNG ({len} bytes)", - len = png.len() - ); + tracing::debug!("clipboard image encoded to PNG ({}) bytes", png.len()); Ok(( png, PastedImageInfo { @@ -84,7 +82,16 @@ pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageErro )) } +/// Android/Termux does not support arboard; return a clear error. +#[cfg(target_os = "android")] +pub fn paste_image_as_png() -> Result<(Vec, PastedImageInfo), PasteImageError> { + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + /// Convenience: write to a temp file and return its path + info. +#[cfg(not(target_os = "android"))] pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { let (png, info) = paste_image_as_png()?; // Create a unique temporary file with a .png suffix to avoid collisions. @@ -101,6 +108,14 @@ pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImag Ok((path, info)) } +#[cfg(target_os = "android")] +pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> { + // Keep error consistent with paste_image_as_png. + Err(PasteImageError::ClipboardUnavailable( + "clipboard image paste is unsupported on Android".into(), + )) +} + /// Normalize pasted text that may represent a filesystem path. /// /// Supports: From 6aa306c584a9bf9f379509ea05195eba5dd4e264 Mon Sep 17 00:00:00 2001 From: pchuri Date: Wed, 3 Sep 2025 15:46:27 +0900 Subject: [PATCH 05/22] feat: add stable file locking using std::fs APIs (#2894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary This PR implements advisory file locking for the message history using Rust 1.89+ stabilized std::fs::File locking APIs, eliminating the need for external dependencies. ## Key Changes - **Stable API Usage**: Uses std::fs::File::try_lock() and try_lock_shared() APIs stabilized in Rust 1.89 - **Cross-Platform Compatibility**: - Unix systems use try_lock_shared() for advisory read locks - Windows systems use try_lock() due to different lock semantics - **Retry Logic**: Maintains existing retry behavior for concurrent access scenarios - **No External Dependencies**: Removes need for external file locking crates ## Technical Details The implementation provides advisory file locking to prevent corruption when multiple Codex processes attempt to write to the message history file simultaneously. The locking is platform-aware to handle differences in Windows vs Unix file locking behavior. ## Testing - ✅ Builds successfully on all platforms - ✅ Existing message history tests pass - ✅ File locking retry logic verified Related to discussion in #2773 about using stabilized Rust APIs instead of external dependencies. --------- Co-authored-by: Michael Bolin --- codex-rs/core/src/message_history.rs | 128 +++++++++++---------------- 1 file changed, 54 insertions(+), 74 deletions(-) diff --git a/codex-rs/core/src/message_history.rs b/codex-rs/core/src/message_history.rs index 9f13ababb7..75bc5aa75c 100644 --- a/codex-rs/core/src/message_history.rs +++ b/codex-rs/core/src/message_history.rs @@ -105,47 +105,34 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config) // Ensure permissions. ensure_owner_only_permissions(&history_file).await?; - // Lock file. - acquire_exclusive_lock_with_retry(&history_file).await?; - - // We use sync I/O with spawn_blocking() because we are using a - // [`std::fs::File`] instead of a [`tokio::fs::File`] to leverage an - // advisory file locking API that is not available in the async API. + // Perform a blocking write under an advisory write lock using std::fs. tokio::task::spawn_blocking(move || -> Result<()> { - history_file.write_all(line.as_bytes())?; - history_file.flush()?; - Ok(()) + // Retry a few times to avoid indefinite blocking when contended. + for _ in 0..MAX_RETRIES { + match history_file.try_lock() { + Ok(()) => { + // While holding the exclusive lock, write the full line. + history_file.write_all(line.as_bytes())?; + history_file.flush()?; + return Ok(()); + } + Err(std::fs::TryLockError::WouldBlock) => { + std::thread::sleep(RETRY_SLEEP); + } + Err(e) => return Err(e.into()), + } + } + + Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "could not acquire exclusive lock on history file after multiple attempts", + )) }) .await??; Ok(()) } -/// Attempt to acquire an exclusive advisory lock on `file`, retrying up to 10 -/// times if the lock is currently held by another process. This prevents a -/// potential indefinite wait while still giving other writers some time to -/// finish their operation. -async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> { - use tokio::time::sleep; - - for _ in 0..MAX_RETRIES { - match file.try_lock() { - Ok(()) => return Ok(()), - Err(e) => match e { - std::fs::TryLockError::WouldBlock => { - sleep(RETRY_SLEEP).await; - } - other => return Err(other.into()), - }, - } - } - - Err(std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "could not acquire exclusive lock on history file after multiple attempts", - )) -} - /// Asynchronously fetch the history file's *identifier* (inode on Unix) and /// the current number of entries by counting newline characters. pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) { @@ -221,29 +208,42 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option l, - Err(e) => { - tracing::warn!(error = %e, "failed to read line from history file"); + match lock_result { + Ok(()) => { + let reader = BufReader::new(&file); + for (idx, line_res) in reader.lines().enumerate() { + let line = match line_res { + Ok(l) => l, + Err(e) => { + tracing::warn!(error = %e, "failed to read line from history file"); + return None; + } + }; + + if idx == offset { + match serde_json::from_str::(&line) { + Ok(entry) => return Some(entry), + Err(e) => { + tracing::warn!(error = %e, "failed to parse history entry"); + return None; + } + } + } + } + // Not found at requested offset. return None; } - }; - - if idx == offset { - match serde_json::from_str::(&line) { - Ok(entry) => return Some(entry), - Err(e) => { - tracing::warn!(error = %e, "failed to parse history entry"); - return None; - } + Err(std::fs::TryLockError::WouldBlock) => { + std::thread::sleep(RETRY_SLEEP); + } + Err(e) => { + tracing::warn!(error = %e, "failed to acquire shared lock on history file"); + return None; } } } @@ -258,26 +258,6 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option Result<()> { - for _ in 0..MAX_RETRIES { - match file.try_lock_shared() { - Ok(()) => return Ok(()), - Err(e) => match e { - std::fs::TryLockError::WouldBlock => { - std::thread::sleep(RETRY_SLEEP); - } - other => return Err(other.into()), - }, - } - } - - Err(std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "could not acquire shared lock on history file after multiple attempts", - )) -} - /// On Unix systems ensure the file permissions are `0o600` (rw-------). If the /// permissions cannot be changed the error is propagated to the caller. #[cfg(unix)] From 9ad2e726fc18aa8a63d3755d672d06eb96d67113 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 2 Sep 2025 23:50:53 -0700 Subject: [PATCH 06/22] chore(deps): bump thiserror from 2.0.12 to 2.0.16 in /codex-rs (#2667) Bumps [thiserror](https://github.com/dtolnay/thiserror) from 2.0.12 to 2.0.16.
Release notes

Sourced from thiserror's releases.

2.0.16

  • Add to "no-std" crates.io category (#429)

2.0.15

  • Prevent Error::provide API becoming unavailable from a future new compiler lint (#427)

2.0.14

  • Allow build-script cleanup failure with NFSv3 output directory to be non-fatal (#426)

2.0.13

  • Documentation improvements
Commits
  • 40b5853 Release 2.0.16
  • 83dfb5f Merge pull request #429 from dtolnay/nostd
  • 9b4a99f Add to "no-std" crates.io category
  • f6145eb Release 2.0.15
  • 2717177 Merge pull request #427 from dtolnay/caplints
  • 2cd13e6 Make error_generic_member_access compatible with -Dwarnings
  • eea6799 Release 2.0.14
  • a2aa6d7 Merge pull request #426 from dtolnay/enotempty
  • f00ebc5 Allow build-script cleanup failure with NFSv3 output directory to be non-fatal
  • 61f28da Release 2.0.13
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=thiserror&package-manager=cargo&previous-version=2.0.12&new-version=2.0.16)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- codex-rs/Cargo.lock | 22 +++++++++++----------- codex-rs/apply-patch/Cargo.toml | 2 +- codex-rs/core/Cargo.toml | 2 +- codex-rs/login/Cargo.toml | 2 +- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c4f0aa4959..873d0a222a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -640,7 +640,7 @@ dependencies = [ "pretty_assertions", "similar", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tree-sitter", "tree-sitter-bash", ] @@ -748,7 +748,7 @@ dependencies = [ "similar", "strum_macros 0.27.2", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "time", "tokio", "tokio-test", @@ -856,7 +856,7 @@ dependencies = [ "serde_json", "sha2", "tempfile", - "thiserror 2.0.12", + "thiserror 2.0.16", "tiny_http", "tokio", "url", @@ -2687,7 +2687,7 @@ checksum = "b3d2ef408b88e913bfc6594f5e693d57676f6463ded7d8bf994175364320c706" dependencies = [ "enumflags2", "libc", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -3859,7 +3859,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.12", + "thiserror 2.0.16", ] [[package]] @@ -4944,11 +4944,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" +checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" dependencies = [ - "thiserror-impl 2.0.12", + "thiserror-impl 2.0.16", ] [[package]] @@ -4964,9 +4964,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.12" +version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" +checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" dependencies = [ "proc-macro2", "quote", @@ -5403,7 +5403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ef1b7a6d914a34127ed8e1fa927eb7088903787bcded4fa3eef8f85ee1568be" dependencies = [ "serde_json", - "thiserror 2.0.12", + "thiserror 2.0.16", "ts-rs-macros", "uuid", ] diff --git a/codex-rs/apply-patch/Cargo.toml b/codex-rs/apply-patch/Cargo.toml index 84d3630cfa..fc8c57aa8b 100644 --- a/codex-rs/apply-patch/Cargo.toml +++ b/codex-rs/apply-patch/Cargo.toml @@ -17,7 +17,7 @@ workspace = true [dependencies] anyhow = "1" similar = "2.7.0" -thiserror = "2.0.12" +thiserror = "2.0.16" tree-sitter = "0.25.8" tree-sitter-bash = "0.25.0" once_cell = "1" diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a4e40c7b55..12f0bc6287 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -40,7 +40,7 @@ shlex = "1.3.0" similar = "2.7.0" strum_macros = "0.27.2" tempfile = "3" -thiserror = "2.0.12" +thiserror = "2.0.16" time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } tokio = { version = "1", features = [ "io-std", diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 0a3579293e..154924da0c 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -17,7 +17,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" tempfile = "3" -thiserror = "2.0.12" +thiserror = "2.0.16" tiny_http = "0.12" tokio = { version = "1", features = [ "io-std", From d77b33ded77542cb66915fb96de083c79ce63c7e Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Sep 2025 00:39:19 -0700 Subject: [PATCH 07/22] core(rollout): extract rollout module, add listing API, and return file heads (#1634) - Move rollout persistence and listing into a dedicated module: rollout/{recorder,list}. - Expose lightweight conversation listing that returns file paths plus the first 5 JSONL records for preview. --- codex-rs/core/Cargo.toml | 2 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 295 ++++++++++++++ codex-rs/core/src/rollout/mod.rs | 12 + .../src/{rollout.rs => rollout/recorder.rs} | 18 +- codex-rs/core/src/rollout/tests.rs | 384 ++++++++++++++++++ 6 files changed, 708 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/rollout/list.rs create mode 100644 codex-rs/core/src/rollout/mod.rs rename codex-rs/core/src/{rollout.rs => rollout/recorder.rs} (95%) create mode 100644 codex-rs/core/src/rollout/tests.rs diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 12f0bc6287..3d2cf4a68d 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -41,7 +41,7 @@ similar = "2.7.0" strum_macros = "0.27.2" tempfile = "3" thiserror = "2.0.16" -time = { version = "0.3", features = ["formatting", "local-offset", "macros"] } +time = { version = "0.3", features = ["formatting", "parsing", "local-offset", "macros"] } tokio = { version = "1", features = [ "io-std", "macros", diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b4966e7988..8ae3e335bd 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -59,6 +59,7 @@ pub mod terminal; mod tool_apply_patch; pub mod turn_diff_tracker; pub mod user_agent; +pub use rollout::list::ConversationsPage; mod user_notification; pub mod util; pub use apply_patch::CODEX_APPLY_PATCH_ARG1; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs new file mode 100644 index 0000000000..375140c47a --- /dev/null +++ b/codex-rs/core/src/rollout/list.rs @@ -0,0 +1,295 @@ +use std::cmp::Reverse; +use std::io::{self}; +use std::path::Path; +use std::path::PathBuf; + +use time::OffsetDateTime; +use time::PrimitiveDateTime; +use time::format_description::FormatItem; +use time::macros::format_description; +use uuid::Uuid; + +use super::SESSIONS_SUBDIR; + +/// Returned page of conversation summaries. +#[derive(Debug, Default, PartialEq)] +pub struct ConversationsPage { + /// Conversation summaries ordered newest first. + pub items: Vec, + /// Opaque pagination token to resume after the last item, or `None` if end. + pub next_cursor: Option, + /// Total number of files touched while scanning this request. + pub num_scanned_files: usize, + /// True if a hard scan cap was hit; consider resuming with `next_cursor`. + pub reached_scan_cap: bool, +} + +/// Summary information for a conversation rollout file. +#[derive(Debug, PartialEq)] +pub struct ConversationItem { + /// Absolute path to the rollout file. + pub path: PathBuf, + /// First up to 5 JSONL records parsed as JSON (includes meta line). + pub head: Vec, +} + +/// Hard cap to bound worst‑case work per request. +const MAX_SCAN_FILES: usize = 50_000; + +/// Pagination cursor identifying a file by timestamp and UUID. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cursor { + ts: OffsetDateTime, + id: Uuid, +} + +impl Cursor { + fn new(ts: OffsetDateTime, id: Uuid) -> Self { + Self { ts, id } + } +} + +impl serde::Serialize for Cursor { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + let ts_str = self + .ts + .format(&format_description!( + "[year]-[month]-[day]T[hour]-[minute]-[second]" + )) + .map_err(|e| serde::ser::Error::custom(format!("format error: {e}")))?; + serializer.serialize_str(&format!("{ts_str}|{}", self.id)) + } +} + +impl<'de> serde::Deserialize<'de> for Cursor { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + parse_cursor(&s).ok_or_else(|| serde::de::Error::custom("invalid cursor")) + } +} + +/// Retrieve recorded conversation file paths with token pagination. The returned `next_cursor` +/// can be supplied on the next call to resume after the last returned item, resilient to +/// concurrent new sessions being appended. Ordering is stable by timestamp desc, then UUID desc. +pub(crate) async fn get_conversations( + codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, +) -> io::Result { + let mut root = codex_home.to_path_buf(); + root.push(SESSIONS_SUBDIR); + + if !root.exists() { + return Ok(ConversationsPage { + items: Vec::new(), + next_cursor: None, + num_scanned_files: 0, + reached_scan_cap: false, + }); + } + + let anchor = cursor.cloned(); + + let result = traverse_directories_for_paths(root.clone(), page_size, anchor).await?; + Ok(result) +} + +/// Load the full contents of a single conversation session file at `path`. +/// Returns the entire file contents as a String. +#[allow(dead_code)] +pub(crate) async fn get_conversation(path: &Path) -> io::Result { + tokio::fs::read_to_string(path).await +} + +/// Load conversation file paths from disk using directory traversal. +/// +/// Directory layout: `~/.codex/sessions/YYYY/MM/DD/rollout-YYYY-MM-DDThh-mm-ss-.jsonl` +/// Returned newest (latest) first. +async fn traverse_directories_for_paths( + root: PathBuf, + page_size: usize, + anchor: Option, +) -> io::Result { + let mut items: Vec = Vec::with_capacity(page_size); + let mut scanned_files = 0usize; + let mut anchor_passed = anchor.is_none(); + let (anchor_ts, anchor_id) = match anchor { + Some(c) => (c.ts, c.id), + None => (OffsetDateTime::UNIX_EPOCH, Uuid::nil()), + }; + + let year_dirs = collect_dirs_desc(&root, |s| s.parse::().ok()).await?; + + 'outer: for (_year, year_path) in year_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break; + } + let month_dirs = collect_dirs_desc(year_path, |s| s.parse::().ok()).await?; + for (_month, month_path) in month_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let day_dirs = collect_dirs_desc(month_path, |s| s.parse::().ok()).await?; + for (_day, day_path) in day_dirs.iter() { + if scanned_files >= MAX_SCAN_FILES { + break 'outer; + } + let mut day_files = collect_files(day_path, |name_str, path| { + if !name_str.starts_with("rollout-") || !name_str.ends_with(".jsonl") { + return None; + } + + parse_timestamp_uuid_from_filename(name_str) + .map(|(ts, id)| (ts, id, name_str.to_string(), path.to_path_buf())) + }) + .await?; + // Stable ordering within the same second: (timestamp desc, uuid desc) + day_files.sort_by_key(|(ts, sid, _name_str, _path)| (Reverse(*ts), Reverse(*sid))); + for (ts, sid, _name_str, path) in day_files.into_iter() { + scanned_files += 1; + if scanned_files >= MAX_SCAN_FILES && items.len() >= page_size { + break 'outer; + } + if !anchor_passed { + if ts < anchor_ts || (ts == anchor_ts && sid < anchor_id) { + anchor_passed = true; + } else { + continue; + } + } + if items.len() == page_size { + break 'outer; + } + let head = read_first_jsonl_records(&path, 5).await.unwrap_or_default(); + items.push(ConversationItem { path, head }); + } + } + } + } + + let next = build_next_cursor(&items); + Ok(ConversationsPage { + items, + next_cursor: next, + num_scanned_files: scanned_files, + reached_scan_cap: scanned_files >= MAX_SCAN_FILES, + }) +} + +/// Pagination cursor token format: "|" where `file_ts` matches the +/// filename timestamp portion (YYYY-MM-DDThh-mm-ss) used in rollout filenames. +/// The cursor orders files by timestamp desc, then UUID desc. +fn parse_cursor(token: &str) -> Option { + let (file_ts, uuid_str) = token.split_once('|')?; + + let Ok(uuid) = Uuid::parse_str(uuid_str) else { + return None; + }; + + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let ts = PrimitiveDateTime::parse(file_ts, format).ok()?.assume_utc(); + + Some(Cursor::new(ts, uuid)) +} + +fn build_next_cursor(items: &[ConversationItem]) -> Option { + let last = items.last()?; + let file_name = last.path.file_name()?.to_string_lossy(); + let (ts, id) = parse_timestamp_uuid_from_filename(&file_name)?; + Some(Cursor::new(ts, id)) +} + +/// Collects immediate subdirectories of `parent`, parses their (string) names with `parse`, +/// and returns them sorted descending by the parsed key. +async fn collect_dirs_desc(parent: &Path, parse: F) -> io::Result> +where + T: Ord + Copy, + F: Fn(&str) -> Option, +{ + let mut dir = tokio::fs::read_dir(parent).await?; + let mut vec: Vec<(T, PathBuf)> = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + if entry + .file_type() + .await + .map(|ft| ft.is_dir()) + .unwrap_or(false) + && let Some(s) = entry.file_name().to_str() + && let Some(v) = parse(s) + { + vec.push((v, entry.path())); + } + } + vec.sort_by_key(|(v, _)| Reverse(*v)); + Ok(vec) +} + +/// Collects files in a directory and parses them with `parse`. +async fn collect_files(parent: &Path, parse: F) -> io::Result> +where + F: Fn(&str, &Path) -> Option, +{ + let mut dir = tokio::fs::read_dir(parent).await?; + let mut collected: Vec = Vec::new(); + while let Some(entry) = dir.next_entry().await? { + if entry + .file_type() + .await + .map(|ft| ft.is_file()) + .unwrap_or(false) + && let Some(s) = entry.file_name().to_str() + && let Some(v) = parse(s, &entry.path()) + { + collected.push(v); + } + } + Ok(collected) +} + +fn parse_timestamp_uuid_from_filename(name: &str) -> Option<(OffsetDateTime, Uuid)> { + // Expected: rollout-YYYY-MM-DDThh-mm-ss-.jsonl + let core = name.strip_prefix("rollout-")?.strip_suffix(".jsonl")?; + + // Scan from the right for a '-' such that the suffix parses as a UUID. + let (sep_idx, uuid) = core + .match_indices('-') + .rev() + .find_map(|(i, _)| Uuid::parse_str(&core[i + 1..]).ok().map(|u| (i, u)))?; + + let ts_str = &core[..sep_idx]; + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let ts = PrimitiveDateTime::parse(ts_str, format).ok()?.assume_utc(); + Some((ts, uuid)) +} + +async fn read_first_jsonl_records( + path: &Path, + max_records: usize, +) -> io::Result> { + use tokio::io::AsyncBufReadExt; + + let file = tokio::fs::File::open(path).await?; + let reader = tokio::io::BufReader::new(file); + let mut lines = reader.lines(); + let mut head: Vec = Vec::new(); + while head.len() < max_records { + let line_opt = lines.next_line().await?; + let Some(line) = line_opt else { break }; + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(trimmed) { + head.push(v); + } + } + Ok(head) +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs new file mode 100644 index 0000000000..19c66a19ce --- /dev/null +++ b/codex-rs/core/src/rollout/mod.rs @@ -0,0 +1,12 @@ +//! Rollout module: persistence and discovery of session rollout files. + +pub(crate) const SESSIONS_SUBDIR: &str = "sessions"; + +pub mod list; +pub mod recorder; + +pub use recorder::RolloutRecorder; +pub use recorder::SessionStateSnapshot; + +#[cfg(test)] +pub mod tests; diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout/recorder.rs similarity index 95% rename from codex-rs/core/src/rollout.rs rename to codex-rs/core/src/rollout/recorder.rs index 98d576ccc0..20d30df654 100644 --- a/codex-rs/core/src/rollout.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -19,14 +19,16 @@ use tracing::info; use tracing::warn; use uuid::Uuid; +use super::SESSIONS_SUBDIR; +use super::list::ConversationsPage; +use super::list::Cursor; +use super::list::get_conversations; use crate::config::Config; use crate::conversation_manager::InitialHistory; use crate::git_info::GitInfo; use crate::git_info::collect_git_info; use codex_protocol::models::ResponseItem; -const SESSIONS_SUBDIR: &str = "sessions"; - #[derive(Serialize, Deserialize, Clone, Default)] pub struct SessionMeta { pub id: Uuid, @@ -65,7 +67,7 @@ pub struct SavedSession { /// $ fx ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl /// ``` #[derive(Clone)] -pub(crate) struct RolloutRecorder { +pub struct RolloutRecorder { tx: Sender, } @@ -76,6 +78,16 @@ enum RolloutCmd { } impl RolloutRecorder { + #[allow(dead_code)] + /// List conversations (rollout files) under the provided Codex home directory. + pub async fn list_conversations( + codex_home: &Path, + page_size: usize, + cursor: Option<&Cursor>, + ) -> std::io::Result { + get_conversations(codex_home, page_size, cursor).await + } + /// Attempt to create a new [`RolloutRecorder`]. If the sessions directory /// cannot be created or the rollout file cannot be opened we return the /// error so the caller can decide whether to disable persistence. diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs new file mode 100644 index 0000000000..52c121e772 --- /dev/null +++ b/codex-rs/core/src/rollout/tests.rs @@ -0,0 +1,384 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::fs::File; +use std::fs::{self}; +use std::io::Write; +use std::path::Path; + +use tempfile::TempDir; +use time::OffsetDateTime; +use time::PrimitiveDateTime; +use time::format_description::FormatItem; +use time::macros::format_description; +use uuid::Uuid; + +use crate::rollout::list::ConversationItem; +use crate::rollout::list::ConversationsPage; +use crate::rollout::list::Cursor; +use crate::rollout::list::get_conversation; +use crate::rollout::list::get_conversations; + +fn write_session_file( + root: &Path, + ts_str: &str, + uuid: Uuid, + num_records: usize, +) -> std::io::Result<(OffsetDateTime, Uuid)> { + let format: &[FormatItem] = + format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); + let dt = PrimitiveDateTime::parse(ts_str, format) + .unwrap() + .assume_utc(); + let dir = root + .join("sessions") + .join(format!("{:04}", dt.year())) + .join(format!("{:02}", u8::from(dt.month()))) + .join(format!("{:02}", dt.day())); + fs::create_dir_all(&dir)?; + + let filename = format!("rollout-{ts_str}-{uuid}.jsonl"); + let file_path = dir.join(filename); + let mut file = File::create(file_path)?; + + let meta = serde_json::json!({ + "timestamp": ts_str, + "id": uuid.to_string() + }); + writeln!(file, "{meta}")?; + + for i in 0..num_records { + let rec = serde_json::json!({ + "record_type": "response", + "index": i + }); + writeln!(file, "{rec}")?; + } + Ok((dt, uuid)) +} + +#[tokio::test] +async fn test_list_conversations_latest_first() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + // Fixed UUIDs for deterministic expectations + let u1 = Uuid::from_u128(1); + let u2 = Uuid::from_u128(2); + let u3 = Uuid::from_u128(3); + + // Create three sessions across three days + write_session_file(home, "2025-01-01T12-00-00", u1, 3).unwrap(); + write_session_file(home, "2025-01-02T12-00-00", u2, 3).unwrap(); + write_session_file(home, "2025-01-03T12-00-00", u3, 3).unwrap(); + + let page = get_conversations(home, 10, None).await.unwrap(); + + // Build expected objects + let p1 = home + .join("sessions") + .join("2025") + .join("01") + .join("03") + .join(format!("rollout-2025-01-03T12-00-00-{u3}.jsonl")); + let p2 = home + .join("sessions") + .join("2025") + .join("01") + .join("02") + .join(format!("rollout-2025-01-02T12-00-00-{u2}.jsonl")); + let p3 = home + .join("sessions") + .join("2025") + .join("01") + .join("01") + .join(format!("rollout-2025-01-01T12-00-00-{u1}.jsonl")); + + let head_3 = vec![ + serde_json::json!({"timestamp": "2025-01-03T12-00-00", "id": u3.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + serde_json::json!({"record_type": "response", "index": 2}), + ]; + let head_2 = vec![ + serde_json::json!({"timestamp": "2025-01-02T12-00-00", "id": u2.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + serde_json::json!({"record_type": "response", "index": 2}), + ]; + let head_1 = vec![ + serde_json::json!({"timestamp": "2025-01-01T12-00-00", "id": u1.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + serde_json::json!({"record_type": "response", "index": 2}), + ]; + + let expected_cursor: Cursor = + serde_json::from_str(&format!("\"2025-01-01T12-00-00|{u1}\"")).unwrap(); + + let expected = ConversationsPage { + items: vec![ + ConversationItem { + path: p1, + head: head_3, + }, + ConversationItem { + path: p2, + head: head_2, + }, + ConversationItem { + path: p3, + head: head_1, + }, + ], + next_cursor: Some(expected_cursor), + num_scanned_files: 3, + reached_scan_cap: false, + }; + + assert_eq!(page, expected); +} + +#[tokio::test] +async fn test_pagination_cursor() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + // Fixed UUIDs for deterministic expectations + let u1 = Uuid::from_u128(11); + let u2 = Uuid::from_u128(22); + let u3 = Uuid::from_u128(33); + let u4 = Uuid::from_u128(44); + let u5 = Uuid::from_u128(55); + + // Oldest to newest + write_session_file(home, "2025-03-01T09-00-00", u1, 1).unwrap(); + write_session_file(home, "2025-03-02T09-00-00", u2, 1).unwrap(); + write_session_file(home, "2025-03-03T09-00-00", u3, 1).unwrap(); + write_session_file(home, "2025-03-04T09-00-00", u4, 1).unwrap(); + write_session_file(home, "2025-03-05T09-00-00", u5, 1).unwrap(); + + let page1 = get_conversations(home, 2, None).await.unwrap(); + let p5 = home + .join("sessions") + .join("2025") + .join("03") + .join("05") + .join(format!("rollout-2025-03-05T09-00-00-{u5}.jsonl")); + let p4 = home + .join("sessions") + .join("2025") + .join("03") + .join("04") + .join(format!("rollout-2025-03-04T09-00-00-{u4}.jsonl")); + let head_5 = vec![ + serde_json::json!({"timestamp": "2025-03-05T09-00-00", "id": u5.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let head_4 = vec![ + serde_json::json!({"timestamp": "2025-03-04T09-00-00", "id": u4.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let expected_cursor1: Cursor = + serde_json::from_str(&format!("\"2025-03-04T09-00-00|{u4}\"")).unwrap(); + let expected_page1 = ConversationsPage { + items: vec![ + ConversationItem { + path: p5, + head: head_5, + }, + ConversationItem { + path: p4, + head: head_4, + }, + ], + next_cursor: Some(expected_cursor1.clone()), + num_scanned_files: 3, // scanned 05, 04, and peeked at 03 before breaking + reached_scan_cap: false, + }; + assert_eq!(page1, expected_page1); + + let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) + .await + .unwrap(); + let p3 = home + .join("sessions") + .join("2025") + .join("03") + .join("03") + .join(format!("rollout-2025-03-03T09-00-00-{u3}.jsonl")); + let p2 = home + .join("sessions") + .join("2025") + .join("03") + .join("02") + .join(format!("rollout-2025-03-02T09-00-00-{u2}.jsonl")); + let head_3 = vec![ + serde_json::json!({"timestamp": "2025-03-03T09-00-00", "id": u3.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let head_2 = vec![ + serde_json::json!({"timestamp": "2025-03-02T09-00-00", "id": u2.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let expected_cursor2: Cursor = + serde_json::from_str(&format!("\"2025-03-02T09-00-00|{u2}\"")).unwrap(); + let expected_page2 = ConversationsPage { + items: vec![ + ConversationItem { + path: p3, + head: head_3, + }, + ConversationItem { + path: p2, + head: head_2, + }, + ], + next_cursor: Some(expected_cursor2.clone()), + num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02, and peeked at 01 + reached_scan_cap: false, + }; + assert_eq!(page2, expected_page2); + + let page3 = get_conversations(home, 2, page2.next_cursor.as_ref()) + .await + .unwrap(); + let p1 = home + .join("sessions") + .join("2025") + .join("03") + .join("01") + .join(format!("rollout-2025-03-01T09-00-00-{u1}.jsonl")); + let head_1 = vec![ + serde_json::json!({"timestamp": "2025-03-01T09-00-00", "id": u1.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + ]; + let expected_cursor3: Cursor = + serde_json::from_str(&format!("\"2025-03-01T09-00-00|{u1}\"")).unwrap(); + let expected_page3 = ConversationsPage { + items: vec![ConversationItem { + path: p1, + head: head_1, + }], + next_cursor: Some(expected_cursor3.clone()), + num_scanned_files: 5, // scanned 05, 04 (anchor), 03, 02 (anchor), 01 + reached_scan_cap: false, + }; + assert_eq!(page3, expected_page3); +} + +#[tokio::test] +async fn test_get_conversation_contents() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let uuid = Uuid::new_v4(); + let ts = "2025-04-01T10-30-00"; + write_session_file(home, ts, uuid, 2).unwrap(); + + let page = get_conversations(home, 1, None).await.unwrap(); + let path = &page.items[0].path; + + let content = get_conversation(path).await.unwrap(); + + // Page equality (single item) + let expected_path = home + .join("sessions") + .join("2025") + .join("04") + .join("01") + .join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl")); + let expected_head = vec![ + serde_json::json!({"timestamp": ts, "id": uuid.to_string()}), + serde_json::json!({"record_type": "response", "index": 0}), + serde_json::json!({"record_type": "response", "index": 1}), + ]; + let expected_cursor: Cursor = serde_json::from_str(&format!("\"{ts}|{uuid}\"")).unwrap(); + let expected_page = ConversationsPage { + items: vec![ConversationItem { + path: expected_path.clone(), + head: expected_head, + }], + next_cursor: Some(expected_cursor), + num_scanned_files: 1, + reached_scan_cap: false, + }; + assert_eq!(page, expected_page); + + // Entire file contents equality + let meta = serde_json::json!({"timestamp": ts, "id": uuid.to_string()}); + let rec0 = serde_json::json!({"record_type": "response", "index": 0}); + let rec1 = serde_json::json!({"record_type": "response", "index": 1}); + let expected_content = format!("{meta}\n{rec0}\n{rec1}\n"); + assert_eq!(content, expected_content); +} + +#[tokio::test] +async fn test_stable_ordering_same_second_pagination() { + let temp = TempDir::new().unwrap(); + let home = temp.path(); + + let ts = "2025-07-01T00-00-00"; + let u1 = Uuid::from_u128(1); + let u2 = Uuid::from_u128(2); + let u3 = Uuid::from_u128(3); + + write_session_file(home, ts, u1, 0).unwrap(); + write_session_file(home, ts, u2, 0).unwrap(); + write_session_file(home, ts, u3, 0).unwrap(); + + let page1 = get_conversations(home, 2, None).await.unwrap(); + + let p3 = home + .join("sessions") + .join("2025") + .join("07") + .join("01") + .join(format!("rollout-2025-07-01T00-00-00-{u3}.jsonl")); + let p2 = home + .join("sessions") + .join("2025") + .join("07") + .join("01") + .join(format!("rollout-2025-07-01T00-00-00-{u2}.jsonl")); + let head = |u: Uuid| -> Vec { + vec![serde_json::json!({"timestamp": ts, "id": u.to_string()})] + }; + let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap(); + let expected_page1 = ConversationsPage { + items: vec![ + ConversationItem { + path: p3, + head: head(u3), + }, + ConversationItem { + path: p2, + head: head(u2), + }, + ], + next_cursor: Some(expected_cursor1.clone()), + num_scanned_files: 3, // scanned u3, u2, peeked u1 + reached_scan_cap: false, + }; + assert_eq!(page1, expected_page1); + + let page2 = get_conversations(home, 2, page1.next_cursor.as_ref()) + .await + .unwrap(); + let p1 = home + .join("sessions") + .join("2025") + .join("07") + .join("01") + .join(format!("rollout-2025-07-01T00-00-00-{u1}.jsonl")); + let expected_cursor2: Cursor = serde_json::from_str(&format!("\"{ts}|{u1}\"")).unwrap(); + let expected_page2 = ConversationsPage { + items: vec![ConversationItem { + path: p1, + head: head(u1), + }], + next_cursor: Some(expected_cursor2.clone()), + num_scanned_files: 3, // scanned u3, u2 (anchor), u1 + reached_scan_cap: false, + }; + assert_eq!(page2, expected_page2); +} From a56eb48195a1b154965c70f709fc976f183ecfa2 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Sep 2025 01:16:47 -0700 Subject: [PATCH 08/22] Use the new search tool (#3086) We were using the preview search tool in the past. We should use the new one. --- codex-rs/core/src/openai_tools.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index 59ca7a8361..0505b6afa7 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -49,7 +49,7 @@ pub(crate) enum OpenAiTool { LocalShell {}, // TODO: Understand why we get an error on web_search although the API docs say it's supported. // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C - #[serde(rename = "web_search_preview")] + #[serde(rename = "web_search")] WebSearch {}, #[serde(rename = "custom")] Freeform(FreeformTool), From fb5dfe3396d311d4873931a0a4ab26bf52936a9d Mon Sep 17 00:00:00 2001 From: Gabriel Peal Date: Wed, 3 Sep 2025 09:44:16 -0700 Subject: [PATCH 09/22] Update guidance on API key permissions (#3112) Fixes https://github.com/openai/codex/issues/3108 --- docs/authentication.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/authentication.md b/docs/authentication.md index d318be708c..5eb5204097 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -8,6 +8,8 @@ If you prefer to pay-as-you-go, you can still authenticate with your OpenAI API export OPENAI_API_KEY="your-api-key-here" ``` +This key must, at minimum, have write access to the Responses API. + ## Migrating to ChatGPT login from API key If you've used the Codex CLI before with usage-based billing via an API key and want to switch to using your ChatGPT plan, follow these steps: @@ -85,4 +87,4 @@ If you run Codex on a remote machine (VPS/server) without a local browser, the l ssh -L 1455:localhost:1455 @ ``` -Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server. \ No newline at end of file +Then, in that SSH session, run `codex` and select "Sign in with ChatGPT". When prompted, open the printed URL (it will be `http://localhost:1455/...`) in your local browser. The traffic will be tunneled to the remote server. From 97000c6e6dec372992150f02746b5c12057642cd Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 3 Sep 2025 10:02:34 -0700 Subject: [PATCH 10/22] core: correct sandboxed shell tool description (reads allowed anywhere) (#3069) Correct the `shell` tool description for sandboxed runs and add targeted tests. - Fix the WorkspaceWrite description to clearly state that writes outside the writable roots require escalated permissions; reads are not restricted. The previous wording/formatting could be read as restricting reads outside the workspace. - Render the writable roots list on its own lines under a newline after "writable roots:" for clarity. - Show the "Commands that require network access" note only in WorkspaceWrite when network is disabled. - Add focused tests that call `create_shell_tool_for_sandbox` directly and assert the exact description text for WorkspaceWrite, ReadOnly, and DangerFullAccess. - Update AGENTS.md to note that `just fmt` can be run automatically without asking. --- AGENTS.md | 4 +- codex-rs/core/src/openai_tools.rs | 94 +++++++++++++++++++++++++++++-- 2 files changed, 90 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 968ce5b9cf..1c3f8ad825 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,10 @@ In the codex-rs folder where the rust code lives: - You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations. - Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate. -Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: +Run `just fmt` (in `codex-rs` directory) automatically after making Rust code changes; do not ask for approval to run it. Before finalizing a change to `codex-rs`, run `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Prefer scoping with `-p` to avoid slow workspace‑wide Clippy builds; only run `just fix` without `-p` if you changed shared crates. Additionally, run the tests: 1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. 2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. -When running interactively, ask the user before running these commands to finalize. +When running interactively, ask the user before running `just fix` and tests to finalize; `just fmt` does not require approval. ## TUI style conventions diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index 0505b6afa7..722370281f 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -240,15 +240,17 @@ fn create_shell_tool_for_sandbox(sandbox_policy: &SandboxPolicy) -> OpenAiTool { let description = match sandbox_policy { SandboxPolicy::WorkspaceWrite { network_access, + writable_roots, .. } => { format!( r#" The shell tool is used to execute shell commands. -- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands will require escalated privileges: +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges: - Types of actions that require escalated privileges: - - Reading files outside the current directory - - Writing files outside the current directory, and protected folders like .git or .env{} + - Writing files other than those in the writable roots + - writable roots: +{}{} - Examples of commands that require escalated privileges: - git commit - npm install or pnpm install @@ -257,8 +259,9 @@ The shell tool is used to execute shell commands. - When invoking a command that will require escalated privileges: - Provide the with_escalated_permissions parameter with the boolean value true - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#, + writable_roots.iter().map(|wr| format!(" - {}", wr.to_string_lossy())).collect::>().join("\n"), if !network_access { - "\n - Commands that require network access\n" + "\n - Commands that require network access\n" } else { "" } @@ -270,9 +273,8 @@ The shell tool is used to execute shell commands. SandboxPolicy::ReadOnly => { r#" The shell tool is used to execute shell commands. -- When invoking the shell tool, your call will be running in a landlock sandbox, and some shell commands (including apply_patch) will require escalated permissions: +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions: - Types of actions that require escalated privileges: - - Reading files outside the current directory - Writing files - Applying patches - Examples of commands that require escalated privileges: @@ -1081,4 +1083,84 @@ mod tests { }) ); } + + #[test] + fn test_shell_tool_for_sandbox_workspace_write() { + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec!["workspace".into()], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let tool = super::create_shell_tool_for_sandbox(&sandbox_policy); + let OpenAiTool::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell"); + + let expected = r#" +The shell tool is used to execute shell commands. +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands will require escalated privileges: + - Types of actions that require escalated privileges: + - Writing files other than those in the writable roots + - writable roots: + - workspace + - Commands that require network access + + - Examples of commands that require escalated privileges: + - git commit + - npm install or pnpm install + - cargo build + - cargo test +- When invoking a command that will require escalated privileges: + - Provide the with_escalated_permissions parameter with the boolean value true + - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter."#; + assert_eq!(description, expected); + } + + #[test] + fn test_shell_tool_for_sandbox_readonly() { + let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::ReadOnly); + let OpenAiTool::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell"); + + let expected = r#" +The shell tool is used to execute shell commands. +- When invoking the shell tool, your call will be running in a sandbox, and some shell commands (including apply_patch) will require escalated permissions: + - Types of actions that require escalated privileges: + - Writing files + - Applying patches + - Examples of commands that require escalated privileges: + - apply_patch + - git commit + - npm install or pnpm install + - cargo build + - cargo test +- When invoking a command that will require escalated privileges: + - Provide the with_escalated_permissions parameter with the boolean value true + - Include a short, 1 sentence explanation for why we need to run with_escalated_permissions in the justification parameter"#; + assert_eq!(description, expected); + } + + #[test] + fn test_shell_tool_for_sandbox_danger_full_access() { + let tool = super::create_shell_tool_for_sandbox(&SandboxPolicy::DangerFullAccess); + let OpenAiTool::Function(ResponsesApiTool { + description, name, .. + }) = &tool + else { + panic!("expected function tool"); + }; + assert_eq!(name, "shell"); + + assert_eq!(description, "Runs a shell command and returns its output."); + } } From af338cc505cd1aa69b6e60ac7091ca23188cf434 Mon Sep 17 00:00:00 2001 From: Lionel Cheng <60159831+lionelchg@users.noreply.github.com> Date: Wed, 3 Sep 2025 19:03:57 +0200 Subject: [PATCH 11/22] Improve @ file search: include specific hidden dirs such as .github, .gitlab (#2981) # Improve @ file search: include specific hidden dirs This should close #2980 ## What - Extend `@` fuzzy file search to include select top-level hidden directories: `.github`, `.gitlab`, `.circleci`, `.devcontainer`, `.azuredevops`, `.vscode`, `.cursor`. - Keep all other hidden directories excluded to avoid noise and heavy traversals. ## Why - Common project config lives under these dot-dirs (CI, editor, devcontainer); users expect `@.github/...` and similar paths to resolve. - Prior behavior hid all dot-dirs, making these files undiscoverable. ## How - In `codex-file-search` walker: - Enable hidden entries via `WalkBuilder.hidden(false)`. - Add `filter_entry` to only allow those specific root dot-directories; other hidden paths remain filtered out. - Preserve `.gitignore` semantics and existing exclude handling. ## Local checks - Ran formatting: `just fmt` - Ran lint (scoped): `just fix -p codex-file-search` - Ran tests: - `cargo test -p codex-file-search` - `cargo test -p codex-tui` ## Readiness - Branch is up-to-date locally; tests pass; lint/format applied. - No merge conflicts expected. - Marking Ready for review. --------- Signed-off-by: lionelchg --- codex-rs/file-search/src/lib.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/codex-rs/file-search/src/lib.rs b/codex-rs/file-search/src/lib.rs index bfcfe922ef..3238c99f24 100644 --- a/codex-rs/file-search/src/lib.rs +++ b/codex-rs/file-search/src/lib.rs @@ -151,7 +151,13 @@ pub fn run( // Use the same tree-walker library that ripgrep uses. We use it directly so // that we can leverage the parallelism it provides. let mut walk_builder = WalkBuilder::new(search_directory); - walk_builder.threads(num_walk_builder_threads); + walk_builder + .threads(num_walk_builder_threads) + // Allow hidden entries. + .hidden(false) + // Don't require git to be present to apply to apply git-related ignore rules. + .require_git(false); + if !exclude.is_empty() { let mut override_builder = OverrideBuilder::new(search_directory); for exclude in exclude { From c636f821aebd07620173da0c2360788d3d43bb3b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 3 Sep 2025 10:11:02 -0700 Subject: [PATCH 12/22] Add a common way to create HTTP client (#3110) Ensure User-Agent and originator are always sent. --- codex-rs/chatgpt/src/apply_command.rs | 2 +- codex-rs/chatgpt/src/chatgpt_client.rs | 7 +- codex-rs/chatgpt/src/chatgpt_token.rs | 7 +- codex-rs/cli/src/login.rs | 6 +- codex-rs/cli/src/proto.rs | 1 + codex-rs/core/src/auth.rs | 79 +++++++++----- codex-rs/core/src/client.rs | 10 +- codex-rs/core/src/default_client.rs | 106 +++++++++++++++++++ codex-rs/core/src/lib.rs | 2 +- codex-rs/core/src/user_agent.rs | 37 ------- codex-rs/core/tests/suite/client.rs | 30 +++--- codex-rs/exec/src/lib.rs | 1 + codex-rs/mcp-server/src/message_processor.rs | 7 +- codex-rs/tui/src/lib.rs | 12 ++- codex-rs/tui/src/updates.rs | 10 +- 15 files changed, 218 insertions(+), 99 deletions(-) create mode 100644 codex-rs/core/src/default_client.rs delete mode 100644 codex-rs/core/src/user_agent.rs diff --git a/codex-rs/chatgpt/src/apply_command.rs b/codex-rs/chatgpt/src/apply_command.rs index 52ab205a0c..3dbfeb1618 100644 --- a/codex-rs/chatgpt/src/apply_command.rs +++ b/codex-rs/chatgpt/src/apply_command.rs @@ -31,7 +31,7 @@ pub async fn run_apply_command( ConfigOverrides::default(), )?; - init_chatgpt_token_from_auth(&config.codex_home).await?; + init_chatgpt_token_from_auth(&config.codex_home, &config.responses_originator_header).await?; let task_response = get_task(&config, apply_cli.task_id).await?; apply_diff_from_task(task_response, cwd).await diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index db75632191..8991003f2b 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -1,5 +1,5 @@ use codex_core::config::Config; -use codex_core::user_agent::get_codex_user_agent; +use codex_core::default_client::create_client; use crate::chatgpt_token::get_chatgpt_token_data; use crate::chatgpt_token::init_chatgpt_token_from_auth; @@ -13,10 +13,10 @@ pub(crate) async fn chatgpt_get_request( path: String, ) -> anyhow::Result { let chatgpt_base_url = &config.chatgpt_base_url; - init_chatgpt_token_from_auth(&config.codex_home).await?; + init_chatgpt_token_from_auth(&config.codex_home, &config.responses_originator_header).await?; // Make direct HTTP request to ChatGPT backend API with the token - let client = reqwest::Client::new(); + let client = create_client(&config.responses_originator_header); let url = format!("{chatgpt_base_url}{path}"); let token = @@ -31,7 +31,6 @@ pub(crate) async fn chatgpt_get_request( .bearer_auth(&token.access_token) .header("chatgpt-account-id", account_id?) .header("Content-Type", "application/json") - .header("User-Agent", get_codex_user_agent(None)) .send() .await .context("Failed to send request")?; diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index 15192ce3fb..c51be9eccf 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -19,8 +19,11 @@ pub fn set_chatgpt_token_data(value: TokenData) { } /// Initialize the ChatGPT token from auth.json file -pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> { - let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT)?; +pub async fn init_chatgpt_token_from_auth( + codex_home: &Path, + originator: &str, +) -> std::io::Result<()> { + let auth = CodexAuth::from_codex_home(codex_home, AuthMode::ChatGPT, originator)?; if let Some(auth) = auth { let token_data = auth.get_token_data().await?; set_chatgpt_token_data(token_data); diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index f03509618d..b8a8e45e02 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -60,7 +60,11 @@ pub async fn run_login_with_api_key( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides); - match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) { + match CodexAuth::from_codex_home( + &config.codex_home, + config.preferred_auth_method, + &config.responses_originator_header, + ) { Ok(Some(auth)) => match auth.mode { AuthMode::ApiKey => match auth.get_token().await { Ok(api_key) => { diff --git a/codex-rs/cli/src/proto.rs b/codex-rs/cli/src/proto.rs index 9b8cb92ee2..8fd8e30df2 100644 --- a/codex-rs/cli/src/proto.rs +++ b/codex-rs/cli/src/proto.rs @@ -40,6 +40,7 @@ pub async fn run_main(opts: ProtoCli) -> anyhow::Result<()> { let conversation_manager = ConversationManager::new(AuthManager::shared( config.codex_home.clone(), config.preferred_auth_method, + config.responses_originator_header.clone(), )); let NewConversation { conversation_id: _, diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index d2954ba6c6..776a111cf2 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -27,6 +27,7 @@ pub struct CodexAuth { pub(crate) api_key: Option, pub(crate) auth_dot_json: Arc>>, pub(crate) auth_file: PathBuf, + pub(crate) client: reqwest::Client, } impl PartialEq for CodexAuth { @@ -36,22 +37,13 @@ impl PartialEq for CodexAuth { } impl CodexAuth { - pub fn from_api_key(api_key: &str) -> Self { - Self { - api_key: Some(api_key.to_owned()), - mode: AuthMode::ApiKey, - auth_file: PathBuf::new(), - auth_dot_json: Arc::new(Mutex::new(None)), - } - } - pub async fn refresh_token(&self) -> Result { let token_data = self .get_current_token_data() .ok_or(std::io::Error::other("Token data is not available."))?; let token = token_data.refresh_token; - let refresh_response = try_refresh_token(token) + let refresh_response = try_refresh_token(token, &self.client) .await .map_err(std::io::Error::other)?; @@ -83,8 +75,9 @@ impl CodexAuth { pub fn from_codex_home( codex_home: &Path, preferred_auth_method: AuthMode, + originator: &str, ) -> std::io::Result> { - load_auth(codex_home, true, preferred_auth_method) + load_auth(codex_home, true, preferred_auth_method, originator) } pub async fn get_token_data(&self) -> Result { @@ -98,7 +91,7 @@ impl CodexAuth { if last_refresh < Utc::now() - chrono::Duration::days(28) { let refresh_response = tokio::time::timeout( Duration::from_secs(60), - try_refresh_token(tokens.refresh_token.clone()), + try_refresh_token(tokens.refresh_token.clone(), &self.client), ) .await .map_err(|_| { @@ -180,8 +173,26 @@ impl CodexAuth { mode: AuthMode::ChatGPT, auth_file: PathBuf::new(), auth_dot_json, + client: crate::default_client::create_client("codex_cli_rs"), } } + + fn from_api_key_with_client(api_key: &str, client: reqwest::Client) -> Self { + Self { + api_key: Some(api_key.to_owned()), + mode: AuthMode::ApiKey, + auth_file: PathBuf::new(), + auth_dot_json: Arc::new(Mutex::new(None)), + client, + } + } + + pub fn from_api_key(api_key: &str) -> Self { + Self::from_api_key_with_client( + api_key, + crate::default_client::create_client(crate::default_client::DEFAULT_ORIGINATOR), + ) + } } pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; @@ -221,18 +232,20 @@ fn load_auth( codex_home: &Path, include_env_var: bool, preferred_auth_method: AuthMode, + originator: &str, ) -> std::io::Result> { // First, check to see if there is a valid auth.json file. If not, we fall // back to AuthMode::ApiKey using the OPENAI_API_KEY environment variable // (if it is set). let auth_file = get_auth_file(codex_home); + let client = crate::default_client::create_client(originator); let auth_dot_json = match try_read_auth_json(&auth_file) { Ok(auth) => auth, // If auth.json does not exist, try to read the OPENAI_API_KEY from the // environment variable. Err(e) if e.kind() == std::io::ErrorKind::NotFound && include_env_var => { return match read_openai_api_key_from_env() { - Some(api_key) => Ok(Some(CodexAuth::from_api_key(&api_key))), + Some(api_key) => Ok(Some(CodexAuth::from_api_key_with_client(&api_key, client))), None => Ok(None), }; } @@ -258,7 +271,7 @@ fn load_auth( match &tokens { Some(tokens) => { if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { - return Ok(Some(CodexAuth::from_api_key(api_key))); + return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); } else { // Ignore the API key and fall through to ChatGPT auth. } @@ -268,7 +281,7 @@ fn load_auth( // Perhaps the user ran `codex login --api-key ` or updated // auth.json by hand. Either way, let's assume they are trying // to use their API key. - return Ok(Some(CodexAuth::from_api_key(api_key))); + return Ok(Some(CodexAuth::from_api_key_with_client(api_key, client))); } } } @@ -284,6 +297,7 @@ fn load_auth( tokens, last_refresh, }))), + client, })) } @@ -333,7 +347,10 @@ async fn update_tokens( Ok(auth_dot_json) } -async fn try_refresh_token(refresh_token: String) -> std::io::Result { +async fn try_refresh_token( + refresh_token: String, + client: &reqwest::Client, +) -> std::io::Result { let refresh_request = RefreshRequest { client_id: CLIENT_ID, grant_type: "refresh_token", @@ -341,7 +358,7 @@ async fn try_refresh_token(refresh_token: String) -> std::io::Result, } @@ -668,12 +689,13 @@ impl AuthManager { /// preferred auth method. Errors loading auth are swallowed; `auth()` will /// simply return `None` in that case so callers can treat it as an /// unauthenticated state. - pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Self { - let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode) + pub fn new(codex_home: PathBuf, preferred_auth_mode: AuthMode, originator: String) -> Self { + let auth = CodexAuth::from_codex_home(&codex_home, preferred_auth_mode, &originator) .ok() .flatten(); Self { codex_home, + originator, inner: RwLock::new(CachedAuth { preferred_auth_mode, auth, @@ -690,6 +712,7 @@ impl AuthManager { }; Arc::new(Self { codex_home: PathBuf::new(), + originator: "codex_cli_rs".to_string(), inner: RwLock::new(cached), }) } @@ -711,7 +734,7 @@ impl AuthManager { /// whether the auth value changed. pub fn reload(&self) -> bool { let preferred = self.preferred_auth_method(); - let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred) + let new_auth = CodexAuth::from_codex_home(&self.codex_home, preferred, &self.originator) .ok() .flatten(); if let Ok(mut guard) = self.inner.write() { @@ -732,8 +755,12 @@ impl AuthManager { } /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf, preferred_auth_mode: AuthMode) -> Arc { - Arc::new(Self::new(codex_home, preferred_auth_mode)) + pub fn shared( + codex_home: PathBuf, + preferred_auth_mode: AuthMode, + originator: String, + ) -> Arc { + Arc::new(Self::new(codex_home, preferred_auth_mode, originator)) } /// Attempt to refresh the current auth token (if any). On success, reload diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index b92acef413..0481767030 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -30,6 +30,7 @@ use crate::client_common::ResponsesApiRequest; use crate::client_common::create_reasoning_param_for_request; use crate::client_common::create_text_param_for_request; use crate::config::Config; +use crate::default_client::create_client; use crate::error::CodexErr; use crate::error::Result; use crate::error::UsageLimitReachedError; @@ -40,7 +41,6 @@ use crate::model_provider_info::WireApi; use crate::openai_model_info::get_model_info; use crate::openai_tools::create_tools_json_for_responses_api; use crate::protocol::TokenUsage; -use crate::user_agent::get_codex_user_agent; use crate::util::backoff; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; @@ -84,10 +84,12 @@ impl ModelClient { summary: ReasoningSummaryConfig, session_id: Uuid, ) -> Self { + let client = create_client(&config.responses_originator_header); + Self { config, auth_manager, - client: reqwest::Client::new(), + client, provider, session_id, effort, @@ -242,10 +244,6 @@ impl ModelClient { req_builder = req_builder.header("chatgpt-account-id", account_id); } - let originator = &self.config.responses_originator_header; - req_builder = req_builder.header("originator", originator); - req_builder = req_builder.header("User-Agent", get_codex_user_agent(Some(originator))); - let res = req_builder.send().await; if let Ok(resp) = &res { trace!( diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs new file mode 100644 index 0000000000..d274351779 --- /dev/null +++ b/codex-rs/core/src/default_client.rs @@ -0,0 +1,106 @@ +pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; + +pub fn get_codex_user_agent(originator: Option<&str>) -> String { + let build_version = env!("CARGO_PKG_VERSION"); + let os_info = os_info::get(); + format!( + "{}/{build_version} ({} {}; {}) {}", + originator.unwrap_or(DEFAULT_ORIGINATOR), + os_info.os_type(), + os_info.version(), + os_info.architecture().unwrap_or("unknown"), + crate::terminal::user_agent() + ) +} + +/// Create a reqwest client with default `originator` and `User-Agent` headers set. +pub fn create_client(originator: &str) -> reqwest::Client { + use reqwest::header::HeaderMap; + use reqwest::header::HeaderValue; + + let mut headers = HeaderMap::new(); + let originator_value = HeaderValue::from_str(originator) + .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_ORIGINATOR)); + headers.insert("originator", originator_value); + let ua = get_codex_user_agent(Some(originator)); + + match reqwest::Client::builder() + // Set UA via dedicated helper to avoid header validation pitfalls + .user_agent(ua) + .default_headers(headers) + .build() + { + Ok(client) => client, + Err(_) => reqwest::Client::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_codex_user_agent() { + let user_agent = get_codex_user_agent(None); + assert!(user_agent.starts_with("codex_cli_rs/")); + } + + #[tokio::test] + async fn test_create_client_sets_default_headers() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let originator = "test_originator"; + let client = create_client(originator); + + // Spin up a local mock server and capture a request. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/")) + .respond_with(ResponseTemplate::new(200)) + .mount(&server) + .await; + + let resp = client + .get(server.uri()) + .send() + .await + .expect("failed to send request"); + assert!(resp.status().is_success()); + + let requests = server + .received_requests() + .await + .expect("failed to fetch received requests"); + assert!(!requests.is_empty()); + let headers = &requests[0].headers; + + // originator header is set to the provided value + let originator_header = headers + .get("originator") + .expect("originator header missing"); + assert_eq!(originator_header.to_str().unwrap(), originator); + + // User-Agent matches the computed Codex UA for that originator + let expected_ua = get_codex_user_agent(Some(originator)); + let ua_header = headers + .get("user-agent") + .expect("user-agent header missing"); + assert_eq!(ua_header.to_str().unwrap(), expected_ua); + } + + #[test] + #[cfg(target_os = "macos")] + fn test_macos() { + use regex_lite::Regex; + let user_agent = get_codex_user_agent(None); + let re = Regex::new( + r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$", + ) + .unwrap(); + assert!(re.is_match(&user_agent)); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 8ae3e335bd..9f731298f8 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -45,6 +45,7 @@ pub use conversation_manager::NewConversation; // Re-export common auth types for workspace consumers pub use auth::AuthManager; pub use auth::CodexAuth; +pub mod default_client; pub mod model_family; mod openai_model_info; mod openai_tools; @@ -58,7 +59,6 @@ pub mod spawn; pub mod terminal; mod tool_apply_patch; pub mod turn_diff_tracker; -pub mod user_agent; pub use rollout::list::ConversationsPage; mod user_notification; pub mod util; diff --git a/codex-rs/core/src/user_agent.rs b/codex-rs/core/src/user_agent.rs deleted file mode 100644 index a63170cebd..0000000000 --- a/codex-rs/core/src/user_agent.rs +++ /dev/null @@ -1,37 +0,0 @@ -const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; - -pub fn get_codex_user_agent(originator: Option<&str>) -> String { - let build_version = env!("CARGO_PKG_VERSION"); - let os_info = os_info::get(); - format!( - "{}/{build_version} ({} {}; {}) {}", - originator.unwrap_or(DEFAULT_ORIGINATOR), - os_info.os_type(), - os_info.version(), - os_info.architecture().unwrap_or("unknown"), - crate::terminal::user_agent() - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_get_codex_user_agent() { - let user_agent = get_codex_user_agent(None); - assert!(user_agent.starts_with("codex_cli_rs/")); - } - - #[test] - #[cfg(target_os = "macos")] - fn test_macos() { - use regex_lite::Regex; - let user_agent = get_codex_user_agent(None); - let re = Regex::new( - r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$", - ) - .unwrap(); - assert!(re.is_match(&user_agent)); - } -} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f77749b4a9..07a51f07a6 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -414,12 +414,15 @@ async fn prefers_chatgpt_token_when_config_prefers_chatgpt() { config.model_provider = model_provider; config.preferred_auth_method = AuthMode::ChatGPT; - let auth_manager = - match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), - Ok(None) => panic!("No CodexAuth found in codex_home"), - Err(e) => panic!("Failed to load CodexAuth: {e}"), - }; + let auth_manager = match CodexAuth::from_codex_home( + codex_home.path(), + config.preferred_auth_method, + &config.responses_originator_header, + ) { + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), + Ok(None) => panic!("No CodexAuth found in codex_home"), + Err(e) => panic!("Failed to load CodexAuth: {e}"), + }; let conversation_manager = ConversationManager::new(auth_manager); let NewConversation { conversation: codex, @@ -495,12 +498,15 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { config.model_provider = model_provider; config.preferred_auth_method = AuthMode::ApiKey; - let auth_manager = - match CodexAuth::from_codex_home(codex_home.path(), config.preferred_auth_method) { - Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), - Ok(None) => panic!("No CodexAuth found in codex_home"), - Err(e) => panic!("Failed to load CodexAuth: {e}"), - }; + let auth_manager = match CodexAuth::from_codex_home( + codex_home.path(), + config.preferred_auth_method, + &config.responses_originator_header, + ) { + Ok(Some(auth)) => codex_core::AuthManager::from_auth_for_testing(auth), + Ok(None) => panic!("No CodexAuth found in codex_home"), + Err(e) => panic!("Failed to load CodexAuth: {e}"), + }; let conversation_manager = ConversationManager::new(auth_manager); let NewConversation { conversation: codex, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index d57d55bc8f..d5991efdad 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -191,6 +191,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let conversation_manager = ConversationManager::new(AuthManager::shared( config.codex_home.clone(), config.preferred_auth_method, + config.responses_originator_header.clone(), )); let NewConversation { conversation_id: _, diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index dbd1077d13..bf5dbf9804 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -53,8 +53,11 @@ impl MessageProcessor { config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = - AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + config.preferred_auth_method, + config.responses_originator_header.clone(), + ); let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); let codex_message_processor = CodexMessageProcessor::new( auth_manager, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 17506094fb..5ce17f7aa6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -301,7 +301,11 @@ async fn run_ratatui_app( let Cli { prompt, images, .. } = cli; - let auth_manager = AuthManager::shared(config.codex_home.clone(), config.preferred_auth_method); + let auth_manager = AuthManager::shared( + config.codex_home.clone(), + config.preferred_auth_method, + config.responses_originator_header.clone(), + ); let login_status = get_login_status(&config); let should_show_onboarding = should_show_onboarding(login_status, &config, should_show_trust_screen); @@ -357,7 +361,11 @@ fn get_login_status(config: &Config) -> LoginStatus { // Reading the OpenAI API key is an async operation because it may need // to refresh the token. Block on it. let codex_home = config.codex_home.clone(); - match CodexAuth::from_codex_home(&codex_home, config.preferred_auth_method) { + match CodexAuth::from_codex_home( + &codex_home, + config.preferred_auth_method, + &config.responses_originator_header, + ) { Ok(Some(auth)) => LoginStatus::AuthMode(auth.mode), Ok(None) => LoginStatus::NotAuthenticated, Err(err) => { diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index fae170ff1a..39d084b407 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -9,7 +9,7 @@ use std::path::Path; use std::path::PathBuf; use codex_core::config::Config; -use codex_core::user_agent::get_codex_user_agent; +use codex_core::default_client::create_client; pub fn get_upgrade_version(config: &Config) -> Option { let version_file = version_filepath(config); @@ -22,8 +22,9 @@ pub fn get_upgrade_version(config: &Config) -> Option { // Refresh the cached latest version in the background so TUI startup // isn’t blocked by a network call. The UI reads the previously cached // value (if any) for this run; the next run shows the banner if needed. + let originator = config.responses_originator_header.clone(); tokio::spawn(async move { - check_for_update(&version_file) + check_for_update(&version_file, &originator) .await .inspect_err(|e| tracing::error!("Failed to update version: {e}")) }); @@ -63,12 +64,11 @@ fn read_version_info(version_file: &Path) -> anyhow::Result { Ok(serde_json::from_str(&contents)?) } -async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { +async fn check_for_update(version_file: &Path, originator: &str) -> anyhow::Result<()> { let ReleaseInfo { tag_name: latest_tag_name, - } = reqwest::Client::new() + } = create_client(originator) .get(LATEST_RELEASE_URL) - .header("User-Agent", get_codex_user_agent(None)) .send() .await? .error_for_status()? From daaadfb2606f6c1d97b7d6d8ffb888008a9e8822 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Sep 2025 10:37:07 -0700 Subject: [PATCH 13/22] Introduce Rollout Policy (#3116) Have a helper function for deciding if we are rolling out a function or not --- codex-rs/core/src/rollout/mod.rs | 1 + codex-rs/core/src/rollout/policy.rs | 16 +++++++++ codex-rs/core/src/rollout/recorder.rs | 49 +++++++-------------------- 3 files changed, 30 insertions(+), 36 deletions(-) create mode 100644 codex-rs/core/src/rollout/policy.rs diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 19c66a19ce..c6b4fc58a3 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -3,6 +3,7 @@ pub(crate) const SESSIONS_SUBDIR: &str = "sessions"; pub mod list; +pub(crate) mod policy; pub mod recorder; pub use recorder::RolloutRecorder; diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/core/src/rollout/policy.rs new file mode 100644 index 0000000000..3eb5290321 --- /dev/null +++ b/codex-rs/core/src/rollout/policy.rs @@ -0,0 +1,16 @@ +use codex_protocol::models::ResponseItem; + +/// Whether a `ResponseItem` should be persisted in rollout files. +#[inline] +pub(crate) fn is_persisted_response_item(item: &ResponseItem) -> bool { + match item { + ResponseItem::Message { .. } + | ResponseItem::Reasoning { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::FunctionCallOutput { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::CustomToolCallOutput { .. } => true, + ResponseItem::WebSearchCall { .. } | ResponseItem::Other => false, + } +} diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 20d30df654..68bcb439d7 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -23,6 +23,7 @@ use super::SESSIONS_SUBDIR; use super::list::ConversationsPage; use super::list::Cursor; use super::list::get_conversations; +use super::policy::is_persisted_response_item; use crate::config::Config; use crate::conversation_manager::InitialHistory; use crate::git_info::GitInfo; @@ -137,21 +138,11 @@ impl RolloutRecorder { pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> { let mut filtered = Vec::new(); for item in items { - match item { - // Note that function calls may look a bit strange if they are - // "fully qualified MCP tool calls," so we could consider - // reformatting them in that case. - ResponseItem::Message { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::Reasoning { .. } => filtered.push(item.clone()), - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => { - // These should never be serialized. - continue; - } + // Note that function calls may look a bit strange if they are + // "fully qualified MCP tool calls," so we could consider + // reformatting them in that case. + if is_persisted_response_item(item) { + filtered.push(item.clone()); } } if filtered.is_empty() { @@ -195,16 +186,11 @@ impl RolloutRecorder { continue; } match serde_json::from_value::(v.clone()) { - Ok(item) => match item { - ResponseItem::Message { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::Reasoning { .. } => items.push(item), - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {} - }, + Ok(item) => { + if is_persisted_response_item(&item) { + items.push(item); + } + } Err(e) => { warn!("failed to parse item: {v:?}, error: {e}"); } @@ -305,17 +291,8 @@ async fn rollout_writer( match cmd { RolloutCmd::AddItems(items) => { for item in items { - match item { - ResponseItem::Message { .. } - | ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::FunctionCallOutput { .. } - | ResponseItem::CustomToolCall { .. } - | ResponseItem::CustomToolCallOutput { .. } - | ResponseItem::Reasoning { .. } => { - writer.write_line(&item).await?; - } - ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {} + if is_persisted_response_item(&item) { + writer.write_line(&item).await?; } } } From 0e827b6598d2fd18325868e4c0419dde2b16db29 Mon Sep 17 00:00:00 2001 From: Sing303 Date: Wed, 3 Sep 2025 18:57:47 +0100 Subject: [PATCH 14/22] Auto-approve DangerFullAccess patches on non-sandboxed platforms (#2988) **What?** Auto-approve patches when `SandboxPolicy::DangerFullAccess` is enabled on platforms without sandbox support. Changes in `codex-rs/core/src/safety.rs`: return `SafetyCheck::AutoApprove { sandbox_type: SandboxType::None }` when no sandbox is available and DangerFullAccess is set. **Why?** On platforms lacking sandbox support, requiring explicit user approval despite `DangerFullAccess` being explicitly enabled adds friction without additional safety. This aligns behavior with the stated policy intent. **How?** Extend `assess_patch_safety` match: * If `get_platform_sandbox()` returns `Some`, keep `AutoApprove { sandbox_type }`. * If `None` **and** `SandboxPolicy::DangerFullAccess`, return `AutoApprove { SandboxType::None }`. * Otherwise, fall back to `AskUser`. **Tests** * Local checks: ```bash cargo test && cargo clippy --tests && cargo fmt -- --config imports_granularity=Item ``` (Additionally: `just fmt`, `just fix -p codex-core`, `cargo check -p codex-core`.) **Docs** No user-facing CLI changes. No README/help updates needed. **Risk/Impact** Reduces prompts on non-sandboxed platforms when DangerFullAccess is explicitly chosen; consistent with policy semantics. --------- Co-authored-by: Michael Bolin --- codex-rs/core/src/safety.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/codex-rs/core/src/safety.rs b/codex-rs/core/src/safety.rs index 752177ed01..790267f55a 100644 --- a/codex-rs/core/src/safety.rs +++ b/codex-rs/core/src/safety.rs @@ -53,6 +53,13 @@ pub fn assess_patch_safety( // paths outside the project. match get_platform_sandbox() { Some(sandbox_type) => SafetyCheck::AutoApprove { sandbox_type }, + None if sandbox_policy == &SandboxPolicy::DangerFullAccess => { + // If the user has explicitly requested DangerFullAccess, then + // we can auto-approve even without a sandbox. + SafetyCheck::AutoApprove { + sandbox_type: SandboxType::None, + } + } None => SafetyCheck::AskUser, } } else if policy == AskForApproval::Never { From 77fb9f34659ce945ffecd7ddf8c16814579274c3 Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Wed, 3 Sep 2025 11:16:07 -0700 Subject: [PATCH 15/22] [codex] document `use_experimental_reasoning_summary` toml key config (#3118) Follow up on https://github.com/openai/codex/issues/3101 --- docs/config.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/config.md b/docs/config.md index 3e1f93df8f..5d7573febf 100644 --- a/docs/config.md +++ b/docs/config.md @@ -611,6 +611,7 @@ Options that are specific to the TUI. | `experimental_resume` | string (path) | Resume JSONL path (internal/experimental). | | `experimental_instructions_file` | string (path) | Replace built‑in instructions (experimental). | | `experimental_use_exec_command_tool` | boolean | Use experimental exec command tool. | +| `use_experimental_reasoning_summary` | boolean | Use experimental summary for reasoning chain. | | `responses_originator_header_internal_override` | string | Override `originator` header value. | | `projects..trust_level` | string | Mark project/worktree as trusted (only `"trusted"` is recognized). | | `preferred_auth_method` | `chatgpt` \| `apikey` | Select default auth method (default: `chatgpt`). | From db5276f8e604bdd1c44506edf765ba4cb08ed7f4 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 3 Sep 2025 12:20:31 -0700 Subject: [PATCH 16/22] chore: Clean up verbosity config (#3056) ## Summary It appears that #2108 hit a merge conflict with #2355 - I failed to notice the path difference when re-reviewing the former. This PR rectifies that, and consolidates it into the protocol package, in line with our philosophy of specifying types in one place. ## Testing - [x] Adds config test for model_verbosity --- codex-rs/core/src/client_common.rs | 2 +- codex-rs/core/src/config.rs | 70 ++++++++++++++++++++++++++- codex-rs/core/src/config_profile.rs | 2 +- codex-rs/core/src/config_types.rs | 42 ---------------- codex-rs/protocol/src/config_types.rs | 12 +++++ 5 files changed, 83 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 77f6a63a09..e326ea3324 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,4 +1,3 @@ -use crate::config_types::Verbosity as VerbosityConfig; use crate::error::Result; use crate::model_family::ModelFamily; use crate::openai_tools::OpenAiTool; @@ -6,6 +5,7 @@ use crate::protocol::TokenUsage; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::config_types::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; +use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use futures::Stream; diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 6a3f39b652..8d66772b51 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -6,7 +6,6 @@ use crate::config_types::ShellEnvironmentPolicy; use crate::config_types::ShellEnvironmentPolicyToml; use crate::config_types::Tui; use crate::config_types::UriBasedFileOpener; -use crate::config_types::Verbosity; use crate::git_info::resolve_root_git_project_for_trust; use crate::model_family::ModelFamily; use crate::model_family::find_family_for_model; @@ -18,6 +17,7 @@ use crate::protocol::SandboxPolicy; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::SandboxMode; +use codex_protocol::config_types::Verbosity; use codex_protocol::mcp_protocol::AuthMode; use dirs::home_dir; use serde::Deserialize; @@ -1067,6 +1067,14 @@ model = "o3" model_provider = "openai" approval_policy = "on-failure" disable_response_storage = true + +[profiles.gpt5] +model = "gpt-5" +model_provider = "openai" +approval_policy = "on-failure" +model_reasoning_effort = "high" +model_reasoning_summary = "detailed" +model_verbosity = "high" "#; let cfg: ConfigToml = toml::from_str(toml).expect("TOML deserialization should succeed"); @@ -1325,6 +1333,66 @@ disable_response_storage = true Ok(()) } + #[test] + fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { + let fixture = create_test_fixture()?; + + let gpt5_profile_overrides = ConfigOverrides { + config_profile: Some("gpt5".to_string()), + cwd: Some(fixture.cwd()), + ..Default::default() + }; + let gpt5_profile_config = Config::load_from_base_config_with_overrides( + fixture.cfg.clone(), + gpt5_profile_overrides, + fixture.codex_home(), + )?; + let expected_gpt5_profile_config = Config { + model: "gpt-5".to_string(), + model_family: find_family_for_model("gpt-5").expect("known model slug"), + model_context_window: Some(400_000), + model_max_output_tokens: Some(128_000), + model_provider_id: "openai".to_string(), + model_provider: fixture.openai_provider.clone(), + approval_policy: AskForApproval::OnFailure, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + shell_environment_policy: ShellEnvironmentPolicy::default(), + disable_response_storage: false, + user_instructions: None, + notify: None, + cwd: fixture.cwd(), + mcp_servers: HashMap::new(), + model_providers: fixture.model_provider_map.clone(), + project_doc_max_bytes: PROJECT_DOC_MAX_BYTES, + codex_home: fixture.codex_home(), + history: History::default(), + file_opener: UriBasedFileOpener::VsCode, + tui: Tui::default(), + codex_linux_sandbox_exe: None, + hide_agent_reasoning: false, + show_raw_agent_reasoning: false, + model_reasoning_effort: ReasoningEffort::High, + model_reasoning_summary: ReasoningSummary::Detailed, + model_verbosity: Some(Verbosity::High), + chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + experimental_resume: None, + base_instructions: None, + include_plan_tool: false, + include_apply_patch_tool: false, + tools_web_search_request: false, + responses_originator_header: "codex_cli_rs".to_string(), + preferred_auth_method: AuthMode::ChatGPT, + use_experimental_streamable_shell_tool: false, + include_view_image_tool: true, + disable_paste_burst: false, + use_experimental_reasoning_summary: false, + }; + + assert_eq!(expected_gpt5_profile_config, gpt5_profile_config); + + Ok(()) + } + #[test] fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { let codex_home = TempDir::new().unwrap(); diff --git a/codex-rs/core/src/config_profile.rs b/codex-rs/core/src/config_profile.rs index 54869919fb..0f25d3168d 100644 --- a/codex-rs/core/src/config_profile.rs +++ b/codex-rs/core/src/config_profile.rs @@ -1,10 +1,10 @@ use serde::Deserialize; use std::path::PathBuf; -use crate::config_types::Verbosity; use crate::protocol::AskForApproval; use codex_protocol::config_types::ReasoningEffort; use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::Verbosity; /// Collection of common configuration options that a user can define as a unit /// in `config.toml`. diff --git a/codex-rs/core/src/config_types.rs b/codex-rs/core/src/config_types.rs index 88be151fff..d6661699fe 100644 --- a/codex-rs/core/src/config_types.rs +++ b/codex-rs/core/src/config_types.rs @@ -8,8 +8,6 @@ use std::path::PathBuf; use wildmatch::WildMatchPattern; use serde::Deserialize; -use serde::Serialize; -use strum_macros::Display; #[derive(Deserialize, Debug, Clone, PartialEq)] pub struct McpServerConfig { @@ -185,43 +183,3 @@ impl From for ShellEnvironmentPolicy { } } } - -/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningEffort { - Low, - #[default] - Medium, - High, - /// Option to disable reasoning. - None, -} - -/// A summary of the reasoning performed by the model. This can be useful for -/// debugging and understanding the model's reasoning process. -/// See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum ReasoningSummary { - #[default] - Auto, - Concise, - Detailed, - /// Option to disable reasoning summaries. - None, -} - -/// Controls output length/detail on GPT-5 models via the Responses API. -/// Serialized with lowercase values to match the OpenAI API. -#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)] -#[serde(rename_all = "lowercase")] -#[strum(serialize_all = "lowercase")] -pub enum Verbosity { - Low, - #[default] - Medium, - High, -} diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 46fd9c1359..510c64090c 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -35,6 +35,18 @@ pub enum ReasoningSummary { None, } +/// Controls output length/detail on GPT-5 models via the Responses API. +/// Serialized with lowercase values to match the OpenAI API. +#[derive(Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display)] +#[serde(rename_all = "lowercase")] +#[strum(serialize_all = "lowercase")] +pub enum Verbosity { + Low, + #[default] + Medium, + High, +} + #[derive(Deserialize, Debug, Clone, Copy, PartialEq, Default, Serialize, Display, TS)] #[serde(rename_all = "kebab-case")] #[strum(serialize_all = "kebab-case")] From 6b83c1c3f3dacf6da342706098f1cb04335e8217 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 3 Sep 2025 15:38:32 -0700 Subject: [PATCH 17/22] Fix failing CI (#3130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this test, the ChatGPT token path is used, and the auth layer tries to refresh the token if it thinks the token is “old.” Your helper writes a fixed last_refresh timestamp that has now aged past the 28‑day threshold, so the code attempts a real refresh against auth.openai.com, never reaches the mock, and you end up with received_requests().await.unwrap() being empty. --- codex-rs/core/tests/suite/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 07a51f07a6..dd152dcbb2 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -96,7 +96,7 @@ fn write_auth_json( "OPENAI_API_KEY": openai_api_key, "tokens": tokens, // RFC3339 datetime; value doesn't matter for these tests - "last_refresh": "2025-08-06T20:41:36.232376Z", + "last_refresh": chrono::Utc::now(), }); std::fs::write( From 4ae45a6c8df62287d720385430d0458a0b2dc354 Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 3 Sep 2025 16:00:33 -0700 Subject: [PATCH 18/22] remove bold the keyword from prompt (#3121) the model was often including the literal text "Bold the keyword" in lists. this guidance doesn't seem particularly useful to me, so just drop it. --- codex-rs/core/prompt.md | 1 - 1 file changed, 1 deletion(-) diff --git a/codex-rs/core/prompt.md b/codex-rs/core/prompt.md index cc7e930a5d..4b39ed6bbe 100644 --- a/codex-rs/core/prompt.md +++ b/codex-rs/core/prompt.md @@ -228,7 +228,6 @@ You are producing plain text that will later be styled by the CLI. Follow these **Bullets** - Use `-` followed by a space for every bullet. -- Bold the keyword, then colon + concise description. - Merge related points when possible; avoid a bullet for every trivial detail. - Keep bullets to one line unless breaking for clarity is unavoidable. - Group into short lists (4–6 bullets) ordered by importance. From ed0d23d560a9276489c4b709cc7379f4e62a665d Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 3 Sep 2025 16:38:09 -0700 Subject: [PATCH 19/22] [tui] Update /mcp output (#3134) # Summary Quick update to clean up MCP output ## Testing - [x] Ran locally, confirmed output looked good --- codex-rs/tui/src/history_cell.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index f8790a0169..50f3e3154f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1076,14 +1076,6 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); } - if let Some(env) = cfg.env.as_ref() - && !env.is_empty() - { - let mut env_pairs: Vec = env.iter().map(|(k, v)| format!("{k}={v}")).collect(); - env_pairs.sort(); - lines.push(vec![" • Env: ".into(), env_pairs.join(" ").into()].into()); - } - if names.is_empty() { lines.push(" • Tools: (none)".into()); } else { From e83c5f429c54cdb12654629926ed51435c3bf4bb Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 3 Sep 2025 16:51:00 -0700 Subject: [PATCH 20/22] Include originator in authentication URL parameters (#3117) Associates the client with an authentication session. --- codex-rs/cli/src/login.rs | 11 ++++++++--- codex-rs/login/src/server.rs | 15 +++++++++++++-- codex-rs/login/tests/suite/login_server_e2e.rs | 2 ++ .../mcp-server/src/codex_message_processor.rs | 6 +++++- codex-rs/tui/src/lib.rs | 4 +--- codex-rs/tui/src/onboarding/auth.rs | 8 +++++++- codex-rs/tui/src/onboarding/onboarding_screen.rs | 16 ++++++++-------- 7 files changed, 44 insertions(+), 18 deletions(-) diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index b8a8e45e02..cff03e7c16 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -12,8 +12,8 @@ use codex_protocol::mcp_protocol::AuthMode; use std::env; use std::path::PathBuf; -pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { - let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string()); +pub async fn login_with_chatgpt(codex_home: PathBuf, originator: String) -> std::io::Result<()> { + let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string(), originator); let server = run_login_server(opts)?; eprintln!( @@ -27,7 +27,12 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides); - match login_with_chatgpt(config.codex_home).await { + match login_with_chatgpt( + config.codex_home, + config.responses_originator_header.clone(), + ) + .await + { Ok(_) => { eprintln!("Successfully logged in"); std::process::exit(0); diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 5cc08d4205..bca3b16420 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -30,10 +30,11 @@ pub struct ServerOptions { pub port: u16, pub open_browser: bool, pub force_state: Option, + pub originator: String, } impl ServerOptions { - pub fn new(codex_home: PathBuf, client_id: String) -> Self { + pub fn new(codex_home: PathBuf, client_id: String, originator: String) -> Self { Self { codex_home, client_id: client_id.to_string(), @@ -41,6 +42,7 @@ impl ServerOptions { port: DEFAULT_PORT, open_browser: true, force_state: None, + originator, } } } @@ -96,7 +98,14 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result { let server = Arc::new(server); let redirect_uri = format!("http://localhost:{actual_port}/auth/callback"); - let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state); + let auth_url = build_authorize_url( + &opts.issuer, + &opts.client_id, + &redirect_uri, + &pkce, + &state, + &opts.originator, + ); if opts.open_browser { let _ = webbrowser::open(&auth_url); @@ -279,6 +288,7 @@ fn build_authorize_url( redirect_uri: &str, pkce: &PkceCodes, state: &str, + originator: &str, ) -> String { let query = vec![ ("response_type", "code"), @@ -290,6 +300,7 @@ fn build_authorize_url( ("id_token_add_organizations", "true"), ("codex_cli_simplified_flow", "true"), ("state", state), + ("originator", originator), ]; let qs = query .into_iter() diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index ceb0a94733..ef6b80fbe1 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -100,6 +100,7 @@ async fn end_to_end_login_flow_persists_auth_json() { port: 0, open_browser: false, force_state: Some(state), + originator: "test_originator".to_string(), }; let server = run_login_server(opts).unwrap(); let login_port = server.actual_port; @@ -158,6 +159,7 @@ async fn creates_missing_codex_home_dir() { port: 0, open_browser: false, force_state: Some(state), + originator: "test_originator".to_string(), }; let server = run_login_server(opts).unwrap(); let login_port = server.actual_port; diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 72474d9866..fd82496af9 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -160,7 +160,11 @@ impl CodexMessageProcessor { let opts = LoginServerOptions { open_browser: false, - ..LoginServerOptions::new(config.codex_home.clone(), CLIENT_ID.to_string()) + ..LoginServerOptions::new( + config.codex_home.clone(), + CLIENT_ID.to_string(), + config.responses_originator_header.clone(), + ) }; enum LoginChatGptReply { diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 5ce17f7aa6..8899b0e139 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -312,13 +312,11 @@ async fn run_ratatui_app( if should_show_onboarding { let directory_trust_decision = run_onboarding_app( OnboardingScreenArgs { - codex_home: config.codex_home.clone(), - cwd: config.cwd.clone(), show_login_screen: should_show_login_screen(login_status, &config), show_trust_screen: should_show_trust_screen, login_status, - preferred_auth_method: config.preferred_auth_method, auth_manager: auth_manager.clone(), + config: config.clone(), }, &mut tui, ) diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index c7217ac1af..90b2c994f1 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -2,6 +2,7 @@ use codex_core::AuthManager; use codex_core::auth::CLIENT_ID; +use codex_core::config::Config; use codex_login::ServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -113,6 +114,7 @@ pub(crate) struct AuthModeWidget { pub login_status: LoginStatus, pub preferred_auth_method: AuthMode, pub auth_manager: Arc, + pub config: Config, } impl AuthModeWidget { @@ -314,7 +316,11 @@ impl AuthModeWidget { } self.error = None; - let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); + let opts = ServerOptions::new( + self.codex_home.clone(), + CLIENT_ID.to_string(), + self.config.responses_originator_header.clone(), + ); match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 5afd393c02..beb96bce3f 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,4 +1,5 @@ use codex_core::AuthManager; +use codex_core::config::Config; use codex_core::git_info::get_git_repo_root; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -21,7 +22,6 @@ use crate::tui::FrameRequester; use crate::tui::Tui; use crate::tui::TuiEvent; use color_eyre::eyre::Result; -use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; @@ -53,26 +53,25 @@ pub(crate) struct OnboardingScreen { } pub(crate) struct OnboardingScreenArgs { - pub codex_home: PathBuf, - pub cwd: PathBuf, pub show_trust_screen: bool, pub show_login_screen: bool, pub login_status: LoginStatus, - pub preferred_auth_method: AuthMode, pub auth_manager: Arc, + pub config: Config, } impl OnboardingScreen { pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { - codex_home, - cwd, show_trust_screen, show_login_screen, login_status, - preferred_auth_method, auth_manager, + config, } = args; + let preferred_auth_method = config.preferred_auth_method; + let cwd = config.cwd.clone(); + let codex_home = config.codex_home.clone(); let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), })]; @@ -84,8 +83,9 @@ impl OnboardingScreen { sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), login_status, - preferred_auth_method, auth_manager, + preferred_auth_method, + config, })) } let is_git_repo = get_git_repo_root(&cwd).is_some(); From bea64569c108e1ef4206620a7d983185f60b42f0 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 3 Sep 2025 17:05:03 -0700 Subject: [PATCH 21/22] MCP sandbox call (#3128) I have read the CLA Document and I hereby sign the CLA --- codex-rs/justfile | 4 + .../mcp-server/src/codex_message_processor.rs | 123 ++++++++++++++---- codex-rs/protocol/src/mcp_protocol.rs | 30 +++++ 3 files changed, 134 insertions(+), 23 deletions(-) diff --git a/codex-rs/justfile b/codex-rs/justfile index beaa682daa..e47ad1a2bd 100644 --- a/codex-rs/justfile +++ b/codex-rs/justfile @@ -30,3 +30,7 @@ fix *args: install: rustup show active-toolchain cargo fetch + +# Run the MCP server +mcp-server-run *args: + cargo run -p codex-mcp-server -- "$@" diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index fd82496af9..9174403e52 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -3,37 +3,31 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use codex_core::AuthManager; -use codex_core::CodexConversation; -use codex_core::ConversationManager; -use codex_core::NewConversation; -use codex_core::config::Config; -use codex_core::config::ConfigOverrides; -use codex_core::config::ConfigToml; -use codex_core::config::load_config_as_toml; -use codex_core::git_info::git_diff_to_remote; -use codex_core::protocol::ApplyPatchApprovalRequestEvent; -use codex_core::protocol::Event; -use codex_core::protocol::EventMsg; -use codex_core::protocol::ExecApprovalRequestEvent; -use codex_core::protocol::ReviewDecision; -use codex_protocol::mcp_protocol::AuthMode; -use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; -use mcp_types::JSONRPCErrorError; -use mcp_types::RequestId; -use tokio::sync::Mutex; -use tokio::sync::oneshot; -use tracing::error; -use uuid::Uuid; - use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::json_to_toml::json_to_toml; use crate::outgoing_message::OutgoingMessageSender; use crate::outgoing_message::OutgoingNotification; +use codex_core::AuthManager; +use codex_core::CodexConversation; +use codex_core::ConversationManager; +use codex_core::NewConversation; use codex_core::auth::CLIENT_ID; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; +use codex_core::config::ConfigToml; +use codex_core::config::load_config_as_toml; +use codex_core::exec::ExecParams; +use codex_core::exec_env::create_env; +use codex_core::get_platform_sandbox; +use codex_core::git_info::git_diff_to_remote; +use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::Event; +use codex_core::protocol::EventMsg; +use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; +use codex_core::protocol::ReviewDecision; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; @@ -42,13 +36,17 @@ use codex_protocol::mcp_protocol::AddConversationListenerParams; use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; use codex_protocol::mcp_protocol::ApplyPatchApprovalParams; use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse; +use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::AuthStatusChangeNotification; use codex_protocol::mcp_protocol::ClientRequest; use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD; +use codex_protocol::mcp_protocol::ExecArbitraryCommandResponse; use codex_protocol::mcp_protocol::ExecCommandApprovalParams; use codex_protocol::mcp_protocol::ExecCommandApprovalResponse; +use codex_protocol::mcp_protocol::ExecOneOffCommandParams; use codex_protocol::mcp_protocol::GetConfigTomlResponse; +use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; use codex_protocol::mcp_protocol::InputItem as WireInputItem; use codex_protocol::mcp_protocol::InterruptConversationParams; use codex_protocol::mcp_protocol::InterruptConversationResponse; @@ -63,6 +61,12 @@ use codex_protocol::mcp_protocol::SendUserMessageResponse; use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; use codex_protocol::mcp_protocol::ServerNotification; +use mcp_types::JSONRPCErrorError; +use mcp_types::RequestId; +use tokio::sync::Mutex; +use tokio::sync::oneshot; +use tracing::error; +use uuid::Uuid; // Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); @@ -152,6 +156,9 @@ impl CodexMessageProcessor { ClientRequest::GetConfigToml { request_id } => { self.get_config_toml(request_id).await; } + ClientRequest::ExecOneOffCommand { request_id, params } => { + self.exec_one_off_command(request_id, params).await; + } } } @@ -420,6 +427,76 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn exec_one_off_command(&self, request_id: RequestId, params: ExecOneOffCommandParams) { + tracing::debug!("ExecOneOffCommand params: {params:?}"); + + if params.command.is_empty() { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: "command must not be empty".to_string(), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + let cwd = params.cwd.unwrap_or_else(|| self.config.cwd.clone()); + let env = create_env(&self.config.shell_environment_policy); + let timeout_ms = params.timeout_ms; + let exec_params = ExecParams { + command: params.command, + cwd, + timeout_ms, + env, + with_escalated_permissions: None, + justification: None, + }; + + let effective_policy = params + .sandbox_policy + .unwrap_or_else(|| self.config.sandbox_policy.clone()); + + let sandbox_type = match &effective_policy { + codex_core::protocol::SandboxPolicy::DangerFullAccess => { + codex_core::exec::SandboxType::None + } + _ => get_platform_sandbox().unwrap_or(codex_core::exec::SandboxType::None), + }; + tracing::debug!("Sandbox type: {sandbox_type:?}"); + let codex_linux_sandbox_exe = self.config.codex_linux_sandbox_exe.clone(); + let outgoing = self.outgoing.clone(); + let req_id = request_id; + + tokio::spawn(async move { + match codex_core::exec::process_exec_tool_call( + exec_params, + sandbox_type, + &effective_policy, + &codex_linux_sandbox_exe, + None, + ) + .await + { + Ok(output) => { + let response = ExecArbitraryCommandResponse { + exit_code: output.exit_code, + stdout: output.stdout.text, + stderr: output.stderr.text, + }; + outgoing.send_response(req_id, response).await; + } + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("exec failed: {err}"), + data: None, + }; + outgoing.send_error(req_id, error).await; + } + } + }); + } + async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) { let config = match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()) { Ok(config) => config, diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index 9a45c1678d..e1797b91ad 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -106,6 +106,12 @@ pub enum ClientRequest { #[serde(rename = "id")] request_id: RequestId, }, + /// Execute a command (argv vector) under the server's sandbox. + ExecOneOffCommand { + #[serde(rename = "id")] + request_id: RequestId, + params: ExecOneOffCommandParams, + }, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, TS)] @@ -218,6 +224,30 @@ pub struct GetAuthStatusParams { pub refresh_token: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct ExecOneOffCommandParams { + /// Command argv to execute. + pub command: Vec, + /// Timeout of the command in milliseconds. + /// If not specified, a sensible default is used server-side. + pub timeout_ms: Option, + /// Optional working directory for the process. Defaults to server config cwd. + #[serde(skip_serializing_if = "Option::is_none")] + pub cwd: Option, + /// Optional explicit sandbox policy overriding the server default. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_policy: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct ExecArbitraryCommandResponse { + pub exit_code: i32, + pub stdout: String, + pub stderr: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct GetAuthStatusResponse { From a35fb9d877dbc77c088bc906713dc9b2cb427694 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Wed, 3 Sep 2025 23:44:02 -0700 Subject: [PATCH 22/22] fix: add callback to map before sending request to fix race condition --- codex-rs/core/src/codex.rs | 32 ++++++++++++++++++++++---------- 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 3b3d43bf2e..36c48020bc 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -568,9 +568,19 @@ impl Session { cwd: PathBuf, reason: Option, ) -> oneshot::Receiver { + // Add the tx_approve callback to the map before sending the request. let (tx_approve, rx_approve) = oneshot::channel(); + let event_id = sub_id.clone(); + let prev_entry = { + let mut state = self.state.lock_unchecked(); + state.pending_approvals.insert(sub_id, tx_approve) + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending approval for sub_id: {event_id}"); + } + let event = Event { - id: sub_id.clone(), + id: event_id, msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { call_id, command, @@ -579,10 +589,6 @@ impl Session { }), }; let _ = self.tx_event.send(event).await; - { - let mut state = self.state.lock_unchecked(); - state.pending_approvals.insert(sub_id, tx_approve); - } rx_approve } @@ -594,9 +600,19 @@ impl Session { reason: Option, grant_root: Option, ) -> oneshot::Receiver { + // Add the tx_approve callback to the map before sending the request. let (tx_approve, rx_approve) = oneshot::channel(); + let event_id = sub_id.clone(); + let prev_entry = { + let mut state = self.state.lock_unchecked(); + state.pending_approvals.insert(sub_id, tx_approve) + }; + if prev_entry.is_some() { + warn!("Overwriting existing pending approval for sub_id: {event_id}"); + } + let event = Event { - id: sub_id.clone(), + id: event_id, msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { call_id, changes: convert_apply_patch_to_protocol(action), @@ -605,10 +621,6 @@ impl Session { }), }; let _ = self.tx_event.send(event).await; - { - let mut state = self.state.lock_unchecked(); - state.pending_approvals.insert(sub_id, tx_approve); - } rx_approve }