From abd901770e17b26b41b7371ec93b56fdcab61d7d Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Fri, 19 Jun 2026 12:02:12 -0700 Subject: [PATCH 01/86] chore(deps): advance tokio-tungstenite (#29132) ## Why Responses websocket connections use `tokio-tungstenite`. When DNS returns an unusable native IPv6 address before a working IPv4 address, sequential dialing can consume Codex's outer websocket timeout before reaching IPv4. The merged fork change adds Happy Eyeballs-style alternate-family racing so websocket dialing matches the recovery behavior already present in the HTTP path. ## What Changed Advance the workspace `tokio-tungstenite` patch from `132f5b39` to merged commit `e5e64b86`, and update the matching lockfile source. The new revision comes from [openai-oss-forks/tokio-tungstenite#1](https://github.com/openai-oss-forks/tokio-tungstenite/pull/1). --- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 2 +- codex-rs/Cargo.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 834b1acadd..be8d988f5d 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -982,7 +982,7 @@ "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}", "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", "git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 55d02cd8b1..f6f0e55831 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -13603,7 +13603,7 @@ dependencies = [ [[package]] name = "tokio-tungstenite" version = "0.28.0" -source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6" +source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad" dependencies = [ "futures-util", "log", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e5f5025e91..667bb7dfdf 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -546,7 +546,7 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" } ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" } -tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" } +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "e5e64b86722c5453c4038131c3193faa49a648ad" } tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } # Uncomment to debug local changes. From 64bdeed9f7adbe60c725153b3fb74ed044a36221 Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Fri, 19 Jun 2026 12:47:53 -0700 Subject: [PATCH 02/86] [codex] Preserve skill descriptions outside model context (#29006) ## Why Skill descriptions are used in model-visible lists: the default available-skills catalog that supports implicit selection, and the on-demand `skills.list` tool response used to discover orchestrator skills. A single overlong description should not consume a disproportionate share of either list. Enforcing the 1024-character limit while loading or migrating skills is the wrong boundary: it rejects otherwise-valid skills and discards metadata that non-model consumers and full skill reads may need. Skill metadata and `SKILL.md` content should remain intact; the cap belongs at model-visible list rendering boundaries. ## What changed - Preserve full `description` and `metadata.short-description` values when loading skills. - Preserve full external-agent command descriptions during `source-command-*` migration instead of skipping commands solely because their descriptions exceed 1024 characters. - Preserve full normalized orchestrator descriptions in the underlying skills catalog. - Cap each description at 1024 Unicode characters when rendering the default available-skills context in `codex-core-skills` and `codex-skills-extension`. - Apply the same cap when serializing descriptions in the model-visible `skills.list` response. - Render truncated descriptions as 1021 original characters plus `...`. - Leave explicit `$skill` injection, `skills.read`, underlying metadata, and on-disk `SKILL.md` files unchanged and full-fidelity. ## Implicit skill selection Codex injects a bounded catalog containing each implicitly allowed skill's name, description, and source locator, together with instructions to use a skill when the task clearly matches its description. The model makes that semantic choice; after selecting a skill, it reads the full `SKILL.md` from its filesystem or provider resource. Explicit `$skill` mentions remain a separate path that injects the full skill instructions. For orchestrator skills, `skills.list` provides bounded discovery metadata before `skills.read` returns the full selected resource. ## Test plan - `just test -p codex-core-skills` - `just test -p codex-skills-extension` - `just test -p codex-external-agent-migration` The focused regressions verify that overlong metadata is preserved at load and migration boundaries while default available-skills rendering and `skills.list` output produce the 1021-character prefix plus `...`. --- codex-rs/core-skills/src/loader.rs | 9 +- codex-rs/core-skills/src/loader_tests.rs | 28 ++-- codex-rs/core-skills/src/render.rs | 54 +++++++- .../ext/skills/src/provider/orchestrator.rs | 12 +- codex-rs/ext/skills/src/render.rs | 27 +++- codex-rs/ext/skills/src/tools/list.rs | 3 +- codex-rs/ext/skills/tests/skills_extension.rs | 127 ++++++++++++++++++ codex-rs/external-agent-migration/src/lib.rs | 37 ++++- 8 files changed, 263 insertions(+), 34 deletions(-) diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 2a3a3189be..aa9688f9a2 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -684,13 +684,8 @@ async fn parse_skill_file( validate_len(&base_name, MAX_NAME_LEN, "name")?; validate_len(&name, MAX_QUALIFIED_NAME_LEN, "qualified name")?; - validate_len(&description, MAX_DESCRIPTION_LEN, "description")?; - if let Some(short_description) = short_description.as_deref() { - validate_len( - short_description, - MAX_SHORT_DESCRIPTION_LEN, - "metadata.short-description", - )?; + if description.is_empty() { + return Err(SkillParseError::MissingField("description")); } let resolved_path = canonicalize_for_skill_identity(fs, path).await; diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index e22ed33f3d..8cd371c9a7 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1573,7 +1573,7 @@ async fn preserves_block_scalar_body_while_repairing_other_fields() { } #[tokio::test] -async fn enforces_short_description_length_limits() { +async fn preserves_overlong_short_descriptions() { let codex_home = tempfile::tempdir().expect("tempdir"); let skill_dir = codex_home.path().join("skills/demo"); fs::create_dir_all(&skill_dir).unwrap(); @@ -1585,15 +1585,13 @@ async fn enforces_short_description_length_limits() { let cfg = make_config(&codex_home).await; let outcome = load_skills_for_test(&cfg).await; - assert_eq!(outcome.skills.len(), 0); - assert_eq!(outcome.errors.len(), 1); assert!( - outcome.errors[0] - .message - .contains("invalid metadata.short-description"), - "expected length error, got: {:?}", + outcome.errors.is_empty(), + "unexpected errors: {:?}", outcome.errors ); + assert_eq!(outcome.skills.len(), 1); + assert_eq!(outcome.skills[0].short_description, Some(too_long)); } #[tokio::test] @@ -1625,7 +1623,7 @@ async fn skips_hidden_and_invalid() { } #[tokio::test] -async fn enforces_length_limits() { +async fn preserves_overlong_descriptions() { let codex_home = tempfile::tempdir().expect("tempdir"); let max_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN); write_skill(&codex_home, "max-len", "max-len", &max_desc); @@ -1642,12 +1640,18 @@ async fn enforces_length_limits() { let too_long_desc = "\u{1F4A1}".repeat(MAX_DESCRIPTION_LEN + 1); write_skill(&codex_home, "too-long", "too-long", &too_long_desc); let outcome = load_skills_for_test(&cfg).await; - assert_eq!(outcome.skills.len(), 1); - assert_eq!(outcome.errors.len(), 1); assert!( - outcome.errors[0].message.contains("invalid description"), - "expected length error" + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors ); + assert_eq!(outcome.skills.len(), 2); + let too_long_skill = outcome + .skills + .iter() + .find(|skill| skill.name == "too-long") + .expect("too-long skill"); + assert_eq!(too_long_skill.description, too_long_desc); } #[tokio::test] diff --git a/codex-rs/core-skills/src/render.rs b/codex-rs/core-skills/src/render.rs index 1477a299b6..89ea1675d0 100644 --- a/codex-rs/core-skills/src/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::path::Component; @@ -16,6 +17,8 @@ use codex_utils_output_truncation::approx_token_count; const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000; const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2; +const MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS: usize = 1_024; +const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "..."; const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100; const APPROX_BYTES_PER_TOKEN: usize = 4; pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."; @@ -446,7 +449,7 @@ impl SkillRenderReport { struct SkillLine<'a> { name: &'a str, - description: &'a str, + description: Cow<'a, str>, path: String, } @@ -485,9 +488,10 @@ impl<'a> SkillLine<'a> { } fn with_path(skill: &'a SkillMetadata, path: String) -> Self { + let description = truncate_default_context_skill_description(skill.description.as_str()); Self { name: skill.name.as_str(), - description: skill.description.as_str(), + description, path, } } @@ -505,7 +509,7 @@ impl<'a> SkillLine<'a> { } fn render_full(&self) -> String { - self.render_with_description(self.description) + self.render_with_description(self.description.as_ref()) } fn render_minimum(&self) -> String { @@ -524,7 +528,7 @@ impl<'a> SkillLine<'a> { format!("- {}: (file: {})", self.name, self.path) } else { let end = self.rendered_description_prefix_len(description_chars); - let description = &self.description[..end]; + let description = &self.description.as_ref()[..end]; format!("- {}: {} (file: {})", self.name, description, self.path) } } @@ -538,6 +542,26 @@ impl<'a> SkillLine<'a> { } } +fn truncate_default_context_skill_description(description: &str) -> Cow<'_, str> { + if description + .char_indices() + .nth(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS) + .is_none() + { + return Cow::Borrowed(description); + } + + let prefix_chars = MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + .saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count()); + let prefix_end = description + .char_indices() + .nth(prefix_chars) + .map_or(description.len(), |(index, _)| index); + let mut truncated = description[..prefix_end].to_string(); + truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX); + Cow::Owned(truncated) +} + impl<'a> DescriptionBudgetLine<'a> { fn new(line: &'a SkillLine<'a>, budget: SkillMetadataBudget) -> Self { let minimum_line = line.render_minimum(); @@ -1030,6 +1054,28 @@ mod tests { ); } + #[test] + fn default_context_caps_descriptions_without_mutating_metadata() { + let description = "\u{1F4A1}".repeat(MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + 1); + let skill = make_skill_with_description("long-skill", SkillScope::Repo, &description); + let expected_description = "\u{1F4A1}".repeat( + MAX_DEFAULT_CONTEXT_SKILL_DESCRIPTION_CHARS + - TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count(), + ) + TRUNCATED_SKILL_DESCRIPTION_SUFFIX; + + let rendered = build_available_skills_from_metadata( + std::slice::from_ref(&skill), + SkillMetadataBudget::Characters(usize::MAX), + ) + .expect("skill should render"); + + assert_eq!(skill.description, description); + assert_eq!( + rendered.skill_lines, + vec![expected_skill_line(&skill, &expected_description)] + ); + } + #[test] fn budgeted_rendering_truncates_descriptions_equally_before_omitting_skills() { let alpha = make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdef"); diff --git a/codex-rs/ext/skills/src/provider/orchestrator.rs b/codex-rs/ext/skills/src/provider/orchestrator.rs index 045b67de62..8d97bf32a1 100644 --- a/codex-rs/ext/skills/src/provider/orchestrator.rs +++ b/codex-rs/ext/skills/src/provider/orchestrator.rs @@ -28,7 +28,6 @@ const MAX_RESOURCE_PAGES: usize = 10; const MAX_ORCHESTRATOR_SKILLS: usize = 100; const MAX_SKILL_NAME_CHARS: usize = 64; const MAX_QUALIFIED_SKILL_NAME_CHARS: usize = 128; -const MAX_SKILL_DESCRIPTION_CHARS: usize = 1_024; const MAX_SKILL_PACKAGE_URI_CHARS: usize = 1_024; const MAX_SKILL_RESOURCE_URI_CHARS: usize = 2_048; const MAX_SKILL_RESOURCE_CONTENT_BYTES: usize = 1024 * 1024; @@ -308,12 +307,17 @@ fn normalized_label(value: &str, max_chars: usize) -> Option { } fn normalized_description(value: &str) -> Option { - normalized_single_line(value, MAX_SKILL_DESCRIPTION_CHARS).map(|value| { + let value = value.split_whitespace().collect::>().join(" "); + if value.chars().any(char::is_control) { + return None; + } + + Some( value .replace('&', "&") .replace('<', "<") - .replace('>', ">") - }) + .replace('>', ">"), + ) } fn normalized_single_line(value: &str, max_chars: usize) -> Option { diff --git a/codex-rs/ext/skills/src/render.rs b/codex-rs/ext/skills/src/render.rs index 89597aa9bb..dda32b4864 100644 --- a/codex-rs/ext/skills/src/render.rs +++ b/codex-rs/ext/skills/src/render.rs @@ -1,3 +1,5 @@ +use std::borrow::Cow; + use codex_utils_string::take_bytes_at_char_boundary; use crate::catalog::SkillCatalog; @@ -7,6 +9,8 @@ use crate::fragments::AvailableSkillsInstructions; const MAX_AVAILABLE_SKILLS_BYTES: usize = 8_000; const MAX_MAIN_PROMPT_BYTES: usize = 8_000; +const MAX_CATALOG_SKILL_DESCRIPTION_CHARS: usize = 1_024; +const TRUNCATED_SKILL_DESCRIPTION_SUFFIX: &str = "..."; pub(crate) const MAX_SKILL_NAME_BYTES: usize = 256; pub(crate) const MAX_SKILL_PATH_BYTES: usize = 1_024; @@ -31,7 +35,8 @@ pub(crate) fn available_skills_fragment( .short_description .as_deref() .unwrap_or(entry.description.as_str()); - let line = render_skill_line(entry, description); + let description = truncate_catalog_skill_description(description); + let line = render_skill_line(entry, description.as_ref()); let next_bytes = total_bytes.saturating_add(line.len()); if next_bytes > MAX_AVAILABLE_SKILLS_BYTES { omitted = omitted.saturating_add(1); @@ -54,6 +59,26 @@ pub(crate) fn available_skills_fragment( Some(AvailableSkillsInstructions::from_skill_lines(skill_lines)) } +pub(crate) fn truncate_catalog_skill_description(description: &str) -> Cow<'_, str> { + if description + .char_indices() + .nth(MAX_CATALOG_SKILL_DESCRIPTION_CHARS) + .is_none() + { + return Cow::Borrowed(description); + } + + let prefix_chars = MAX_CATALOG_SKILL_DESCRIPTION_CHARS + .saturating_sub(TRUNCATED_SKILL_DESCRIPTION_SUFFIX.chars().count()); + let prefix_end = description + .char_indices() + .nth(prefix_chars) + .map_or(description.len(), |(index, _)| index); + let mut truncated = description[..prefix_end].to_string(); + truncated.push_str(TRUNCATED_SKILL_DESCRIPTION_SUFFIX); + Cow::Owned(truncated) +} + fn render_skill_line(entry: &SkillCatalogEntry, description: &str) -> String { let locator_kind = match &entry.authority.kind { SkillSourceKind::Host => "file", diff --git a/codex-rs/ext/skills/src/tools/list.rs b/codex-rs/ext/skills/src/tools/list.rs index 31cb68003d..e556f9783f 100644 --- a/codex-rs/ext/skills/src/tools/list.rs +++ b/codex-rs/ext/skills/src/tools/list.rs @@ -8,6 +8,7 @@ use serde::Deserialize; use serde::Serialize; use crate::catalog::SkillCatalogEntry; +use crate::render::truncate_catalog_skill_description; use crate::render::truncate_utf8_to_bytes; use super::MAX_HANDLE_BYTES; @@ -95,7 +96,7 @@ fn listed_skill(entry: SkillCatalogEntry) -> Option { authority, package: entry.id.0, name: entry.name, - description: entry.description, + description: truncate_catalog_skill_description(&entry.description).into_owned(), main_resource: entry.main_prompt.as_str().to_string(), }) } diff --git a/codex-rs/ext/skills/tests/skills_extension.rs b/codex-rs/ext/skills/tests/skills_extension.rs index 2d06c33636..8b02558347 100644 --- a/codex-rs/ext/skills/tests/skills_extension.rs +++ b/codex-rs/ext/skills/tests/skills_extension.rs @@ -10,10 +10,14 @@ use codex_core_skills::SKILLS_INTRO_WITH_ABSOLUTE_PATHS; use codex_core_skills::SkillLoadOutcome; use codex_core_skills::SkillMetadata; use codex_core_skills::injection::InjectedHostSkillPrompts; +use codex_extension_api::ConversationHistory; use codex_extension_api::ExtensionData; use codex_extension_api::ExtensionEventSink; use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::NoopTurnItemEmitter; use codex_extension_api::ThreadStartInput; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolPayload; use codex_extension_api::TurnInputContext; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; @@ -23,6 +27,7 @@ use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::TruncationPolicy; use codex_protocol::user_input::UserInput; use codex_skills_extension::SkillProviders; use codex_skills_extension::SkillsExtensionConfig; @@ -255,6 +260,128 @@ async fn selected_executor_catalog_is_context_and_selected_entrypoint_is_turn_in Ok(()) } +#[tokio::test] +async fn default_context_truncates_catalog_descriptions() -> TestResult { + let description = "x".repeat(1_025); + let mut entry = test_entry( + SkillSourceKind::Orchestrator, + "codex_apps", + "orchestrator/long-description", + "skill://orchestrator/long-description/SKILL.md", + ); + entry.description = description.clone(); + let providers = + SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![entry], + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: None, + fail_first_list: false, + })); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config(); + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + environments: &[], + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let fragments = registry.context_contributors()[0] + .contribute_thread_context(&session_store, &thread_store) + .await; + assert_eq!(1, fragments.len()); + let rendered = fragments[0].text(); + assert!(rendered.contains(&("x".repeat(1_021) + "..."))); + assert!(!rendered.contains(&"x".repeat(1_024))); + assert!(!rendered.contains(&description)); + + Ok(()) +} + +#[tokio::test] +async fn skills_list_truncates_catalog_descriptions_in_tool_output() -> TestResult { + let description = "x".repeat(1_025); + let mut entry = test_entry( + SkillSourceKind::Orchestrator, + "codex_apps", + "orchestrator/long-description", + "skill://orchestrator/long-description/SKILL.md", + ); + entry.description = description.clone(); + let providers = + SkillProviders::new().with_orchestrator_provider(Arc::new(StaticSkillProvider { + catalog: SkillCatalog { + entries: vec![entry], + warnings: Vec::new(), + }, + read_requests: Arc::new(Mutex::new(Vec::new())), + list_calls: None, + fail_first_list: false, + })); + let mut builder = ExtensionRegistryBuilder::new(); + install_with_providers(&mut builder, providers, skills_extension_config); + let registry = builder.build(); + let session_store = ExtensionData::new("session"); + let thread_store = ExtensionData::new("thread"); + let session_source = SessionSource::Cli; + let config = default_config(); + registry.thread_lifecycle_contributors()[0] + .on_thread_start(ThreadStartInput { + config: &config, + session_source: &session_source, + persistent_thread_state_available: true, + environments: &[], + session_store: &session_store, + thread_store: &thread_store, + }) + .await; + + let tools = registry.tool_contributors()[0].tools(&session_store, &thread_store); + let list_tool = tools + .iter() + .find(|tool| tool.tool_name().name == "list") + .ok_or("skills.list tool should be registered")?; + let payload = ToolPayload::Function { + arguments: serde_json::json!({"authority": {"kind": "orchestrator"}}).to_string(), + }; + let output = list_tool + .handle(ToolCall { + turn_id: "turn-1".to_string(), + call_id: "call-1".to_string(), + tool_name: list_tool.tool_name(), + model: "gpt-test".to_string(), + truncation_policy: TruncationPolicy::Bytes(1_024), + conversation_history: ConversationHistory::default(), + turn_item_emitter: Arc::new(NoopTurnItemEmitter), + environments: Vec::new(), + payload: payload.clone(), + }) + .await?; + let response = output + .post_tool_use_response("call-1", &payload) + .ok_or("skills.list should expose structured output")?; + let rendered_description = response["skills"][0]["description"] + .as_str() + .ok_or("skills.list response should include a description")?; + + assert_eq!(rendered_description, "x".repeat(1_021) + "..."); + assert_ne!(rendered_description, description); + + Ok(()) +} + #[tokio::test] async fn orchestrator_catalog_snapshot_caches_failure() -> TestResult { let list_calls = Arc::new(AtomicUsize::new(0)); diff --git a/codex-rs/external-agent-migration/src/lib.rs b/codex-rs/external-agent-migration/src/lib.rs index 13a6bc63a3..7e5f626d8d 100644 --- a/codex-rs/external-agent-migration/src/lib.rs +++ b/codex-rs/external-agent-migration/src/lib.rs @@ -18,7 +18,6 @@ const EXTERNAL_AGENT_HOOKS_SUBDIR: &str = "hooks"; const EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR: &str = "hooks"; const COMMAND_SKILL_PREFIX: &str = "source-command"; const MAX_SKILL_NAME_LEN: usize = 64; -const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; #[derive(Debug)] struct ParsedDocument { @@ -1130,14 +1129,11 @@ fn command_skill_name_if_supported( return None; } let source_name = command_source_name(source_commands, source_file); - let description = command_skill_description(document, &source_name)?; + command_skill_description(document, &source_name)?; let name = command_skill_name(source_commands, source_file); if name.chars().count() > MAX_SKILL_NAME_LEN { return None; } - if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN { - return None; - } if has_unsupported_command_template_features(&document.body) { return None; } @@ -1388,6 +1384,8 @@ mod tests { use super::*; use pretty_assertions::assert_eq; + const MAX_SKILL_DESCRIPTION_LEN: usize = 1024; + fn source_path(relative_path: &str) -> PathBuf { Path::new("/repo") .join(external_agent_config_dir()) @@ -1722,6 +1720,35 @@ command = "enabled-server" assert!(command_skill_name_if_supported(&root, &file, &document).is_none()); } + #[test] + fn commands_with_overlong_descriptions_are_preserved() { + let root = source_path("commands"); + let file = source_path("commands/review.md"); + let description = "x".repeat(MAX_SKILL_DESCRIPTION_LEN + 1); + let document = + parse_document_content(&format!("---\ndescription: {description}\n---\nReview\n")); + + assert_eq!( + command_skill_name_if_supported(&root, &file, &document), + Some("source-command-review".to_string()) + ); + + let rendered = render_command_skill( + &document.body, + "source-command-review", + &description, + "review", + ); + let rendered_document = parse_document_content(&rendered); + assert_eq!( + rendered_document + .frontmatter + .get("description") + .and_then(FrontmatterValue::as_scalar), + Some(description.as_str()) + ); + } + #[test] fn commands_with_provider_runtime_expansion_are_skipped() { let root = source_path("commands"); From d66708232299bdbf373ec55b0d6b938c246cfa60 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Fri, 19 Jun 2026 21:37:01 -0700 Subject: [PATCH 03/86] Allow resume and settings commands during tasks and MCP startup (#29154) ## Why The TUI treats both an active turn and MCP startup as a running task. That currently blocks `/resume` and several settings commands even though they do not compete with turn execution, which is especially frustrating when MCP startup is slow. Model, permissions, personality, and service-tier selections already update thread settings independently of the running turn. Other clients can send those updates mid-turn, while the current turn continues with its captured settings. Allowing the same updates from local slash commands makes the TUI consistent with that existing behavior. ## What changed - Allow `/resume` while a task is running. - Allow `/model`, `/permissions`, `/personality`, and service-tier commands such as `/fast` while a task is running. - Keep the existing behavior where the active turn uses its captured settings and updates apply to subsequent turns. - Exercise the commands under the busy state in the existing TUI tests and retain coverage for commands that should remain blocked. ## Behavior note Turn settings such as model selection and reasoning effort are captured when a turn starts. Changing them during an active turn affects the next turn, not the turn already in progress. The status bar updates immediately, so it may temporarily display the newly selected setting before that setting is actually in effect. ## Verification - Focused `codex-tui` tests for resume dispatch, settings popups, `/fast`, and disabled-command behavior. ## Related issues Closes #19015. Addresses the next-turn-safe model/reasoning switching portion of #14356; dedicated shortcuts and the proposed depth meter remain out of scope. --- codex-rs/tui/src/app/event_dispatch.rs | 10 +++ codex-rs/tui/src/app_event.rs | 5 ++ .../tui/src/bottom_pane/slash_commands.rs | 2 +- codex-rs/tui/src/chatwidget/input_flow.rs | 24 ++++- codex-rs/tui/src/chatwidget/interaction.rs | 5 +- codex-rs/tui/src/chatwidget/slash_dispatch.rs | 14 ++- ...h_command_while_task_running_snapshot.snap | 4 +- .../tui/src/chatwidget/tests/exec_flow.rs | 6 +- .../src/chatwidget/tests/slash_commands.rs | 88 ++++++++++++++----- codex-rs/tui/src/slash_command.rs | 8 +- 10 files changed, 126 insertions(+), 40 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 16e4aaa627..0ab569b0eb 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -892,6 +892,16 @@ impl App { self.sync_active_thread_personality_setting(app_server, personality) .await; } + AppEvent::SettingsSelectionClosed => { + self.app_event_tx.send(AppEvent::SettingsSelectionSettled); + } + AppEvent::SettingsSelectionSettled => { + if self.chat_widget.no_modal_or_popup_active() { + self.chat_widget + .set_queue_autosend_suppressed(/*suppressed*/ false); + self.chat_widget.maybe_send_next_queued_input(); + } + } AppEvent::OpenReasoningPopup { model } => { self.chat_widget.open_reasoning_popup(model); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1143848d13..fc018aad87 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -694,6 +694,11 @@ pub(crate) enum AppEvent { /// Update the current personality in the running app and widget. UpdatePersonality(Personality), + /// Finish a settings selection after its preceding update events have been applied. + SettingsSelectionClosed, + /// Run after any nested settings events emitted while handling the close event. + SettingsSelectionSettled, + /// Persist the selected model and reasoning effort to the appropriate config. PersistModelSelection { model: String, diff --git a/codex-rs/tui/src/bottom_pane/slash_commands.rs b/codex-rs/tui/src/bottom_pane/slash_commands.rs index e011eb838c..6ca1ba93d1 100644 --- a/codex-rs/tui/src/bottom_pane/slash_commands.rs +++ b/codex-rs/tui/src/bottom_pane/slash_commands.rs @@ -48,7 +48,7 @@ impl SlashCommandItem { pub(crate) fn available_during_task(&self) -> bool { match self { Self::Builtin(cmd) => cmd.available_during_task(), - Self::ServiceTier(_) => false, + Self::ServiceTier(_) => true, } } } diff --git a/codex-rs/tui/src/chatwidget/input_flow.rs b/codex-rs/tui/src/chatwidget/input_flow.rs index b24a4fde27..916c54cd5e 100644 --- a/codex-rs/tui/src/chatwidget/input_flow.rs +++ b/codex-rs/tui/src/chatwidget/input_flow.rs @@ -24,8 +24,9 @@ impl ChatWidget { { return; } - let should_submit_now = - self.is_session_configured() && !self.is_plan_streaming_in_tui(); + let should_submit_now = self.is_session_configured() + && !self.is_plan_streaming_in_tui() + && !self.input_queue.suppress_queue_autosend; if should_submit_now { if self.only_user_shell_commands_running() && !user_message.text.starts_with('!') @@ -69,6 +70,20 @@ impl ChatWidget { self.refresh_plan_mode_nudge(); } + pub(super) fn defer_input_until_settings_applied(&mut self) { + if !self.bottom_pane.no_modal_or_popup_active() { + self.input_queue.suppress_queue_autosend = true; + } + } + + pub(super) fn on_modal_or_popup_closed(&mut self) { + if self.input_queue.suppress_queue_autosend { + self.app_event_tx.send(AppEvent::SettingsSelectionClosed); + } else { + self.maybe_send_next_queued_input(); + } + } + pub(super) fn queue_user_message(&mut self, user_message: UserMessage) { self.queue_user_message_with_options(user_message, QueuedInputAction::Plain, Vec::new()); } @@ -84,7 +99,10 @@ impl ChatWidget { action: QueuedInputAction, pending_pastes: Vec<(String, String)>, ) { - if !self.is_session_configured() || self.is_user_turn_pending_or_running() { + if !self.is_session_configured() + || self.is_user_turn_pending_or_running() + || self.input_queue.suppress_queue_autosend + { self.input_queue .queued_user_messages .push_back(QueuedUserMessage { diff --git a/codex-rs/tui/src/chatwidget/interaction.rs b/codex-rs/tui/src/chatwidget/interaction.rs index 74fc3d5e52..ba0aeb0a91 100644 --- a/codex-rs/tui/src/chatwidget/interaction.rs +++ b/codex-rs/tui/src/chatwidget/interaction.rs @@ -25,7 +25,7 @@ impl ChatWidget { self.pause_active_goal_for_interrupt(); } if self.bottom_pane.no_modal_or_popup_active() { - self.maybe_send_next_queued_input(); + self.on_modal_or_popup_closed(); } return; } @@ -392,6 +392,9 @@ impl ChatWidget { if should_pause_active_goal { self.pause_active_goal_for_interrupt(); } + if modal_or_popup_active && self.bottom_pane.no_modal_or_popup_active() { + self.on_modal_or_popup_closed(); + } return; } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 7c1179681e..df9c65f8ce 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -131,6 +131,13 @@ impl ChatWidget { .send(AppEvent::RawOutputModeChanged { enabled }); } + fn slash_command_blocked_by_active_task(&self, cmd: SlashCommand) -> bool { + (!cmd.available_during_task() && self.bottom_pane.is_task_running()) + || (cmd == SlashCommand::Resume + && (self.input_queue.user_turn_pending_start + || self.turn_lifecycle.agent_turn_running)) + } + pub(super) fn dispatch_command(&mut self, cmd: SlashCommand) { if !self.ensure_slash_command_allowed_in_side_conversation(cmd) { return; @@ -138,7 +145,7 @@ impl ChatWidget { if !self.ensure_side_command_allowed_outside_review(cmd) { return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if self.slash_command_blocked_by_active_task(cmd) { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() @@ -263,9 +270,11 @@ impl ChatWidget { } SlashCommand::Model => { self.open_model_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Personality => { self.open_personality_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Plan => { self.apply_plan_slash_command(); @@ -293,6 +302,7 @@ impl ChatWidget { } SlashCommand::Permissions => { self.open_permissions_popup(); + self.defer_input_until_settings_applied(); } SlashCommand::Vim => { self.toggle_vim_mode_and_notify(); @@ -545,7 +555,7 @@ impl ChatWidget { self.dispatch_command(cmd); return; } - if !cmd.available_during_task() && self.bottom_pane.is_task_running() { + if self.slash_command_blocked_by_active_task(cmd) { let message = format!( "'/{}' is disabled while a task is in progress.", cmd.command() diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap index e8f08a437a..4b4a02e0be 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__disabled_slash_command_while_task_running_snapshot.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: blob --- -■ '/model' is disabled while a task is in progress. +■ '/resume' is disabled while a task is in progress. diff --git a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs index 1b8a7beddb..4f7d225dd8 100644 --- a/codex-rs/tui/src/chatwidget/tests/exec_flow.rs +++ b/codex-rs/tui/src/chatwidget/tests/exec_flow.rs @@ -1069,10 +1069,10 @@ async fn user_message_during_user_shell_command_is_queued_not_steered() { async fn disabled_slash_command_while_task_running_snapshot() { // Build a chat widget and simulate an active task let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; - chat.bottom_pane.set_task_running(/*running*/ true); + handle_turn_started(&mut chat, "turn-1"); - // Dispatch a command that is unavailable while a task runs (e.g., /model) - chat.dispatch_command(SlashCommand::Model); + // Resume remains available during MCP startup, but not while an agent turn is active. + chat.dispatch_command(SlashCommand::Resume); // Drain history and snapshot the rendered error line(s) let cells = drain_insert_history(&mut rx); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 73d033de03..d8b3f8a0cd 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -337,8 +337,12 @@ async fn queued_bang_shell_waits_for_user_shell_completion_before_next_input() { assert!(chat.input_queue.queued_user_messages.is_empty()); } -async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_popup_text: &str) { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; +async fn assert_cancelled_queued_menu_drains_next_input( + command: &str, + expected_popup_text: &str, + cancel_key: KeyEvent, +) { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; chat.thread_id = Some(ThreadId::new()); handle_turn_started(&mut chat, "turn-1"); @@ -355,7 +359,14 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ ); assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); - chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + chat.handle_key_event(cancel_key); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + assert!( + std::iter::from_fn(|| rx.try_recv().ok()) + .any(|event| matches!(event, AppEvent::SettingsSelectionClosed)) + ); + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.maybe_send_next_queued_input(); match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => assert_eq!( @@ -372,39 +383,65 @@ async fn assert_cancelled_queued_menu_drains_next_input(command: &str, expected_ #[tokio::test] async fn queued_slash_menu_cancel_drains_next_input() { - assert_cancelled_queued_menu_drains_next_input("/model", "Select Model").await; - assert_cancelled_queued_menu_drains_next_input("/permissions", "Update Model Permissions") - .await; + assert_cancelled_queued_menu_drains_next_input( + "/model", + "Select Model", + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ) + .await; + assert_cancelled_queued_menu_drains_next_input( + "/permissions", + "Update Model Permissions", + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ) + .await; } #[tokio::test] -async fn queued_slash_menu_selection_drains_next_input() { - let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.2")).await; +async fn queued_settings_selection_applies_before_next_input() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; chat.thread_id = Some(ThreadId::new()); + let mut preset = get_available_model(&chat, "gpt-5.4"); + preset.supported_reasoning_efforts.truncate(1); + let selected_effort = preset.supported_reasoning_efforts[0].effort.clone(); + chat.model_catalog = std::sync::Arc::new(ModelCatalog::new(vec![preset])); handle_turn_started(&mut chat, "turn-1"); - queue_composer_text_with_tab(&mut chat, "/permissions"); + queue_composer_text_with_tab(&mut chat, "/model"); queue_composer_text_with_tab(&mut chat, "hello after selection"); complete_turn_with_message(&mut chat, "turn-1", Some("done")); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( - popup.contains("Update Model Permissions"), - "expected permissions menu to open; popup:\n{popup}" + popup.contains("Select Model and Effort"), + "expected model menu to open; popup:\n{popup}" ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::OpenReasoningPopup { model } => chat.open_reasoning_popup(model), + AppEvent::UpdateModel(model) => chat.set_model(&model), + AppEvent::UpdateReasoningEffort(effort) => chat.set_reasoning_effort(effort), + AppEvent::SettingsSelectionClosed => { + chat.app_event_tx.send(AppEvent::SettingsSelectionSettled); + } + AppEvent::SettingsSelectionSettled if chat.no_modal_or_popup_active() => { + chat.set_queue_autosend_suppressed(/*suppressed*/ false); + chat.maybe_send_next_queued_input(); + } + _ => {} + } + } match next_submit_op(&mut op_rx) { - Op::UserTurn { items, .. } => assert_eq!( - items, - vec![UserInput::Text { - text: "hello after selection".to_string(), - text_elements: Vec::new(), - }] + Op::UserTurn { model, effort, .. } => assert_eq!( + (model, effort), + ("gpt-5.4".to_string(), Some(selected_effort)) ), - other => panic!("expected queued message after permissions selection, got {other:?}"), + other => panic!("expected queued message with updated model, got {other:?}"), } assert!(chat.input_queue.queued_user_messages.is_empty()); } @@ -1595,7 +1632,7 @@ async fn unavailable_slash_command_is_available_from_local_recall() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); - submit_composer_text(&mut chat, "/model"); + submit_composer_text(&mut chat, "/review"); let cells = drain_insert_history(&mut rx); let rendered = cells @@ -1604,10 +1641,10 @@ async fn unavailable_slash_command_is_available_from_local_recall() { .collect::>() .join("\n"); assert!( - rendered.contains("'/model' is disabled while a task is in progress."), + rendered.contains("'/review' is disabled while a task is in progress."), "expected disabled-command message, got: {rendered:?}" ); - assert_eq!(recall_latest_after_clearing(&mut chat), "/model"); + assert_eq!(recall_latest_after_clearing(&mut chat), "/review"); } #[tokio::test] @@ -2293,8 +2330,9 @@ async fn slash_memory_update_reports_stubbed_feature() { } #[tokio::test] -async fn slash_resume_opens_picker() { +async fn slash_resume_opens_picker_while_mcp_startup_is_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); chat.dispatch_command(SlashCommand::Resume); @@ -2350,8 +2388,9 @@ async fn slash_delete_confirmation_requests_current_thread_delete() { } #[tokio::test] -async fn slash_resume_with_arg_requests_named_session() { +async fn slash_resume_with_arg_requests_named_session_while_mcp_startup_is_running() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.bottom_pane.set_task_running(/*running*/ true); chat.bottom_pane.set_composer_text( "/resume my-saved-thread".to_string(), @@ -2587,8 +2626,9 @@ async fn fast_slash_command_updates_and_persists_local_service_tier() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; set_fast_mode_test_catalog(&mut chat); chat.set_feature_enabled(Feature::FastMode, /*enabled*/ true); + chat.bottom_pane.set_task_running(/*running*/ true); - chat.handle_service_tier_command_dispatch(fast_tier_command()); + submit_composer_text(&mut chat, "/fast"); let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::>(); assert!( diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index e68b3e1cfa..5e3e906bae 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -190,13 +190,9 @@ impl SlashCommand { SlashCommand::New | SlashCommand::Archive | SlashCommand::Delete - | SlashCommand::Resume | SlashCommand::Fork | SlashCommand::Init | SlashCommand::Compact - | SlashCommand::Model - | SlashCommand::Personality - | SlashCommand::Permissions | SlashCommand::Keymap | SlashCommand::Vim | SlashCommand::ElevateSandbox @@ -211,6 +207,10 @@ impl SlashCommand { | SlashCommand::MemoryDrop | SlashCommand::MemoryUpdate => false, SlashCommand::Diff + | SlashCommand::Resume + | SlashCommand::Model + | SlashCommand::Personality + | SlashCommand::Permissions | SlashCommand::Copy | SlashCommand::Raw | SlashCommand::Rename From d1209bddfce79e8f6cafd7f9212a2df6915aec5d Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 13:15:49 -0700 Subject: [PATCH 04/86] core: add context window lineage IDs (#29256) ## Why The rendered `` fragment identifies the thread and current context window, but it does not expose enough lineage to identify the first window in the thread or the immediately preceding window. Those IDs also need to remain stable across compaction, resume, and rollback. ## What changed - Track first, previous, and current UUIDv7 context-window IDs in auto-compaction state. - Render `thread_id`, `first_window_id`, `previous_window_id`, and the current window ID in the full `` fragment. - Persist the first and previous window IDs in compacted rollout checkpoints and restore them during rollout reconstruction. - Preserve compatibility with older compacted records that do not contain the new optional fields. - Update focused state, rendering, reconstruction, rollback, and serialization coverage. ## Validation - `just test -p codex-core token_budget` - `just test -p codex-protocol compacted_item::tests` - `just test -p codex-core tracks_prefill_and_window_boundaries` - `just test -p codex-core reconstruct_history_uses_replacement_history_verbatim` - `just test -p codex-core thread_rollback_restores_cleared_reference_context_item_after_compaction` --- .../src/protocol/thread_history.rs | 2 + codex-rs/core/src/agent/control_tests.rs | 2 + codex-rs/core/src/compact.rs | 6 +- codex-rs/core/src/compact_remote.rs | 6 +- codex-rs/core/src/compact_remote_v2.rs | 6 +- .../core/src/context/token_budget_context.rs | 18 ++- codex-rs/core/src/session/mod.rs | 34 +++-- .../src/session/rollout_reconstruction.rs | 13 ++ .../session/rollout_reconstruction_tests.rs | 16 +++ codex-rs/core/src/session/tests.rs | 39 +++++- .../core/src/state/auto_compact_window.rs | 65 ++++++--- codex-rs/core/src/state/mod.rs | 1 + codex-rs/core/src/state/session.rs | 20 ++- ..._new_context_window_tool_full_context.snap | 3 +- codex-rs/core/tests/suite/token_budget.rs | 128 ++++++++++-------- codex-rs/protocol/src/compacted_item.rs | 12 ++ codex-rs/protocol/src/protocol.rs | 6 + codex-rs/rollout/src/metadata_tests.rs | 2 + .../thread-store/src/thread_metadata_sync.rs | 2 + 19 files changed, 277 insertions(+), 104 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 30221d9442..0157101cf0 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -3341,6 +3341,8 @@ mod tests { message: String::new(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 66b5b5c4cb..8dbcab3380 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -1160,6 +1160,8 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { message: String::new(), replacement_history: Some(replacement_history), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::TurnContext(turn_context.to_turn_context_item()), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7a2eaf935f..8a5efd5947 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -302,7 +302,7 @@ async fn run_compact_task_inner_impl( let user_messages = collect_user_messages(history_items); let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); - let (window_number, window_id) = sess.advance_auto_compact_window().await; + let (window_number, window_ids) = sess.advance_auto_compact_window().await; if matches!( initial_context_injection, @@ -320,7 +320,9 @@ async fn run_compact_task_inner_impl( message: summary_text.clone(), replacement_history: Some(new_history.clone()), window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), }; sess.replace_compacted_history( turn_context.as_ref(), diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 26469c201a..7a47881e47 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -258,7 +258,7 @@ async fn run_remote_compact_task_inner_impl( &responses_metadata, ) .await?; - let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; + let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -275,7 +275,9 @@ async fn run_remote_compact_task_inner_impl( message: String::new(), replacement_history: Some(new_history.clone()), window_number: Some(new_window_number), - window_id: Some(new_window_id), + first_window_id: Some(new_window_ids.first_window_id.to_string()), + previous_window_id: new_window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(new_window_ids.window_id.to_string()), }; // Install is the semantic boundary where the compact endpoint's output becomes live // thread history. Keep it distinct from the later inference request so the reducer can diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 6f10a474a1..0b382700ac 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -292,7 +292,7 @@ async fn run_remote_compact_task_inner_impl( let (compacted_history, retained_images) = build_v2_compacted_history(&prompt_input, compaction_output); analytics_details.retained_image_count = Some(retained_images); - let (new_window_number, new_window_id) = sess.advance_auto_compact_window().await; + let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await; let new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -309,7 +309,9 @@ async fn run_remote_compact_task_inner_impl( message: String::new(), replacement_history: Some(new_history.clone()), window_number: Some(new_window_number), - window_id: Some(new_window_id), + first_window_id: Some(new_window_ids.first_window_id.to_string()), + previous_window_id: new_window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(new_window_ids.window_id.to_string()), }; compaction_trace.record_installed(&CompactionCheckpointTracePayload { input_history: &trace_input_history, diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index b21b617355..bac1caafd0 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -5,14 +5,24 @@ use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct TokenBudgetContext { thread_id: ThreadId, + first_window_id: Uuid, + previous_window_id: Option, window_id: Uuid, tokens_left: i64, } impl TokenBudgetContext { - pub(crate) fn new(thread_id: ThreadId, window_id: Uuid, tokens_left: i64) -> Self { + pub(crate) fn new( + thread_id: ThreadId, + first_window_id: Uuid, + previous_window_id: Option, + window_id: Uuid, + tokens_left: i64, + ) -> Self { Self { thread_id, + first_window_id, + previous_window_id, window_id, tokens_left, } @@ -34,10 +44,14 @@ impl ContextualUserFragment for TokenBudgetContext { fn body(&self) -> String { let thread_id = self.thread_id; + let first_window_id = self.first_window_id; + let previous_window_id = self + .previous_window_id + .map_or_else(|| "none".to_string(), |window_id| window_id.to_string()); let window_id = self.window_id; let tokens_left = self.tokens_left; format!( - "Thread id {thread_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." + "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." ) } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 0792334b30..3cc746912a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -311,6 +311,7 @@ use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; #[cfg(test)] use crate::skills::SkillLoadOutcome; +use crate::state::AutoCompactWindowIds; use crate::state::AutoCompactWindowSnapshot; use crate::state::PendingRequestPermissions; use crate::state::SessionServices; @@ -1364,6 +1365,8 @@ impl Session { previous_turn_settings, reference_context_item, window_number, + first_window_id, + previous_window_id, window_id, } = self .reconstruct_history_from_rollout(turn_context, rollout_items) @@ -1382,8 +1385,16 @@ impl Session { { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); - let window_id = window_id.unwrap_or_else(|| state.auto_compact_window_id()); - state.restore_auto_compact_window(window_number, window_id); + let fallback_ids = state.auto_compact_window_ids(); + let window_id = window_id.unwrap_or(fallback_ids.window_id); + state.restore_auto_compact_window( + window_number, + AutoCompactWindowIds { + first_window_id: first_window_id.unwrap_or(window_id), + previous_window_id, + window_id, + }, + ); state.set_previous_turn_settings(previous_turn_settings.clone()); } let prefix_tokens = if matches!( @@ -3011,7 +3022,7 @@ impl Session { collaboration_mode, base_instructions, session_source, - auto_compact_window_id, + auto_compact_window_ids, ) = { let state = self.state.lock().await; ( @@ -3020,7 +3031,7 @@ impl Session { state.session_configuration.collaboration_mode.clone(), state.session_configuration.base_instructions.clone(), state.session_configuration.session_source.clone(), - state.auto_compact_window_id(), + state.auto_compact_window_ids(), ) }; if let Some(model_switch_message) = @@ -3210,7 +3221,9 @@ impl Session { developer_sections.push( crate::context::TokenBudgetContext::new( self.thread_id(), - auto_compact_window_id, + auto_compact_window_ids.first_window_id, + auto_compact_window_ids.previous_window_id, + auto_compact_window_ids.window_id, model_context_window, ) .render(), @@ -3309,10 +3322,9 @@ impl Session { format!("{thread_id}:{window_number}") } - pub(crate) async fn advance_auto_compact_window(&self) -> (u64, String) { + pub(crate) async fn advance_auto_compact_window(&self) -> (u64, AutoCompactWindowIds) { let mut state = self.state.lock().await; - let (window_number, window_id) = state.advance_auto_compact_window(); - (window_number, window_id.to_string()) + state.advance_auto_compact_window() } pub(crate) async fn request_new_context_window(&self) { @@ -3328,7 +3340,7 @@ impl Session { let mut state = self.state.lock().await; state.start_new_context_window_if_requested() }; - let (window_number, window_id) = window?; + let (window_number, window_ids) = window?; let context_items = self.build_initial_context(turn_context).await; let turn_context_item = turn_context.to_turn_context_item(); let replacement_history = context_items; @@ -3341,7 +3353,9 @@ impl Session { message: String::new(), replacement_history: Some(replacement_history), window_number: Some(window_number), - window_id: Some(window_id.to_string()), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), }), RolloutItem::TurnContext(turn_context_item), ]) diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index 22d0990e32..bf534c1e8a 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -10,12 +10,16 @@ pub(super) struct RolloutReconstruction { pub(super) previous_turn_settings: Option, pub(super) reference_context_item: Option, pub(super) window_number: u64, + pub(super) first_window_id: Option, + pub(super) previous_window_id: Option, pub(super) window_id: Option, } #[derive(Debug, Clone, Copy)] struct ReconstructedWindow { number: u64, + first_id: Option, + previous_id: Option, id: Option, } @@ -133,6 +137,11 @@ impl Session { { active_segment.window = Some(ReconstructedWindow { number: window_number, + first_id: compacted.first_window_id.as_deref().and_then(parse_uuid_v7), + previous_id: compacted + .previous_window_id + .as_deref() + .and_then(parse_uuid_v7), id: compacted.window_id.as_deref().and_then(parse_uuid_v7), }); } @@ -341,6 +350,8 @@ impl Session { let window = window.unwrap_or(ReconstructedWindow { number: fallback_window_number, + first_id: None, + previous_id: None, id: None, }); RolloutReconstruction { @@ -348,6 +359,8 @@ impl Session { previous_turn_settings, reference_context_item, window_number: window.number, + first_window_id: window.first_id, + previous_window_id: window.previous_id, window_id: window.id, } } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 43563d2339..1823b28fdb 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -829,6 +829,8 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::ThreadRolledBack( @@ -887,6 +889,8 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -914,6 +918,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_does_ message: "legacy summary".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -947,6 +953,8 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear message: "legacy summary".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::EventMsg(EventMsg::TurnStarted( @@ -1043,6 +1051,8 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), RolloutItem::TurnContext(previous_context_item), @@ -1196,6 +1206,8 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1433,6 +1445,8 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), ]; @@ -1599,6 +1613,8 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear message: String::new(), replacement_history: Some(Vec::new()), window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }), // A newer TurnStarted replaces the incomplete compacted turn without a matching diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index bca6be6a74..6f84da3c79 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1653,11 +1653,15 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { metadata: None, }, ]; + let first_window_id = Uuid::now_v7(); + let previous_window_id = Uuid::now_v7(); let window_id = Uuid::now_v7(); let rollout_items = vec![RolloutItem::Compacted(CompactedItem { message: String::new(), replacement_history: Some(replacement_history.clone()), window_number: Some(42), + first_window_id: Some(first_window_id.to_string()), + previous_window_id: Some(previous_window_id.to_string()), window_id: Some(window_id.to_string()), })]; @@ -1667,6 +1671,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { assert_eq!(reconstructed.history, replacement_history); assert_eq!(42, reconstructed.window_number); + assert_eq!(Some(first_window_id), reconstructed.first_window_id); + assert_eq!(Some(previous_window_id), reconstructed.previous_window_id); assert_eq!(Some(window_id), reconstructed.window_id); } @@ -3059,6 +3065,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio user_message("turn 1 user"), user_message("summary after compaction"), ]; + let first_window_id = Uuid::now_v7(); + let previous_window_id = Uuid::now_v7(); let compacted_window_id = Uuid::now_v7(); sess.persist_rollout_items(&[ @@ -3102,6 +3110,8 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio message: "summary after compaction".to_string(), replacement_history: Some(compacted_history.clone()), window_number: Some(7), + first_window_id: Some(first_window_id.to_string()), + previous_window_id: Some(previous_window_id.to_string()), window_id: Some(compacted_window_id.to_string()), }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { @@ -3152,7 +3162,14 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio .await; { let mut state = sess.state.lock().await; - state.restore_auto_compact_window(/*window_number*/ 99, Uuid::now_v7()); + state.restore_auto_compact_window( + /*window_number*/ 99, + AutoCompactWindowIds { + first_window_id: Uuid::now_v7(), + previous_window_id: Some(Uuid::now_v7()), + window_id: Uuid::now_v7(), + }, + ); } handlers::thread_rollback(&sess, "sub-1".to_string(), /*num_turns*/ 1).await; @@ -3162,8 +3179,12 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio assert_eq!(sess.clone_history().await.raw_items(), compacted_history); assert!(sess.reference_context_item().await.is_none()); assert_eq!( - sess.state.lock().await.auto_compact_window_id(), - compacted_window_id + sess.state.lock().await.auto_compact_window_ids(), + AutoCompactWindowIds { + first_window_id, + previous_window_id: Some(previous_window_id), + window_id: compacted_window_id, + } ); assert!(sess.current_window_id().await.ends_with(":7")); } @@ -9910,12 +9931,14 @@ async fn sample_rollout( let user_messages1 = collect_user_messages(&snapshot1); let rebuilt1 = compact::build_compacted_history(Vec::new(), &user_messages1, summary1); live_history.replace(rebuilt1); - let (window_number, window_id) = session.advance_auto_compact_window().await; + let (window_number, window_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary1.to_string(), replacement_history: None, window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), })); let user2 = ResponseItem::Message { @@ -9955,12 +9978,14 @@ async fn sample_rollout( let user_messages2 = collect_user_messages(&snapshot2); let rebuilt2 = compact::build_compacted_history(Vec::new(), &user_messages2, summary2); live_history.replace(rebuilt2); - let (window_number, window_id) = session.advance_auto_compact_window().await; + let (window_number, window_ids) = session.advance_auto_compact_window().await; rollout_items.push(RolloutItem::Compacted(CompactedItem { message: summary2.to_string(), replacement_history: None, window_number: Some(window_number), - window_id: Some(window_id), + first_window_id: Some(window_ids.first_window_id.to_string()), + previous_window_id: window_ids.previous_window_id.map(|id| id.to_string()), + window_id: Some(window_ids.window_id.to_string()), })); let user3 = ResponseItem::Message { diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs index e1fde27504..ff5d7a2a2f 100644 --- a/codex-rs/core/src/state/auto_compact_window.rs +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -1,6 +1,13 @@ use codex_protocol::protocol::TokenUsage; use uuid::Uuid; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct AutoCompactWindowIds { + pub(crate) first_window_id: Uuid, + pub(crate) previous_window_id: Option, + pub(crate) window_id: Uuid, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) struct AutoCompactWindowSnapshot { pub(crate) prefill_input_tokens: Option, @@ -15,7 +22,7 @@ enum AutoCompactWindowPrefill { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct AutoCompactWindow { window_number: u64, - window_id: Uuid, + ids: AutoCompactWindowIds, new_context_window_requested: bool, /// Absolute input-token baseline for the current compaction window. /// @@ -27,9 +34,14 @@ pub(super) struct AutoCompactWindow { impl AutoCompactWindow { pub(super) fn new() -> Self { + let window_id = Uuid::now_v7(); Self { window_number: 0, - window_id: Uuid::now_v7(), + ids: AutoCompactWindowIds { + first_window_id: window_id, + previous_window_id: None, + window_id, + }, new_context_window_requested: false, prefill_input_tokens: None, } @@ -43,20 +55,21 @@ impl AutoCompactWindow { self.window_number } - pub(super) fn window_id(&self) -> Uuid { - self.window_id + pub(super) fn ids(&self) -> AutoCompactWindowIds { + self.ids } - pub(super) fn restore(&mut self, window_number: u64, window_id: Uuid) { + pub(super) fn restore(&mut self, window_number: u64, ids: AutoCompactWindowIds) { self.window_number = window_number; - self.window_id = window_id; + self.ids = ids; } - pub(super) fn advance(&mut self) -> (u64, Uuid) { + pub(super) fn advance(&mut self) -> (u64, AutoCompactWindowIds) { self.window_number = self.window_number.saturating_add(1); - self.window_id = Uuid::now_v7(); + self.ids.previous_window_id = Some(self.ids.window_id); + self.ids.window_id = Uuid::now_v7(); self.new_context_window_requested = false; - (self.window_number, self.window_id) + (self.window_number, self.ids) } pub(super) fn request_new_context_window(&mut self) { @@ -118,21 +131,41 @@ mod tests { let mut window = AutoCompactWindow::new(); assert_eq!(window.window_number(), 0); - assert_eq!(window.window_id().get_version_num(), 7); + let initial_window_id = window.ids().window_id; + assert_eq!(initial_window_id.get_version_num(), 7); + assert_eq!( + window.ids(), + AutoCompactWindowIds { + first_window_id: initial_window_id, + previous_window_id: None, + window_id: initial_window_id, + } + ); + let first_window_id = initial_window_id; let restored_window_id = Uuid::now_v7(); - window.restore(/*window_number*/ 3, restored_window_id); + let restored_previous_window_id = Uuid::now_v7(); + window.restore( + /*window_number*/ 3, + AutoCompactWindowIds { + first_window_id, + previous_window_id: Some(restored_previous_window_id), + window_id: restored_window_id, + }, + ); assert_eq!(window.window_number(), 3); - assert_eq!(window.window_id(), restored_window_id); + assert_eq!(window.ids().window_id, restored_window_id); window.request_new_context_window(); assert!(window.take_new_context_window_request()); assert!(!window.take_new_context_window_request()); window.request_new_context_window(); - let (window_number, window_id) = window.advance(); + let (window_number, ids) = window.advance(); assert_eq!(window_number, 4); assert_eq!(window.window_number(), 4); - assert_eq!(window.window_id(), window_id); - assert_eq!(window_id.get_version_num(), 7); - assert_ne!(window_id, restored_window_id); + assert_eq!(window.ids(), ids); + assert_eq!(ids.first_window_id, first_window_id); + assert_eq!(ids.previous_window_id, Some(restored_window_id)); + assert_eq!(ids.window_id.get_version_num(), 7); + assert_ne!(ids.window_id, restored_window_id); assert!(!window.take_new_context_window_request()); assert_eq!( diff --git a/codex-rs/core/src/state/mod.rs b/codex-rs/core/src/state/mod.rs index c693221bed..20ff78b54f 100644 --- a/codex-rs/core/src/state/mod.rs +++ b/codex-rs/core/src/state/mod.rs @@ -5,6 +5,7 @@ mod session; mod turn; pub(crate) use additional_context::AdditionalContextStore; +pub(crate) use auto_compact_window::AutoCompactWindowIds; pub(crate) use auto_compact_window::AutoCompactWindowSnapshot; pub(crate) use service::SessionServices; pub(crate) use session::SessionState; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 83cef227f8..824f5f6366 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -6,10 +6,10 @@ use codex_sandboxing::policy_transforms::merge_permission_profiles; use std::collections::HashMap; use std::collections::HashSet; use std::collections::VecDeque; -use uuid::Uuid; use super::AdditionalContextStore; use super::auto_compact_window::AutoCompactWindow; +use super::auto_compact_window::AutoCompactWindowIds; use super::auto_compact_window::AutoCompactWindowSnapshot; use crate::context_manager::ContextManager; use crate::session::PreviousTurnSettings; @@ -152,15 +152,19 @@ impl SessionState { self.auto_compact_window.window_number() } - pub(crate) fn auto_compact_window_id(&self) -> Uuid { - self.auto_compact_window.window_id() + pub(crate) fn auto_compact_window_ids(&self) -> AutoCompactWindowIds { + self.auto_compact_window.ids() } - pub(crate) fn restore_auto_compact_window(&mut self, window_number: u64, window_id: Uuid) { - self.auto_compact_window.restore(window_number, window_id); + pub(crate) fn restore_auto_compact_window( + &mut self, + window_number: u64, + ids: AutoCompactWindowIds, + ) { + self.auto_compact_window.restore(window_number, ids); } - pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, Uuid) { + pub(crate) fn advance_auto_compact_window(&mut self) -> (u64, AutoCompactWindowIds) { self.auto_compact_window.advance() } @@ -168,7 +172,9 @@ impl SessionState { self.auto_compact_window.request_new_context_window(); } - pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option<(u64, Uuid)> { + pub(crate) fn start_new_context_window_if_requested( + &mut self, + ) -> Option<(u64, AutoCompactWindowIds)> { if !self.auto_compact_window.take_new_context_window_request() { return None; } diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index 12bab10ec8..3b933b4ed7 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -1,5 +1,6 @@ --- source: core/tests/suite/token_budget.rs +assertion_line: 539 expression: snapshot --- Scenario: New context window tool installs fresh full context before the next follow-up request. @@ -8,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n + [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index db70d3f33c..7c4dfa1bed 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -35,6 +35,35 @@ fn token_budget_texts(request: &ResponsesRequest) -> Vec { .collect() } +fn token_budget_window_ids( + text: &str, + thread_id: codex_protocol::ThreadId, + tokens_left: i64, +) -> (String, Option, String) { + let captures = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {tokens_left} tokens left in this context window\.\n$" + ), + text, + ); + let first_window_id = captures + .get(1) + .expect("first window id capture") + .as_str() + .to_string(); + let previous_window_id = captures + .get(2) + .expect("previous window id capture") + .as_str(); + let previous_window_id = (previous_window_id != "none").then(|| previous_window_id.to_string()); + let window_id = captures + .get(3) + .expect("window id capture") + .as_str() + .to_string(); + (first_window_id, previous_window_id, window_id) +} + fn tool_names(request: &ResponsesRequest) -> Vec { request .body_json() @@ -83,12 +112,13 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let (first_window_id, previous_window_id, window_id) = token_budget_window_ids( &initial_token_budget[0], + thread_id, + EFFECTIVE_CONTEXT_WINDOW, ); + assert_eq!(previous_window_id, None); + assert_eq!(first_window_id, window_id); assert_eq!( token_budget_texts(&requests[1]), initial_token_budget, @@ -147,12 +177,7 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R let thread_id = test.session_configured.thread_id; let full_context = token_budget_texts(&requests[0]); assert_eq!(full_context.len(), 1); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" - ), - &full_context[0], - ); + token_budget_window_ids(&full_context[0], thread_id, /*tokens_left*/ 9_500); let full_context = full_context[0].clone(); let threshold_25 = "\nYou have 7000 tokens left in this context window.\n" @@ -245,12 +270,7 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu .to_string(); let token_budgets = token_budget_texts(&requests[1]); assert_eq!(token_budgets.len(), 2); - assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id [0-9a-f-]{{36}}\.\nYou have 9500 tokens left in this context window\.\n$" - ), - &token_budgets[0], - ); + token_budget_window_ids(&token_budgets[0], thread_id, /*tokens_left*/ 9_500); assert_eq!(token_budgets[1], remaining_context); assert_eq!( requests[2].function_call_output_content_and_success(call_id), @@ -373,28 +393,30 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let initial_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), - &initial_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); + let (initial_first_window_id, initial_previous_window_id, initial_window_id) = + token_budget_window_ids( + &initial_token_budget[0], + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); let post_compaction_token_budget = token_budget_texts(&requests[2]); assert_eq!(post_compaction_token_budget.len(), 1); - let post_compaction_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let ( + post_compaction_first_window_id, + post_compaction_previous_window_id, + post_compaction_window_id, + ) = token_budget_window_ids( &post_compaction_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); + assert_eq!(initial_previous_window_id, None); + assert_eq!(initial_first_window_id, initial_window_id); + assert_eq!(post_compaction_first_window_id, initial_first_window_id); + assert_eq!( + post_compaction_previous_window_id.as_deref(), + Some(initial_window_id.as_str()) + ); assert_ne!(post_compaction_window_id, initial_window_id); Ok(()) @@ -458,29 +480,24 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let initial_window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let (initial_first_window_id, _, initial_window_id) = token_budget_window_ids( &initial_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); let new_window_token_budget = token_budget_texts(&requests[2]); assert_eq!(new_window_token_budget.len(), 1); - let window_id = assert_regex_match( - &format!( - r"^\nThread id {thread_id}\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {EFFECTIVE_CONTEXT_WINDOW} tokens left in this context window\.\n$" - ), + let (new_first_window_id, new_previous_window_id, new_window_id) = token_budget_window_ids( &new_window_token_budget[0], - ) - .get(1) - .expect("window id capture") - .as_str() - .to_string(); - assert_ne!(window_id, initial_window_id); + thread_id, + EFFECTIVE_CONTEXT_WINDOW, + ); + assert_eq!(new_first_window_id, initial_first_window_id); + assert_eq!( + new_previous_window_id.as_deref(), + Some(initial_window_id.as_str()) + ); + assert_ne!(new_window_id, initial_window_id); assert!( !requests[2].body_contains_text("request new context window"), "new_context should drop the prior window history before continuing the turn" @@ -496,7 +513,8 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { ); let snapshot = snapshot .replace(&thread_id.to_string(), "") - .replace(&window_id, ""); + .replace(&new_first_window_id, "") + .replace(&new_window_id, ""); insta::assert_snapshot!( "token_budget_new_context_window_tool_full_context", snapshot diff --git a/codex-rs/protocol/src/compacted_item.rs b/codex-rs/protocol/src/compacted_item.rs index 3415373898..0f437e0bb6 100644 --- a/codex-rs/protocol/src/compacted_item.rs +++ b/codex-rs/protocol/src/compacted_item.rs @@ -23,6 +23,8 @@ impl<'de> Deserialize<'de> for CompactedItem { message: serialized.message, replacement_history: serialized.replacement_history, window_number, + first_window_id: serialized.first_window_id, + previous_window_id: serialized.previous_window_id, window_id, }) } @@ -36,6 +38,10 @@ struct SerializedCompactedItem { #[serde(default)] window_number: Option, #[serde(default)] + first_window_id: Option, + #[serde(default)] + previous_window_id: Option, + #[serde(default)] window_id: Option, } @@ -59,6 +65,8 @@ mod tests { message: "summary".to_string(), replacement_history: None, window_number: Some(3), + first_window_id: Some("019b3f6e-0000-7000-8000-000000000001".to_string()), + previous_window_id: Some("019b3f6e-0000-7000-8000-000000000002".to_string()), window_id: Some("019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001".to_string()), }; @@ -67,6 +75,8 @@ mod tests { json!({ "message": "summary", "window_number": 3, + "first_window_id": "019b3f6e-0000-7000-8000-000000000001", + "previous_window_id": "019b3f6e-0000-7000-8000-000000000002", "window_id": "019b3f6e-7a10-7cc3-8b6e-1d09e2f7a001", }) ); @@ -86,6 +96,8 @@ mod tests { message: "summary".to_string(), replacement_history: None, window_number: Some(3), + first_window_id: None, + previous_window_id: None, window_id: None, } ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 1fe732c4ff..081649b7a4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2996,6 +2996,12 @@ pub struct CompactedItem { /// Monotonic position of this context window within the thread. #[serde(default, skip_serializing_if = "Option::is_none")] pub window_number: Option, + /// UUIDv7 identity of the first context window in this thread's window chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_window_id: Option, + /// UUIDv7 identity of the context window immediately before this one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub previous_window_id: Option, /// UUIDv7 identity of this context window. #[serde(default, skip_serializing_if = "Option::is_none")] pub window_id: Option, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index 4467439552..e6771e37c3 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -155,6 +155,8 @@ fn builder_from_items_falls_back_to_filename() { message: "noop".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, })]; diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index b45bb2d635..0336006fb8 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -484,6 +484,8 @@ mod tests { message: "compacted".to_string(), replacement_history: None, window_number: None, + first_window_id: None, + previous_window_id: None, window_id: None, }); From b6d6be2a8474836856871e5c58af05653238278a Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 18:02:02 -0700 Subject: [PATCH 05/86] [codex] prototype mcp_history thread hint injection (#29259) ## Why Prototype whether the harness can invoke the `mcp_history` MCP while constructing full initial context and expose its thread hint to the model without requiring a model-issued tool call. The prototype builds on the context-window lineage added by #29256 and is now based directly on `main`. ## What changed - Call `mcp_history/thread_hint` with no arguments while building the full `` context. - Pass the current `threadId` through MCP request metadata, matching the normal MCP tool-call path. - Serialize only the unstructured `content` result and append it inside `` when the call succeeds. - Omit the additional context when the MCP call or content serialization fails. ## Prototype limitations - The direct call bypasses the normal model-initiated MCP approval, lifecycle-event, telemetry, and result-sanitization path. - The call has no prototype-specific timeout, result-size cap, or per-window cache. - MCP latency is added to full-context construction, including applicable compaction paths. ## Validation - `just test -p codex-core token_budget` --- .../core/src/context/token_budget_context.rs | 14 +- codex-rs/core/src/session/mod.rs | 27 +++- ..._new_context_window_tool_full_context.snap | 4 +- codex-rs/core/tests/suite/token_budget.rs | 130 ++++++++++++++---- .../rmcp-client/src/bin/test_stdio_server.rs | 35 +++++ 5 files changed, 171 insertions(+), 39 deletions(-) diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index bac1caafd0..24e1869e8a 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -8,7 +8,7 @@ pub(crate) struct TokenBudgetContext { first_window_id: Uuid, previous_window_id: Option, window_id: Uuid, - tokens_left: i64, + mcp_result: Option, } impl TokenBudgetContext { @@ -17,14 +17,14 @@ impl TokenBudgetContext { first_window_id: Uuid, previous_window_id: Option, window_id: Uuid, - tokens_left: i64, + mcp_result: Option, ) -> Self { Self { thread_id, first_window_id, previous_window_id, window_id, - tokens_left, + mcp_result, } } } @@ -49,9 +49,13 @@ impl ContextualUserFragment for TokenBudgetContext { .previous_window_id .map_or_else(|| "none".to_string(), |window_id| window_id.to_string()); let window_id = self.window_id; - let tokens_left = self.tokens_left; + let mcp_result = self + .mcp_result + .as_deref() + .map(|result| format!("\n{result}")) + .unwrap_or_default(); format!( - "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.\nYou have {tokens_left} tokens left in this context window." + "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.{mcp_result}" ) } } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3cc746912a..c85898555b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -3216,15 +3216,38 @@ impl Session { } // This is full-context metadata. Steady-state context diffs should not re-emit it. if turn_context.config.features.enabled(Feature::TokenBudget) - && let Some(model_context_window) = turn_context.model_context_window() + && turn_context.model_context_window().is_some() { + let mcp_result = self + .call_tool( + "notes", + "thread_hint", + /*arguments*/ None, + Some(serde_json::json!({ + "threadId": self.thread_id().to_string(), + })), + ) + .await + .ok() + .and_then(|result| { + let text = result + .content + .iter() + .filter_map(|content| { + content.get("text").and_then(serde_json::Value::as_str) + }) + .filter(|text| !text.is_empty()) + .collect::>() + .join("\n"); + (!text.is_empty()).then_some(text) + }); developer_sections.push( crate::context::TokenBudgetContext::new( self.thread_id(), auto_compact_window_ids.first_window_id, auto_compact_window_ids.previous_window_id, auto_compact_window_ids.window_id, - model_context_window, + mcp_result, ) .render(), ); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index 3b933b4ed7..e5dd607170 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/token_budget.rs -assertion_line: 539 +assertion_line: 588 expression: snapshot --- Scenario: New context window tool installs fresh full context before the next follow-up request. @@ -9,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\nYou have 121600 tokens left in this context window.\n + [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\n 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 7c4dfa1bed..80f852a33e 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1,4 +1,6 @@ use anyhow::Result; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; @@ -17,15 +19,18 @@ use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; +use core_test_support::stdio_server_bin; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; +use core_test_support::wait_for_mcp_server; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; +use std::time::Duration; const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000; -const EFFECTIVE_CONTEXT_WINDOW: i64 = CONFIGURED_CONTEXT_WINDOW * 95 / 100; fn token_budget_texts(request: &ResponsesRequest) -> Vec { request @@ -38,11 +43,10 @@ fn token_budget_texts(request: &ResponsesRequest) -> Vec { fn token_budget_window_ids( text: &str, thread_id: codex_protocol::ThreadId, - tokens_left: i64, ) -> (String, Option, String) { let captures = assert_regex_match( &format!( - r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nYou have {tokens_left} tokens left in this context window\.\n$" + r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\n$" ), text, ); @@ -112,11 +116,8 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let (first_window_id, previous_window_id, window_id) = token_budget_window_ids( - &initial_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + let (first_window_id, previous_window_id, window_id) = + token_budget_window_ids(&initial_token_budget[0], thread_id); assert_eq!(previous_window_id, None); assert_eq!(first_window_id, window_id); assert_eq!( @@ -128,6 +129,89 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let rmcp_test_server_bin = stdio_server_bin()?; + let test = test_codex() + .with_config(move |config| { + config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + let mut servers = config.mcp_servers.get().clone(); + servers.insert( + "notes".to_string(), + McpServerConfig { + transport: McpServerTransportConfig::Stdio { + command: rmcp_test_server_bin, + args: Vec::new(), + env: None, + env_vars: Vec::new(), + cwd: None, + }, + environment_id: "local".to_string(), + enabled: true, + required: false, + supports_parallel_tool_calls: false, + disabled_reason: None, + startup_timeout_sec: Some(Duration::from_secs(10)), + tool_timeout_sec: None, + default_tools_approval_mode: None, + enabled_tools: None, + disabled_tools: None, + scopes: None, + oauth: None, + oauth_resource: None, + tools: HashMap::new(), + }, + ); + config + .mcp_servers + .set(servers) + .expect("test mcp servers should accept any configuration"); + }) + .build(&server) + .await?; + wait_for_mcp_server(&test.codex, "notes").await?; + let responses = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ])], + ) + .await; + + test.submit_turn("inject the history hint").await?; + + let request = responses.single_request(); + let thread_id = test.session_configured.thread_id; + let token_budgets = token_budget_texts(&request); + assert_eq!(token_budgets.len(), 1); + let captures = assert_regex_match( + &format!( + r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id none\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n$" + ), + &token_budgets[0], + ); + assert_eq!( + captures.get(1).expect("first window id capture").as_str(), + captures.get(2).expect("current window id capture").as_str() + ); + assert!( + !tool_names(&request) + .iter() + .any(|name| name == "mcp__notes__thread_hint"), + "thread_hint should be hidden from model tool exposure" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> Result<()> { skip_if_no_network!(Ok(())); @@ -177,7 +261,7 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R let thread_id = test.session_configured.thread_id; let full_context = token_budget_texts(&requests[0]); assert_eq!(full_context.len(), 1); - token_budget_window_ids(&full_context[0], thread_id, /*tokens_left*/ 9_500); + token_budget_window_ids(&full_context[0], thread_id); let full_context = full_context[0].clone(); let threshold_25 = "\nYou have 7000 tokens left in this context window.\n" @@ -270,7 +354,7 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu .to_string(); let token_budgets = token_budget_texts(&requests[1]); assert_eq!(token_budgets.len(), 2); - token_budget_window_ids(&token_budgets[0], thread_id, /*tokens_left*/ 9_500); + token_budget_window_ids(&token_budgets[0], thread_id); assert_eq!(token_budgets[1], remaining_context); assert_eq!( requests[2].function_call_output_content_and_success(call_id), @@ -394,22 +478,14 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (initial_first_window_id, initial_previous_window_id, initial_window_id) = - token_budget_window_ids( - &initial_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + token_budget_window_ids(&initial_token_budget[0], thread_id); let post_compaction_token_budget = token_budget_texts(&requests[2]); assert_eq!(post_compaction_token_budget.len(), 1); let ( post_compaction_first_window_id, post_compaction_previous_window_id, post_compaction_window_id, - ) = token_budget_window_ids( - &post_compaction_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + ) = token_budget_window_ids(&post_compaction_token_budget[0], thread_id); assert_eq!(initial_previous_window_id, None); assert_eq!(initial_first_window_id, initial_window_id); assert_eq!(post_compaction_first_window_id, initial_first_window_id); @@ -480,18 +556,12 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { let thread_id = test.session_configured.thread_id; let initial_token_budget = token_budget_texts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); - let (initial_first_window_id, _, initial_window_id) = token_budget_window_ids( - &initial_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + let (initial_first_window_id, _, initial_window_id) = + token_budget_window_ids(&initial_token_budget[0], thread_id); let new_window_token_budget = token_budget_texts(&requests[2]); assert_eq!(new_window_token_budget.len(), 1); - let (new_first_window_id, new_previous_window_id, new_window_id) = token_budget_window_ids( - &new_window_token_budget[0], - thread_id, - EFFECTIVE_CONTEXT_WINDOW, - ); + let (new_first_window_id, new_previous_window_id, new_window_id) = + token_budget_window_ids(&new_window_token_budget[0], thread_id); assert_eq!(new_first_window_id, initial_first_window_id); assert_eq!( new_previous_window_id.as_deref(), diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index 9ff36e349d..722f5f7d78 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -19,6 +19,7 @@ use rmcp::model::JsonObject; use rmcp::model::ListResourceTemplatesResult; use rmcp::model::ListResourcesResult; use rmcp::model::ListToolsResult; +use rmcp::model::Meta; use rmcp::model::PaginatedRequestParams; use rmcp::model::RawResource; use rmcp::model::RawResourceTemplate; @@ -70,9 +71,27 @@ impl TestToolServer { ); sandbox_meta_tool.annotations = Some(ToolAnnotations::new().read_only(true)); + #[expect(clippy::expect_used)] + let thread_hint_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + })) + .expect("thread_hint tool schema should deserialize"); + let mut thread_hint_tool = Tool::new( + Cow::Borrowed("thread_hint"), + Cow::Borrowed("Return an unstructured history hint for a thread."), + Arc::new(thread_hint_schema), + ); + thread_hint_tool.annotations = Some(ToolAnnotations::new().read_only(true)); + let mut thread_hint_meta = Meta::new(); + thread_hint_meta.insert("ui".to_string(), json!({ "visibility": [] })); + thread_hint_tool.meta = Some(thread_hint_meta); + let tools = vec![ Self::echo_tool(), Self::echo_dash_tool(), + thread_hint_tool, Self::client_capabilities_tool(), Self::cwd_tool(), Self::sync_tool(), @@ -537,6 +556,22 @@ impl ServerHandler for TestToolServer { .map_err(|err| McpError::internal_error(err.to_string(), None))?; Ok(Self::structured_result(json!({ "cwd": cwd }))) } + "thread_hint" => { + let thread_id = context + .meta + .0 + .get("threadId") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + McpError::invalid_params("missing threadId metadata".to_string(), None) + })?; + Ok(CallToolResult::success(vec![ + rmcp::model::Content::text(format!( + "manual history hint for thread {thread_id}" + )), + rmcp::model::Content::text("unstructured notes/thread_hint fixture result"), + ])) + } "echo" | "echo-tool" => { let args: EchoArgs = match request.arguments { Some(arguments) => serde_json::from_value(serde_json::Value::Object( From 6df037d47ff9359dbe1e471f5eb3f7e6ff1fe421 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 19:13:42 -0700 Subject: [PATCH 06/86] [codex] add configurable token budget compaction reminder (#29255) ## Why The token-budget feature reports coarse remaining-context milestones, but it does not give the model a configurable wrap-up prompt before automatic compaction. A strict threshold-crossing check can also miss resumed or reconfigured windows that are already inside the threshold. ## What changed - Add structured `[features.token_budget]` configuration for an absolute `reminder_threshold_tokens` and bounded `reminder_message_template`; `{n_remaining}` is expanded when the reminder is delivered. - Compute remaining tokens against the next effective auto-compaction boundary, including scoped `body_after_prefix` accounting and the full context-window limit. - Make reminder delivery level-triggered before and after sampling, with one-shot state owned by `AutoCompactWindow` and re-armed on compaction, `new_context`, restore, or history replacement. - Leave the existing initial full-window token-budget context, 25/50/75% notices, and token-budget tools unchanged. - Persist the resolved feature configuration in the session config lock and regenerate the config schema. ## Validation - `just test -p codex-core token_budget` - `just test -p codex-core token_budget_reminder_emits_after_crossing_compaction_threshold` - `just test -p codex-core auto_compact_window` - `just test -p codex-core lock_contains_prompts_and_materializes_features` - `just test -p codex-features` - `just test -p codex-config` --- codex-rs/config/src/schema.rs | 9 ++ codex-rs/core/config.schema.json | 35 +++++++- codex-rs/core/src/config/config_tests.rs | 84 +++++++++++++++++++ codex-rs/core/src/config/mod.rs | 75 +++++++++++++++++ codex-rs/core/src/context/mod.rs | 1 + .../core/src/context/token_budget_context.rs | 31 +++++++ codex-rs/core/src/session/config_lock.rs | 26 ++++++ codex-rs/core/src/session/token_budget.rs | 69 +++++++++------ codex-rs/core/src/session/turn.rs | 13 ++- .../core/src/state/auto_compact_window.rs | 10 +++ codex-rs/core/src/state/session.rs | 4 + codex-rs/core/tests/suite/token_budget.rs | 52 ++++++++++++ codex-rs/features/src/feature_configs.rs | 26 ++++++ codex-rs/features/src/lib.rs | 9 ++ codex-rs/thread-manager-sample/src/main.rs | 1 + 15 files changed, 418 insertions(+), 27 deletions(-) diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index fad074e9c3..8547269ce2 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -44,6 +44,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::TokenBudget { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } if feature.id == codex_features::Feature::RolloutBudget { validation.properties.insert( feature.key.to_string(), diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 828ec69f01..2655f2e743 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -669,7 +669,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" @@ -967,6 +967,16 @@ } ] }, + "FeatureToml_for_TokenBudgetConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/TokenBudgetConfigToml" + } + ] + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -2836,6 +2846,27 @@ } ] }, + "TokenBudgetConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "reminder_message_template": { + "description": "Reminder template. `{n_remaining}` is replaced with the tokens remaining before auto-compaction.", + "maxLength": 1000, + "minLength": 1, + "type": "string" + }, + "reminder_threshold_tokens": { + "description": "Number of tokens remaining before auto-compaction when the wrap-up reminder is emitted.", + "format": "int64", + "minimum": 1.0, + "type": "integer" + } + }, + "type": "object" + }, "ToolSuggestConfig": { "additionalProperties": false, "properties": { @@ -4932,7 +4963,7 @@ "type": "boolean" }, "token_budget": { - "type": "boolean" + "$ref": "#/definitions/FeatureToml_for_TokenBudgetConfigToml" }, "tool_call_mcp_elicitation": { "type": "boolean" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 904018ebc6..ce93740166 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -460,6 +460,90 @@ direct_only_tool_namespaces = ["mcp__history", "mcp__notes"] Ok(()) } +#[tokio::test] +async fn load_config_resolves_token_budget_config() -> std::io::Result<()> { + for (config_toml, expected) in [ + ( + "[features]\ntoken_budget = true\n", + TokenBudgetConfig::default(), + ), + ( + r#" +[features.token_budget] +enabled = true +reminder_threshold_tokens = 16000 +reminder_message_template = "Custom reminder: {n_remaining} tokens." +"#, + TokenBudgetConfig { + reminder_threshold_tokens: Some(16_000), + reminder_message_template: "Custom reminder: {n_remaining} tokens.".to_string(), + }, + ), + ] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(config_toml).expect("TOML should deserialize"); + let config = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::TokenBudget)); + assert_eq!(config.token_budget, Some(expected)); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_invalid_token_budget_reminder_template() -> std::io::Result<()> { + for reminder_message_template in [ + String::new(), + "x".repeat(TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES + 1), + ] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(&format!( + "[features.token_budget]\nenabled = true\nreminder_message_template = {reminder_message_template:?}\n" + )) + .expect("TOML should deserialize"); + let error = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("invalid reminder template should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + } + Ok(()) +} + +#[tokio::test] +async fn load_config_rejects_non_positive_token_budget_reminder_threshold() -> std::io::Result<()> { + for reminder_threshold_tokens in [-1, 0] { + let codex_home = tempdir()?; + let config_toml = toml::from_str(&format!( + "[features.token_budget]\nenabled = true\nreminder_threshold_tokens = {reminder_threshold_tokens}\n" + )) + .expect("TOML should deserialize"); + let error = Config::load_from_base_config_with_overrides( + config_toml, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await + .expect_err("non-positive reminder threshold should be rejected"); + + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "features.token_budget.reminder_threshold_tokens must be positive" + ); + } + Ok(()) +} + #[tokio::test] async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { let codex_home = tempdir()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 8e9ccc6c24..d5986501a4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -68,6 +68,7 @@ use codex_features::Features; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; +use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; @@ -1026,6 +1027,8 @@ pub struct Config { /// Settings specific to the task-path-based multi-agent tool surface. pub multi_agent_v2: MultiAgentV2Config, + /// Context-window token budget configuration, when enabled. + pub token_budget: Option, /// Shared token budget for the root thread and its sub-agents. pub rollout_budget: Option, /// Current-time reminder configuration, when enabled. @@ -1075,6 +1078,27 @@ pub struct CodeModeConfig { pub direct_only_tool_namespaces: Vec, } +pub(crate) const DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE: &str = concat!( + "Your context window is nearly exhausted (only {n_remaining} tokens remaining) and will be automatically reset for you soon. ", + "Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows." +); +const TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES: usize = 1000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TokenBudgetConfig { + pub reminder_threshold_tokens: Option, + pub reminder_message_template: String, +} + +impl Default for TokenBudgetConfig { + fn default() -> Self { + Self { + reminder_threshold_tokens: None, + reminder_message_template: DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string(), + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Serialize)] pub struct RolloutBudgetConfig { pub limit_tokens: i64, @@ -2509,6 +2533,48 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config } } +fn resolve_token_budget_config( + config_toml: &ConfigToml, + features: &ManagedFeatures, +) -> std::io::Result> { + if !features.enabled(Feature::TokenBudget) { + return Ok(None); + } + + let token_budget_config = token_budget_toml_config(config_toml.features.as_ref()); + let reminder_threshold_tokens = + token_budget_config.and_then(|config| config.reminder_threshold_tokens); + if reminder_threshold_tokens.is_some_and(|tokens| tokens <= 0) { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.token_budget.reminder_threshold_tokens must be positive", + )); + } + + let reminder_message_template = token_budget_config + .and_then(|config| config.reminder_message_template.clone()) + .unwrap_or_else(|| DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string()); + if reminder_message_template.trim().is_empty() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.token_budget.reminder_message_template must not be empty", + )); + } + if reminder_message_template.len() > TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "features.token_budget.reminder_message_template must not exceed {TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES} bytes" + ), + )); + } + + Ok(Some(TokenBudgetConfig { + reminder_threshold_tokens, + reminder_message_template, + })) +} + fn resolve_rollout_budget_config( config_toml: &ConfigToml, features: &ManagedFeatures, @@ -2635,6 +2701,13 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA } } +fn token_budget_toml_config(features: Option<&FeaturesToml>) -> Option<&TokenBudgetConfigToml> { + match features?.token_budget.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + fn current_time_reminder_toml_config( features: Option<&FeaturesToml>, ) -> Option<&CurrentTimeReminderConfigToml> { @@ -3287,6 +3360,7 @@ impl Config { resolve_experimental_request_user_input_enabled(&cfg); let code_mode = resolve_code_mode_config(&cfg); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg); + let token_budget = resolve_token_budget_config(&cfg, &features)?; let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?; let current_time_reminder = resolve_current_time_reminder_config(&cfg, &features)?; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); @@ -3830,6 +3904,7 @@ impl Config { background_terminal_max_timeout, ghost_snapshot, multi_agent_v2, + token_budget, rollout_budget, current_time_reminder, features, diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 3347358bb5..c9319ebef0 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -73,6 +73,7 @@ pub(crate) use rollout_budget::RolloutBudgetContext; pub(crate) use subagent_notification::SubagentNotification; pub(crate) use token_budget_context::TokenBudgetContext; pub(crate) use token_budget_context::TokenBudgetRemainingContext; +pub(crate) use token_budget_context::TokenBudgetReminder; pub(crate) use turn_aborted::TurnAborted; pub(crate) use user_instructions::UserInstructions; pub(crate) use user_shell_command::UserShellCommand; diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index 24e1869e8a..93d5e1e6ae 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -99,3 +99,34 @@ impl ContextualUserFragment for TokenBudgetRemainingContext { } } } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TokenBudgetReminder { + message: String, +} + +impl TokenBudgetReminder { + pub(crate) fn new(message_template: &str, n_remaining: i64) -> Self { + Self { + message: message_template.replace("{n_remaining}", &n_remaining.to_string()), + } + } +} + +impl ContextualUserFragment for TokenBudgetReminder { + fn role(&self) -> &'static str { + "developer" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("\n", "\n") + } + + fn body(&self) -> String { + self.message.clone() + } +} diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 79afd11515..2553f71504 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -10,6 +10,7 @@ use codex_features::FeatureToml; use codex_features::FeaturesToml; use codex_features::MultiAgentV2ConfigToml; use codex_features::RolloutBudgetConfigToml; +use codex_features::TokenBudgetConfigToml; use codex_protocol::ThreadId; use crate::config::Config; @@ -152,6 +153,12 @@ fn save_config_resolved_fields( resolved_config_to_toml(&config.multi_agent_v2, "features.multi_agent_v2")?; multi_agent_v2.enabled = Some(config.features.enabled(Feature::MultiAgentV2)); features.multi_agent_v2 = Some(FeatureToml::Config(multi_agent_v2)); + if let Some(token_budget) = config.token_budget.as_ref() { + let mut token_budget: TokenBudgetConfigToml = + resolved_config_to_toml(token_budget, "features.token_budget")?; + token_budget.enabled = Some(config.features.enabled(Feature::TokenBudget)); + features.token_budget = Some(FeatureToml::Config(token_budget)); + } if let Some(rollout_budget) = config.rollout_budget.as_ref() { let mut rollout_budget: RolloutBudgetConfigToml = resolved_config_to_toml(rollout_budget, "features.rollout_budget")?; @@ -238,6 +245,14 @@ mod tests { async fn lock_contains_prompts_and_materializes_features() { let mut sc = crate::session::tests::make_session_configuration_for_tests().await; let mut config = (*sc.original_config_do_not_use).clone(); + config.token_budget = Some(crate::config::TokenBudgetConfig { + reminder_threshold_tokens: Some(16_000), + reminder_message_template: "Locked reminder: {n_remaining} tokens.".to_string(), + }); + config + .features + .enable(Feature::TokenBudget) + .expect("token_budget should be enableable in tests"); config.rollout_budget = Some(crate::config::RolloutBudgetConfig { limit_tokens: 100_000, reminder_interval_tokens: 10_000, @@ -318,6 +333,17 @@ mod tests { }) )); + assert_eq!( + features.token_budget, + Some(FeatureToml::Config(TokenBudgetConfigToml { + enabled: Some(true), + reminder_threshold_tokens: Some(16_000), + reminder_message_template: Some( + "Locked reminder: {n_remaining} tokens.".to_string() + ), + })) + ); + assert_eq!( features.rollout_budget, Some(FeatureToml::Config(RolloutBudgetConfigToml { diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index 4548ed3198..017b7d542f 100644 --- a/codex-rs/core/src/session/token_budget.rs +++ b/codex-rs/core/src/session/token_budget.rs @@ -5,40 +5,61 @@ use codex_features::Feature; const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75]; -pub(super) async fn maybe_record_token_budget_remaining_context( +pub(super) async fn maybe_record( sess: &Session, turn_context: &TurnContext, tokens_before_sampling: i64, tokens_after_sampling: i64, + tokens_until_compaction: i64, ) { if !turn_context.config.features.enabled(Feature::TokenBudget) { return; } - let Some(model_context_window) = turn_context.model_context_window() else { - return; - }; - if model_context_window <= 0 || tokens_after_sampling <= tokens_before_sampling { - return; + + let mut response_items = Vec::new(); + if let Some(model_context_window) = turn_context.model_context_window() + && model_context_window > 0 + && tokens_after_sampling > tokens_before_sampling + { + let tokens_before_sampling = tokens_before_sampling.max(0); + let tokens_after_sampling = tokens_after_sampling.max(0); + let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| { + tokens_before_sampling.saturating_mul(100) + < model_context_window.saturating_mul(*threshold) + && tokens_after_sampling.saturating_mul(100) + >= model_context_window.saturating_mul(*threshold) + }); + if crossed_threshold { + let tokens_left = model_context_window + .saturating_sub(tokens_after_sampling) + .max(0); + response_items.push(ContextualUserFragment::into( + crate::context::TokenBudgetRemainingContext::new(tokens_left), + )); + } } - let tokens_before_sampling = tokens_before_sampling.max(0); - let tokens_after_sampling = tokens_after_sampling.max(0); - let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| { - tokens_before_sampling.saturating_mul(100) < model_context_window.saturating_mul(*threshold) - && tokens_after_sampling.saturating_mul(100) - >= model_context_window.saturating_mul(*threshold) - }); - if !crossed_threshold { - return; + if let Some(config) = turn_context.config.token_budget.as_ref().filter(|config| { + config + .reminder_threshold_tokens + .is_some_and(|threshold| tokens_until_compaction <= threshold) + }) { + let reminder_due = { + let mut state = sess.state.lock().await; + state.claim_token_budget_reminder() + }; + if reminder_due { + response_items.push(ContextualUserFragment::into( + crate::context::TokenBudgetReminder::new( + &config.reminder_message_template, + tokens_until_compaction, + ), + )); + } } - let tokens_left = model_context_window - .saturating_sub(tokens_after_sampling) - .max(0); - - let response_item = ContextualUserFragment::into( - crate::context::TokenBudgetRemainingContext::new(tokens_left), - ); - sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) - .await; + if !response_items.is_empty() { + sess.record_conversation_items(turn_context, &response_items) + .await; + } } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 28c0f65612..df74841b19 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -313,11 +313,22 @@ pub(crate) async fn run_turn( ); let tokens_after_sampling = token_status.active_context_tokens; - super::token_budget::maybe_record_token_budget_remaining_context( + let full_context_remaining = token_status + .full_context_window_limit + .map_or(i64::MAX, |limit| { + limit.saturating_sub(tokens_after_sampling) + }); + let tokens_until_compaction = token_status + .auto_compact_scope_limit + .saturating_sub(token_status.auto_compact_scope_tokens) + .min(full_context_remaining) + .max(0); + super::token_budget::maybe_record( sess.as_ref(), turn_context.as_ref(), tokens_before_sampling, tokens_after_sampling, + tokens_until_compaction, ) .await; diff --git a/codex-rs/core/src/state/auto_compact_window.rs b/codex-rs/core/src/state/auto_compact_window.rs index ff5d7a2a2f..419e85be04 100644 --- a/codex-rs/core/src/state/auto_compact_window.rs +++ b/codex-rs/core/src/state/auto_compact_window.rs @@ -30,6 +30,7 @@ pub(super) struct AutoCompactWindow { /// not the growth itself; server-observed usage replaces estimated /// resume/recompute baselines when available. prefill_input_tokens: Option, + token_budget_reminder_delivered: bool, } impl AutoCompactWindow { @@ -44,6 +45,7 @@ impl AutoCompactWindow { }, new_context_window_requested: false, prefill_input_tokens: None, + token_budget_reminder_delivered: false, } } @@ -69,9 +71,14 @@ impl AutoCompactWindow { self.ids.previous_window_id = Some(self.ids.window_id); self.ids.window_id = Uuid::now_v7(); self.new_context_window_requested = false; + self.token_budget_reminder_delivered = false; (self.window_number, self.ids) } + pub(super) fn claim_token_budget_reminder(&mut self) -> bool { + !std::mem::replace(&mut self.token_budget_reminder_delivered, true) + } + pub(super) fn request_new_context_window(&mut self) { self.new_context_window_requested = true; } @@ -154,6 +161,8 @@ mod tests { ); assert_eq!(window.window_number(), 3); assert_eq!(window.ids().window_id, restored_window_id); + assert!(window.claim_token_budget_reminder()); + assert!(!window.claim_token_budget_reminder()); window.request_new_context_window(); assert!(window.take_new_context_window_request()); assert!(!window.take_new_context_window_request()); @@ -167,6 +176,7 @@ mod tests { assert_eq!(ids.window_id.get_version_num(), 7); assert_ne!(ids.window_id, restored_window_id); assert!(!window.take_new_context_window_request()); + assert!(window.claim_token_budget_reminder()); assert_eq!( window.snapshot(), diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 824f5f6366..6444dc8cf3 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -148,6 +148,10 @@ impl SessionState { self.auto_compact_window.snapshot() } + pub(crate) fn claim_token_budget_reminder(&mut self) -> bool { + self.auto_compact_window.claim_token_budget_reminder() + } + pub(crate) fn auto_compact_window_number(&self) -> u64 { self.auto_compact_window.window_number() } diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 80f852a33e..a1f8fb5de2 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; +use codex_core::config::TokenBudgetConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; use codex_protocol::protocol::EventMsg; @@ -298,6 +299,57 @@ async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> R Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_completed_with_tokens("resp-1", /*total_tokens*/ 8_000), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_context_window = Some(10_000); + config.token_budget = Some(TokenBudgetConfig { + reminder_threshold_tokens: Some(2_000), + ..TokenBudgetConfig::default() + }); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + }) + .build(&server) + .await?; + + test.submit_turn("cross threshold").await?; + test.submit_turn("observe reminder").await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + let initial_context = token_budget_texts(&requests[0]); + assert_eq!(initial_context.len(), 1); + let remaining_context = + "\nYou have 1500 tokens left in this context window.\n" + .to_string(); + let reminder = "\nYour context window is nearly exhausted (only 1000 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows.\n" + .to_string(); + assert_eq!( + token_budget_texts(&requests[1]), + vec![initial_context[0].clone(), remaining_context, reminder] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index dc78d8cd43..15a37fe36a 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -73,6 +73,32 @@ impl FeatureConfig for MultiAgentV2ConfigToml { } } +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TokenBudgetConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Number of tokens remaining before auto-compaction when the wrap-up reminder is emitted. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + pub reminder_threshold_tokens: Option, + /// Reminder template. `{n_remaining}` is replaced with the tokens remaining before + /// auto-compaction. + #[serde(skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 1000))] + pub reminder_message_template: Option, +} + +impl FeatureConfig for TokenBudgetConfigToml { + fn enabled(&self) -> Option { + self.enabled + } + + fn set_enabled(&mut self, enabled: bool) { + self.enabled = Some(enabled); + } +} + #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RolloutBudgetConfigToml { diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3bba6faf98..f2a30b07b6 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -26,6 +26,7 @@ pub use feature_configs::NetworkProxyModeToml; pub use feature_configs::NetworkProxyUnixSocketPermissionToml; use feature_configs::RemovedAppsMcpPathOverrideConfigToml; pub use feature_configs::RolloutBudgetConfigToml; +pub use feature_configs::TokenBudgetConfigToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -632,6 +633,8 @@ pub struct FeaturesToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub token_budget: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub rollout_budget: Option>, #[serde(default, skip_serializing_if = "Option::is_none")] pub current_time_reminder: Option>, @@ -667,6 +670,9 @@ impl FeaturesToml { if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } + if let Some(enabled) = self.token_budget.as_ref().and_then(FeatureToml::enabled) { + entries.insert(Feature::TokenBudget.key().to_string(), enabled); + } if let Some(enabled) = self.rollout_budget.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::RolloutBudget.key().to_string(), enabled); } @@ -688,6 +694,7 @@ impl FeaturesToml { let Self { code_mode, multi_agent_v2, + token_budget, rollout_budget, current_time_reminder, removed_apps_mcp_path_override: _, @@ -703,6 +710,8 @@ impl FeaturesToml { materialize_resolved_feature_enabled(code_mode, enabled); } else if spec.id == Feature::MultiAgentV2 { materialize_resolved_feature_enabled(multi_agent_v2, enabled); + } else if spec.id == Feature::TokenBudget { + materialize_resolved_feature_enabled(token_budget, enabled); } else if spec.id == Feature::RolloutBudget { materialize_resolved_feature_enabled(rollout_budget, enabled); } else if spec.id == Feature::CurrentTimeReminder { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index b9796a55cb..3d8561a176 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -280,6 +280,7 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R background_terminal_max_timeout: 300_000, ghost_snapshot: GhostSnapshotConfig::default(), multi_agent_v2: MultiAgentV2Config::default(), + token_budget: None, rollout_budget: None, current_time_reminder: None, features: Default::default(), From aaf737fa59c4fc76984ec4cd705e8a7f07f0bd79 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Sat, 20 Jun 2026 21:50:09 -0700 Subject: [PATCH 07/86] [codex] simplify token budget context (#29295) ## Why The token-budget feature currently adds remaining-token messages whenever usage crosses the 25%, 50%, and 75% thresholds. Those periodic inserts create prompt churn without requiring action, while the near-compaction reminder and explicit `get_context_remaining` tool already cover actionable and on-demand budget information. The context-window lineage block is also easier to scan as plain labeled text than as a ``-wrapped fragment. ## What changed - Stop recording automatic remaining-token messages at percentage thresholds. - Render context-window lineage in `First`, `Current`, `Previous` order with colon-separated labels. - Omit the `Previous` line for the first context window. - Remove `` wrappers from newly rendered lineage, near-compaction reminders, and `get_context_remaining` output. - Keep recognizing legacy wrapped fragments so existing rollouts remain compatible. - Remove the post-sampling token snapshot that was only needed by the periodic threshold path. ## Testing - `just test -p codex-core token_budget` (11 tests passed) --- .../core/src/context/token_budget_context.rs | 29 ++-- codex-rs/core/src/event_mapping.rs | 1 + codex-rs/core/src/event_mapping_tests.rs | 2 +- codex-rs/core/src/session/token_budget.rs | 62 ++------ codex-rs/core/src/session/turn.rs | 15 +- ..._new_context_window_tool_full_context.snap | 2 +- codex-rs/core/tests/suite/token_budget.rs | 149 ++++-------------- 7 files changed, 65 insertions(+), 195 deletions(-) diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index 93d5e1e6ae..55a94b2b11 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -39,24 +39,25 @@ impl ContextualUserFragment for TokenBudgetContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { let thread_id = self.thread_id; let first_window_id = self.first_window_id; - let previous_window_id = self - .previous_window_id - .map_or_else(|| "none".to_string(), |window_id| window_id.to_string()); let window_id = self.window_id; - let mcp_result = self - .mcp_result - .as_deref() - .map(|result| format!("\n{result}")) - .unwrap_or_default(); - format!( - "Thread id {thread_id}.\nFirst context window id {first_window_id}.\nPrevious context window id {previous_window_id}.\nCurrent context window id {window_id}.{mcp_result}" - ) + let mut lines = vec![ + format!("Thread id {thread_id}."), + format!("First context window id: {first_window_id}"), + format!("Current context window id: {window_id}"), + ]; + if let Some(previous_window_id) = self.previous_window_id { + lines.push(format!("Previous context window id: {previous_window_id}")); + } + if let Some(mcp_result) = &self.mcp_result { + lines.push(mcp_result.clone()); + } + lines.join("\n") } } @@ -87,7 +88,7 @@ impl ContextualUserFragment for TokenBudgetRemainingContext { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { @@ -123,7 +124,7 @@ impl ContextualUserFragment for TokenBudgetReminder { } fn type_markers() -> (&'static str, &'static str) { - ("\n", "\n") + ("", "") } fn body(&self) -> String { diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 1ca097e783..4c57c5cadc 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -34,6 +34,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", + // Keep recognizing token-budget wrappers persisted by older versions. "", "", ]; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index b7453c902f..322fe7d005 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -29,7 +29,7 @@ fn recognizes_skills_instructions_as_contextual_developer_content() { } #[test] -fn recognizes_token_budget_as_contextual_developer_content() { +fn recognizes_legacy_token_budget_as_contextual_developer_content() { let content = vec![ContentItem::InputText { text: "\nYou have 710 tokens left in this context window.\n" .to_string(), diff --git a/codex-rs/core/src/session/token_budget.rs b/codex-rs/core/src/session/token_budget.rs index 017b7d542f..17f27c106b 100644 --- a/codex-rs/core/src/session/token_budget.rs +++ b/codex-rs/core/src/session/token_budget.rs @@ -3,63 +3,35 @@ use super::turn_context::TurnContext; use crate::context::ContextualUserFragment; use codex_features::Feature; -const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75]; - pub(super) async fn maybe_record( sess: &Session, turn_context: &TurnContext, - tokens_before_sampling: i64, - tokens_after_sampling: i64, tokens_until_compaction: i64, ) { if !turn_context.config.features.enabled(Feature::TokenBudget) { return; } - let mut response_items = Vec::new(); - if let Some(model_context_window) = turn_context.model_context_window() - && model_context_window > 0 - && tokens_after_sampling > tokens_before_sampling - { - let tokens_before_sampling = tokens_before_sampling.max(0); - let tokens_after_sampling = tokens_after_sampling.max(0); - let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| { - tokens_before_sampling.saturating_mul(100) - < model_context_window.saturating_mul(*threshold) - && tokens_after_sampling.saturating_mul(100) - >= model_context_window.saturating_mul(*threshold) - }); - if crossed_threshold { - let tokens_left = model_context_window - .saturating_sub(tokens_after_sampling) - .max(0); - response_items.push(ContextualUserFragment::into( - crate::context::TokenBudgetRemainingContext::new(tokens_left), - )); - } - } - - if let Some(config) = turn_context.config.token_budget.as_ref().filter(|config| { + let Some(config) = turn_context.config.token_budget.as_ref().filter(|config| { config .reminder_threshold_tokens .is_some_and(|threshold| tokens_until_compaction <= threshold) - }) { - let reminder_due = { - let mut state = sess.state.lock().await; - state.claim_token_budget_reminder() - }; - if reminder_due { - response_items.push(ContextualUserFragment::into( - crate::context::TokenBudgetReminder::new( - &config.reminder_message_template, - tokens_until_compaction, - ), - )); - } + }) else { + return; + }; + + let reminder_due = { + let mut state = sess.state.lock().await; + state.claim_token_budget_reminder() + }; + if !reminder_due { + return; } - if !response_items.is_empty() { - sess.record_conversation_items(turn_context, &response_items) - .await; - } + let response_item = ContextualUserFragment::into(crate::context::TokenBudgetReminder::new( + &config.reminder_message_template, + tokens_until_compaction, + )); + sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) + .await; } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index df74841b19..a35d9b60ca 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -254,8 +254,7 @@ pub(crate) async fn run_turn( window_id, CodexResponsesRequestKind::Turn, ); - let tokens_before_sampling = sess.get_total_token_usage().await; - let (sampling_request_output, sampling_request_input) = run_sampling_request( + run_sampling_request( Arc::clone(&sess), Arc::clone(&turn_context), Arc::clone(&turn_extension_data), @@ -265,17 +264,11 @@ pub(crate) async fn run_turn( sampling_request_input, cancellation_token.child_token(), ) - .await?; - - Ok(( - tokens_before_sampling, - sampling_request_output, - sampling_request_input, - )) + .await } .await; match sampling_request_result { - Ok((tokens_before_sampling, sampling_request_output, sampling_request_input)) => { + Ok((sampling_request_output, sampling_request_input)) => { let SamplingRequestResult { needs_follow_up: model_needs_follow_up, last_agent_message: sampling_request_last_agent_message, @@ -326,8 +319,6 @@ pub(crate) async fn run_turn( super::token_budget::maybe_record( sess.as_ref(), turn_context.as_ref(), - tokens_before_sampling, - tokens_after_sampling, tokens_until_compaction, ) .await; diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index e5dd607170..c0c905625f 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -9,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] \nThread id .\nFirst context window id .\nPrevious context window id .\nCurrent context window id .\n + [03] Thread id .\nFirst context window id: \nCurrent context window id: \nPrevious context window id: 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index a1f8fb5de2..4d448f9e7e 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -33,11 +33,11 @@ use std::time::Duration; const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000; -fn token_budget_texts(request: &ResponsesRequest) -> Vec { +fn token_budget_contexts(request: &ResponsesRequest) -> Vec { request .message_input_texts("developer") .into_iter() - .filter(|text| text.starts_with("")) + .filter(|text| text.starts_with("Thread id ")) .collect() } @@ -47,7 +47,7 @@ fn token_budget_window_ids( ) -> (String, Option, String) { let captures = assert_regex_match( &format!( - r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id (none|[0-9a-f-]{{36}})\.\nCurrent context window id ([0-9a-f-]{{36}})\.\n$" + r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})(?:\nPrevious context window id: ([0-9a-f-]{{36}}))?$" ), text, ); @@ -56,16 +56,12 @@ fn token_budget_window_ids( .expect("first window id capture") .as_str() .to_string(); - let previous_window_id = captures - .get(2) - .expect("previous window id capture") - .as_str(); - let previous_window_id = (previous_window_id != "none").then(|| previous_window_id.to_string()); let window_id = captures - .get(3) + .get(2) .expect("window id capture") .as_str() .to_string(); + let previous_window_id = captures.get(3).map(|capture| capture.as_str().to_string()); (first_window_id, previous_window_id, window_id) } @@ -115,14 +111,14 @@ async fn token_budget_context_is_only_emitted_with_full_context() -> Result<()> assert_eq!(requests.len(), 2); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (first_window_id, previous_window_id, window_id) = token_budget_window_ids(&initial_token_budget[0], thread_id); assert_eq!(previous_window_id, None); assert_eq!(first_window_id, window_id); assert_eq!( - token_budget_texts(&requests[1]), + token_budget_contexts(&requests[1]), initial_token_budget, "steady-state context update should not advance the context window" ); @@ -191,11 +187,11 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { let request = responses.single_request(); let thread_id = test.session_configured.thread_id; - let token_budgets = token_budget_texts(&request); + let token_budgets = token_budget_contexts(&request); assert_eq!(token_budgets.len(), 1); let captures = assert_regex_match( &format!( - r"^\nThread id {thread_id}\.\nFirst context window id ([0-9a-f-]{{36}})\.\nPrevious context window id none\.\nCurrent context window id ([0-9a-f-]{{36}})\.\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n$" + r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result$" ), &token_budgets[0], ); @@ -213,92 +209,6 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn token_budget_remaining_context_emits_on_first_threshold_crossing() -> Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let responses = mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_completed_with_tokens("resp-1", /*total_tokens*/ 2_500), - ]), - sse(vec![ - ev_response_created("resp-2"), - ev_completed_with_tokens("resp-2", /*total_tokens*/ 3_000), - ]), - sse(vec![ - ev_response_created("resp-3"), - ev_completed_with_tokens("resp-3", /*total_tokens*/ 5_000), - ]), - sse(vec![ - ev_response_created("resp-4"), - ev_completed_with_tokens("resp-4", /*total_tokens*/ 8_000), - ]), - sse(vec![ev_response_created("resp-5"), ev_completed("resp-5")]), - ], - ) - .await; - let test = test_codex() - .with_config(|config| { - config.model_context_window = Some(10_000); - config - .features - .enable(Feature::TokenBudget) - .expect("test config should allow token budget"); - }) - .build(&server) - .await?; - - for turn in 1..=5 { - test.submit_turn(&format!("turn {turn}")).await?; - } - - let requests = responses.requests(); - assert_eq!(requests.len(), 5); - - let thread_id = test.session_configured.thread_id; - let full_context = token_budget_texts(&requests[0]); - assert_eq!(full_context.len(), 1); - token_budget_window_ids(&full_context[0], thread_id); - let full_context = full_context[0].clone(); - let threshold_25 = - "\nYou have 7000 tokens left in this context window.\n" - .to_string(); - let threshold_50 = - "\nYou have 4500 tokens left in this context window.\n" - .to_string(); - let threshold_75 = - "\nYou have 1500 tokens left in this context window.\n" - .to_string(); - - assert_eq!(token_budget_texts(&requests[0]), vec![full_context.clone()]); - assert_eq!( - token_budget_texts(&requests[1]), - vec![full_context.clone(), threshold_25.clone()] - ); - assert_eq!( - token_budget_texts(&requests[2]), - vec![full_context.clone(), threshold_25.clone()] - ); - assert_eq!( - token_budget_texts(&requests[3]), - vec![ - full_context.clone(), - threshold_25.clone(), - threshold_50.clone() - ] - ); - assert_eq!( - token_budget_texts(&requests[4]), - vec![full_context, threshold_25, threshold_50, threshold_75] - ); - - Ok(()) -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Result<()> { skip_if_no_network!(Ok(())); @@ -335,17 +245,18 @@ async fn token_budget_reminder_emits_after_crossing_compaction_threshold() -> Re let requests = responses.requests(); assert_eq!(requests.len(), 2); - let initial_context = token_budget_texts(&requests[0]); + let initial_context = token_budget_contexts(&requests[0]); assert_eq!(initial_context.len(), 1); - let remaining_context = - "\nYou have 1500 tokens left in this context window.\n" - .to_string(); - let reminder = "\nYour context window is nearly exhausted (only 1000 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows.\n" - .to_string(); + let reminder = "Your context window is nearly exhausted (only 1000 tokens remaining) and will be automatically reset for you soon. Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows."; assert_eq!( - token_budget_texts(&requests[1]), - vec![initial_context[0].clone(), remaining_context, reminder] + requests[1] + .message_input_texts("developer") + .into_iter() + .filter(|text| text == reminder) + .count(), + 1 ); + assert_eq!(token_budget_contexts(&requests[1]), initial_context); Ok(()) } @@ -401,13 +312,10 @@ async fn get_context_remaining_returns_token_budget_remaining_fragment() -> Resu ); let thread_id = test.session_configured.thread_id; - let remaining_context = - "\nYou have 7000 tokens left in this context window.\n" - .to_string(); - let token_budgets = token_budget_texts(&requests[1]); - assert_eq!(token_budgets.len(), 2); + let remaining_context = "You have 7000 tokens left in this context window.".to_string(); + let token_budgets = token_budget_contexts(&requests[1]); + assert_eq!(token_budgets.len(), 1); token_budget_window_ids(&token_budgets[0], thread_id); - assert_eq!(token_budgets[1], remaining_context); assert_eq!( requests[2].function_call_output_content_and_success(call_id), Some((Some(remaining_context), None)) @@ -464,14 +372,11 @@ async fn get_context_remaining_returns_unknown_when_window_is_unavailable() -> R "get_context_remaining should be exposed when token budget is enabled" ); - assert_eq!(token_budget_texts(&requests[0]), Vec::::new()); + assert_eq!(token_budget_contexts(&requests[0]), Vec::::new()); assert_eq!( requests[1].function_call_output_content_and_success(call_id), Some(( - Some( - "\nYou have unknown tokens left in this context window.\n" - .to_string() - ), + Some("You have unknown tokens left in this context window.".to_string()), None, )) ); @@ -527,11 +432,11 @@ async fn token_budget_context_uses_new_window_after_compaction() -> Result<()> { assert_eq!(requests.len(), 3); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (initial_first_window_id, initial_previous_window_id, initial_window_id) = token_budget_window_ids(&initial_token_budget[0], thread_id); - let post_compaction_token_budget = token_budget_texts(&requests[2]); + let post_compaction_token_budget = token_budget_contexts(&requests[2]); assert_eq!(post_compaction_token_budget.len(), 1); let ( post_compaction_first_window_id, @@ -606,11 +511,11 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { "new_context should be exposed when token budget is enabled" ); let thread_id = test.session_configured.thread_id; - let initial_token_budget = token_budget_texts(&requests[0]); + let initial_token_budget = token_budget_contexts(&requests[0]); assert_eq!(initial_token_budget.len(), 1); let (initial_first_window_id, _, initial_window_id) = token_budget_window_ids(&initial_token_budget[0], thread_id); - let new_window_token_budget = token_budget_texts(&requests[2]); + let new_window_token_budget = token_budget_contexts(&requests[2]); assert_eq!(new_window_token_budget.len(), 1); let (new_first_window_id, new_previous_window_id, new_window_id) = token_budget_window_ids(&new_window_token_budget[0], thread_id); From bd2968a4db3057657f6d242010bfd15f589124f3 Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 11:33:21 +0100 Subject: [PATCH 08/86] Carry sandbox intent to remote exec servers (#29108) ## What changed PR #29099 stopped sending the orchestrator's concrete sandbox wrapper to a remote exec-server. Remote commands now arrive as plain native argv. This PR adds the next piece: Codex also sends portable sandbox intent next to that plain argv. For a remote unified-exec command, the request can now include: - the canonical permission profile before local workspace-root materialization - the sandbox cwd and workspace roots as `PathUri` values - Windows sandbox settings - the legacy Landlock setting - whether managed networking must be enforced The important part is that symbolic entries such as `:workspace_roots` stay symbolic while crossing the boundary. The executor can then bind them to its own workspace-root paths instead of receiving orchestrator-local absolute paths. The data travels through `ExecRequest` into `ExecParams`. Older exec-servers can still deserialize requests because the new fields have defaults. ## Why The orchestrator should not decide how another machine implements sandboxing. For example: - a local macOS Codex would normally build a Seatbelt command - a remote Linux executor needs a Linux sandbox command instead The orchestrator now sends the plain command plus the policy it intended to enforce. A later PR can let the exec-server choose and build the correct sandbox for its own operating system. ## Important detail This keeps the portable intent separate from the local `SandboxType`. `SandboxType::None` is ambiguous: - it can mean the command was explicitly approved to run without a sandbox - it can also mean the orchestrator host has no concrete sandbox implementation available Those cases are different for remote execution. This PR adds `sandbox_requested` so an executor can still receive sandbox intent when the orchestrator cannot build a local wrapper. Explicit unsandboxed retries still send no sandbox context. ## Behavior today This PR only transports the intent. The exec-server accepts the new fields but does not apply them yet. Remote commands therefore remain unsandboxed after this PR, just as they are after PR #29099. ## Follow-up The next PR will make exec-server read this portable intent, bind symbolic workspace permissions to executor-native roots, choose the sandbox for its own operating system, build the wrapper locally, and then spawn the command. --- codex-rs/core/src/exec.rs | 2 + codex-rs/core/src/sandboxing/mod.rs | 7 +++ codex-rs/core/src/tasks/user_shell.rs | 2 + codex-rs/core/src/tools/orchestrator.rs | 42 +++++++++++++---- .../src/tools/runtimes/apply_patch_tests.rs | 4 ++ codex-rs/core/src/tools/runtimes/mod_tests.rs | 2 + .../tools/runtimes/shell/unix_escalation.rs | 4 ++ codex-rs/core/src/tools/sandboxing.rs | 30 +++++++++++- codex-rs/core/src/tools/sandboxing_tests.rs | 29 ++++++++---- .../core/src/unified_exec/process_manager.rs | 2 + .../src/unified_exec/process_manager_tests.rs | 2 + codex-rs/exec-server/src/environment.rs | 2 + codex-rs/exec-server/src/local_process.rs | 2 + codex-rs/exec-server/src/protocol.rs | 6 +++ .../exec-server/src/server/handler/tests.rs | 2 + codex-rs/exec-server/src/server/processor.rs | 2 + codex-rs/exec-server/tests/exec_process.rs | 24 ++++++++++ codex-rs/exec-server/tests/relay.rs | 2 + .../rmcp-client/src/stdio_server_launcher.rs | 2 + codex-rs/sandboxing/src/manager.rs | 46 ++++++++++++------- 20 files changed, 178 insertions(+), 36 deletions(-) diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 9d55ae8864..99c87cdd99 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -456,6 +456,8 @@ pub(crate) async fn execute_exec_request( windows_sandbox_filesystem_overrides, network_environment_id, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = exec_request; // TODO(anp): Keep PathUri through the local process launch boundary. diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index c156b0d70e..d0006f8657 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -14,6 +14,7 @@ use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; use crate::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::exec_output::ExecToolCallOutput; @@ -60,6 +61,8 @@ pub struct ExecRequest { pub network_sandbox_policy: NetworkSandboxPolicy, pub(crate) windows_sandbox_filesystem_overrides: Option, pub arg0: Option, + pub(crate) exec_server_sandbox: Option, + pub(crate) exec_server_enforce_managed_network: bool, } impl ExecRequest { @@ -102,6 +105,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } @@ -158,6 +163,8 @@ impl ExecRequest { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, } } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index c5b44a6494..844e327bdf 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -224,6 +224,8 @@ pub(crate) async fn execute_user_shell_command( network_sandbox_policy: permission_profile.network_sandbox_policy(), windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let stdout_stream = Some(StdoutStream { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 803b8d6a9f..aaa0dfe9d1 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -84,7 +84,9 @@ impl ToolOrchestrator { }; let attempt_with_network_approval = SandboxAttempt { sandbox: attempt.sandbox, + sandbox_requested: attempt.sandbox_requested, permissions: attempt.permissions, + exec_server_permissions: attempt.exec_server_permissions, enforce_managed_network: attempt.enforce_managed_network, manager: attempt.manager, sandbox_cwd: attempt.sandbox_cwd, @@ -225,16 +227,27 @@ impl ToolOrchestrator { &file_system_sandbox_policy, ); let managed_network_active = turn_ctx.network.is_some(); - let initial_sandbox = match sandbox_override { - SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None, - SandboxOverride::NoOverride => self.sandbox.select_initial( + let sandbox_preference = tool.sandbox_preference(); + let sandbox_requested = match sandbox_override { + SandboxOverride::BypassSandboxFirstAttempt => false, + SandboxOverride::NoOverride => self.sandbox.should_sandbox( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), - turn_ctx.windows_sandbox_level, + sandbox_preference, managed_network_active, ), }; + let initial_sandbox = if sandbox_requested { + self.sandbox.select_initial( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + turn_ctx.windows_sandbox_level, + managed_network_active, + ) + } else { + SandboxType::None + }; // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.config.features.use_legacy_landlock(); @@ -246,7 +259,9 @@ impl ToolOrchestrator { let workspace_roots = turn_ctx.config.effective_workspace_roots(); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, + sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, @@ -401,16 +416,23 @@ impl ToolOrchestrator { .await?; } - let retry_sandbox = if unsandboxed_allowed { - SandboxType::None - } else { + let retry_sandbox_requested = !unsandboxed_allowed + && self.sandbox.should_sandbox( + &file_system_sandbox_policy, + network_sandbox_policy, + sandbox_preference, + managed_network_active, + ); + let retry_sandbox = if retry_sandbox_requested { self.sandbox.select_initial( &file_system_sandbox_policy, network_sandbox_policy, - tool.sandbox_preference(), + sandbox_preference, turn_ctx.windows_sandbox_level, managed_network_active, ) + } else { + SandboxType::None }; let retry_codex_linux_sandbox_exe = if unsandboxed_allowed { None @@ -419,7 +441,9 @@ impl ToolOrchestrator { }; let retry_attempt = SandboxAttempt { sandbox: retry_sandbox, + sandbox_requested: retry_sandbox_requested, permissions: &turn_ctx.permission_profile, + exec_server_permissions: turn_ctx.config.permissions.permission_profile(), enforce_managed_network: managed_network_active, manager: &self.sandbox, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 054f2c7c15..f1c6f43aa3 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -220,7 +220,9 @@ async fn file_system_sandbox_context_uses_active_attempt() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::MacosSeatbelt, + sandbox_requested: true, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, @@ -286,7 +288,9 @@ async fn no_sandbox_attempt_has_no_file_system_context() { let sandbox_policy_cwd = PathUri::from_abs_path(&path); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 91db26aaae..9b485dbe58 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -106,7 +106,9 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: let manager = SandboxManager::new(); let attempt = SandboxAttempt { sandbox: SandboxType::None, + sandbox_requested: false, permissions: &permissions, + exec_server_permissions: &permissions, enforce_managed_network: false, manager: &manager, sandbox_cwd: &sandbox_policy_cwd, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index c08148c7ee..a77645f941 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -166,6 +166,8 @@ pub(super) async fn try_run_zsh_fork( network_sandbox_policy, windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides, arg0, + exec_server_sandbox: _, + exec_server_enforce_managed_network: _, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; let effective_timeout = Duration::from_millis( @@ -898,6 +900,8 @@ impl CoreShellCommandExecutor { network_sandbox_policy: self.network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: self.arg0.clone(), + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }, /*stdout_stream*/ None, after_spawn, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 847a3c7584..f9d21dccfa 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -11,6 +11,7 @@ use crate::session::turn_context::TurnContext; use crate::state::SessionServices; use crate::tools::hook_names::HookToolName; use crate::tools::network_approval::NetworkApprovalSpec; +use codex_file_system::FileSystemSandboxContext; use codex_network_proxy::NetworkProxy; use codex_protocol::approvals::ExecPolicyAmendment; use codex_protocol::approvals::NetworkApprovalContext; @@ -24,6 +25,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_sandboxing::policy_transforms::effective_permission_profile; use codex_tools::ToolName; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -408,7 +410,11 @@ pub(crate) trait ToolRuntime: Approvable + Sandboxable { pub(crate) struct SandboxAttempt<'a> { pub sandbox: SandboxType, + /// Whether policy requested sandboxing, independent of this host's concrete wrapper. + pub sandbox_requested: bool, pub permissions: &'a codex_protocol::models::PermissionProfile, + /// Canonical permissions before this host materializes workspace roots. + pub exec_server_permissions: &'a codex_protocol::models::PermissionProfile, pub enforce_managed_network: bool, pub(crate) manager: &'a SandboxManager, pub(crate) sandbox_cwd: &'a PathUri, @@ -460,6 +466,10 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let exec_server_permissions = effective_permission_profile( + self.exec_server_permissions, + command.additional_permissions.as_ref(), + ); let request = self .manager .transform(SandboxTransformRequest { @@ -477,11 +487,27 @@ impl<'a> SandboxAttempt<'a> { windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, }) .map_err(CodexErr::from)?; - Ok(crate::sandboxing::ExecRequest::from_sandbox_exec_request( + let mut exec_request = crate::sandboxing::ExecRequest::from_sandbox_exec_request( request, options, self.workspace_roots.to_vec(), - )) + ); + if self.sandbox_requested { + exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { + permissions: exec_server_permissions.into(), + cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), + workspace_roots: self + .workspace_roots + .iter() + .map(PathUri::from_abs_path) + .collect(), + windows_sandbox_level: self.windows_sandbox_level, + windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, + use_legacy_landlock: self.use_legacy_landlock, + }); + exec_request.exec_server_enforce_managed_network = self.enforce_managed_network; + } + Ok(exec_request) } } diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index f4d6e6ce42..47b0b4e891 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -4,7 +4,6 @@ use crate::tools::hook_names::HookToolName; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::GranularApprovalConfig; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; @@ -202,21 +201,23 @@ fn deny_read_blocks_explicit_escalation_and_policy_bypass() { } #[test] -fn exec_server_env_keeps_command_native() { +fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { let cwd: AbsolutePathBuf = std::env::current_dir() .expect("current dir") .try_into() .expect("absolute cwd"); let cwd_uri = PathUri::from_abs_path(&cwd); - let permissions = codex_protocol::models::PermissionProfile::from_runtime_permissions( - &FileSystemSandboxPolicy::default(), - NetworkSandboxPolicy::Restricted, - ); + let exec_server_permissions = codex_protocol::models::PermissionProfile::workspace_write(); + let permissions = exec_server_permissions + .clone() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)); let manager = SandboxManager::new(); let attempt = SandboxAttempt { - sandbox: SandboxType::MacosSeatbelt, + sandbox: SandboxType::None, + sandbox_requested: true, permissions: &permissions, - enforce_managed_network: false, + exec_server_permissions: &exec_server_permissions, + enforce_managed_network: true, manager: &manager, sandbox_cwd: &cwd_uri, workspace_roots: std::slice::from_ref(&cwd), @@ -252,4 +253,16 @@ fn exec_server_env_keeps_command_native() { ); assert_eq!(request.arg0, None); assert_eq!(request.sandbox, SandboxType::None); + assert_eq!( + request.exec_server_sandbox, + Some(codex_exec_server::FileSystemSandboxContext { + permissions: exec_server_permissions.into(), + cwd: Some(cwd_uri), + workspace_roots: vec![PathUri::from_abs_path(&cwd)], + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, + windows_sandbox_private_desktop: false, + use_legacy_landlock: false, + }) + ); + assert!(request.exec_server_enforce_managed_network); } diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index fc5508eb80..a3b4bcf9c7 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -166,6 +166,8 @@ fn exec_server_params_for_request( tty, pipe_stdin: false, arg0: request.arg0.clone(), + sandbox: request.exec_server_sandbox.clone(), + enforce_managed_network: request.exec_server_enforce_managed_network, } } diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 5abd926c62..b6afdc85ba 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -111,6 +111,8 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { network_sandbox_policy, windows_sandbox_filesystem_overrides: None, arg0: None, + exec_server_sandbox: None, + exec_server_enforce_managed_network: false, }; let params = diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 9496bb8be6..d3e7e5e981 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1156,6 +1156,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .expect("start process"); diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 5e1b52b11c..1a6f2cf00f 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -902,6 +902,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 97ef86aad2..e05595f273 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -103,6 +103,12 @@ pub struct ExecParams { /// Optional process-visible argv0 override. Values such as `codex-linux-sandbox` are command /// names rather than paths, so this is not a [`PathUri`]. pub arg0: Option, + /// Portable sandbox intent. Concrete wrapper argv is resolved by the exec-server. + #[serde(default)] + pub sandbox: Option, + /// Whether the eventual executor-side sandbox must enforce managed networking. + #[serde(default)] + pub enforce_managed_network: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 0bd682bd96..9cbbf78d00 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -33,6 +33,8 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index 952f414213..b2daba7caf 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -403,6 +403,8 @@ mod tests { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, } } diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index 11a3f3ece7..ac38f50eaf 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -81,6 +81,8 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), "proc-1"); @@ -222,6 +224,8 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -253,6 +257,8 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -300,6 +306,8 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -348,6 +356,8 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -421,6 +431,8 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tty: true, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -458,6 +470,8 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -491,6 +505,8 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -525,6 +541,8 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!(session.process.process_id().as_str(), process_id); @@ -578,6 +596,8 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -618,6 +638,8 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; @@ -676,6 +698,8 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5e58618410..5f49655e39 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -150,6 +150,8 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { tty: false, pipe_stdin: false, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await?; assert_eq!( diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 8ada806ff7..94aa45c1e7 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -503,6 +503,8 @@ impl ExecutorStdioServerLauncher { tty: false, pipe_stdin: true, arg0: None, + sandbox: None, + enforce_managed_network: false, }) .await .map_err(io::Error::other)?; diff --git a/codex-rs/sandboxing/src/manager.rs b/codex-rs/sandboxing/src/manager.rs index dd7aba3614..29b1177b11 100644 --- a/codex-rs/sandboxing/src/manager.rs +++ b/codex-rs/sandboxing/src/manager.rs @@ -282,24 +282,36 @@ impl SandboxManager { windows_sandbox_level: WindowsSandboxLevel, has_managed_network_requirements: bool, ) -> SandboxType { + if self.should_sandbox( + file_system_policy, + network_policy, + pref, + has_managed_network_requirements, + ) { + get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) + .unwrap_or(SandboxType::None) + } else { + SandboxType::None + } + } + + /// Returns whether the request needs a sandbox, independently of whether + /// this host can provide a concrete sandbox implementation. + pub fn should_sandbox( + &self, + file_system_policy: &FileSystemSandboxPolicy, + network_policy: NetworkSandboxPolicy, + pref: SandboxablePreference, + has_managed_network_requirements: bool, + ) -> bool { match pref { - SandboxablePreference::Forbid => SandboxType::None, - SandboxablePreference::Require => { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } - SandboxablePreference::Auto => { - if should_require_platform_sandbox( - file_system_policy, - network_policy, - has_managed_network_requirements, - ) { - get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled) - .unwrap_or(SandboxType::None) - } else { - SandboxType::None - } - } + SandboxablePreference::Forbid => false, + SandboxablePreference::Require => true, + SandboxablePreference::Auto => should_require_platform_sandbox( + file_system_policy, + network_policy, + has_managed_network_requirements, + ), } } From 1088b30fda97c83b70b3b64e883d481ba40528ae Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 12:40:51 +0100 Subject: [PATCH 09/86] Test pipelined scalar exec-server requests (#29325) ## Summary This adds focused coverage for the simpler same-connection scalar request path. The exec-server connection already supports multiple in-flight JSON-RPC scalar requests on one connection. This test locks in that behavior by sending two normal requests before reading either response, without adding a batch frame or any new API surface. ## What changed - Added a processor-level test that initializes an exec-server connection. - Sends two scalar `environment/info` requests back-to-back on the same connection. - Verifies both responses come back on the same connection by request id. Checked locally with: - `just test -p codex-exec-server connection_accepts_pipelined_scalar_requests` --- codex-rs/exec-server/src/server/processor.rs | 35 ++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index b2daba7caf..f5c2ba760d 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -196,6 +196,7 @@ mod tests { use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_utils_path_uri::PathUri; + use pretty_assertions::assert_eq; use serde::Serialize; use serde::de::DeserializeOwned; use tokio::io::AsyncBufReadExt; @@ -211,9 +212,11 @@ mod tests { use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::connection::JsonRpcConnection; + use crate::protocol::ENVIRONMENT_INFO_METHOD; use crate::protocol::EXEC_METHOD; use crate::protocol::EXEC_READ_METHOD; use crate::protocol::EXEC_TERMINATE_METHOD; + use crate::protocol::EnvironmentInfo; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; use crate::protocol::INITIALIZE_METHOD; @@ -225,6 +228,38 @@ mod tests { use crate::protocol::TerminateResponse; use crate::server::session_registry::SessionRegistry; + #[tokio::test] + async fn connection_accepts_pipelined_scalar_requests() { + let registry = SessionRegistry::new(); + let (mut writer, mut lines, task) = spawn_test_connection(registry, "pipelined-scalar"); + + send_request( + &mut writer, + /*id*/ 1, + INITIALIZE_METHOD, + &InitializeParams { + client_name: "exec-server-test".to_string(), + resume_session_id: None, + }, + ) + .await; + let _: InitializeResponse = read_response(&mut lines, /*expected_id*/ 1).await; + send_notification(&mut writer, INITIALIZED_METHOD, &()).await; + + send_request(&mut writer, /*id*/ 2, ENVIRONMENT_INFO_METHOD, &()).await; + send_request(&mut writer, /*id*/ 3, ENVIRONMENT_INFO_METHOD, &()).await; + + let _: EnvironmentInfo = read_response(&mut lines, /*expected_id*/ 2).await; + let _: EnvironmentInfo = read_response(&mut lines, /*expected_id*/ 3).await; + + drop(writer); + drop(lines); + timeout(Duration::from_secs(1), task) + .await + .expect("processor should exit") + .expect("processor should join"); + } + #[tokio::test] async fn transport_disconnect_detaches_session_during_in_flight_read() { let registry = SessionRegistry::new(); From c2fbf4247abb9718a280c2647cedeab87db2e169 Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 13:04:18 +0100 Subject: [PATCH 10/86] Parallelize skill metadata stats (#29326) ## Summary This switches skill discovery to the simpler same-connection scalar request shape. After reading a skills directory, discovery now starts the existing `fs/getMetadata` calls for all visible entries in that directory before awaiting the results. There is no JSON-RPC batch frame and no new filesystem API; remote filesystems use the existing request-id multiplexing on the same exec-server connection. This is the scoped alternative to the batch-frame approach in #29074 / #29075. ## What changed - Collect visible directory entries before processing them. - Run their existing `fs.get_metadata(...)` calls with `join_all`. - Process the results in the original directory order, so skill discovery behavior stays the same. ## Benchmarks Fresh local benchmark against generated skill trees over a real exec-server remote filesystem. The benchmark calls the actual `load_skills_from_roots` path, so this includes directory reads, metadata stats, `SKILL.md` reads, and parsing. Times are p50 milliseconds from 5 samples after 1 warmup, using warmed runs. | Scenario | Legacy `main` | Batch frame stack (#29074 / #29075) | Same-connection scalar stack | | --- | ---: | ---: | ---: | | 100 flat skills | 377.4 | 389.0 | 378.6 | | 500 flat skills | 1983.2 | 1856.6 | 1757.5 | Takeaway: for the actual skill discovery path, same-connection scalar is tied with legacy at 100 skills and best at 500 skills. The batch-frame stack does not show enough win here to justify the extra protocol/API surface. Benchmark command: - `just test -p codex-exec-server benchmark_remote_skill_discovery --run-ignored ignored-only --no-capture` Checked locally with: - `just test -p codex-core-skills` - `just bazel-lock-update` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + codex-rs/core-skills/Cargo.toml | 1 + codex-rs/core-skills/src/loader.rs | 31 ++++++++++++++++++++++-------- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f6f0e55831..f35402efd0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2809,6 +2809,7 @@ dependencies = [ "codex-utils-plugins", "dirs", "dunce", + "futures", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml index 7748540ac1..3ac82be9a8 100644 --- a/codex-rs/core-skills/Cargo.toml +++ b/codex-rs/core-skills/Cargo.toml @@ -31,6 +31,7 @@ codex-utils-path-uri = { workspace = true } codex-utils-plugins = { workspace = true } dirs = { workspace = true } dunce = { workspace = true } +futures = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_yaml = { workspace = true } diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index aa9688f9a2..b377e909ea 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -22,6 +22,7 @@ use codex_utils_path_uri::PathUri; use codex_utils_plugins::PluginSkillRoot; use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; +use futures::future::join_all; use serde::Deserialize; use std::collections::HashSet; use std::collections::VecDeque; @@ -534,15 +535,29 @@ async fn discover_skills_under_root( } }; - for entry in entries { - let file_name = entry.file_name; - if file_name.starts_with('.') { - continue; - } + let paths = entries + .into_iter() + .filter_map(|entry| { + let file_name = entry.file_name; + if file_name.starts_with('.') { + return None; + } + let path = dir.join(&file_name); + let path_uri = PathUri::from_abs_path(&path); + Some((file_name, path, path_uri)) + }) + .collect::>(); + let metadata_results = join_all( + paths + .iter() + .map(|(_, _, path_uri)| fs.get_metadata(path_uri, /*sandbox*/ None)), + ) + .await; - let path = dir.join(&file_name); - let path_uri = PathUri::from_abs_path(&path); - let metadata = match fs.get_metadata(&path_uri, /*sandbox*/ None).await { + for ((file_name, path, path_uri), metadata_result) in + paths.into_iter().zip(metadata_results) + { + let metadata = match metadata_result { Ok(metadata) => metadata, Err(e) => { error!("failed to stat skills path {}: {e:#}", path.display()); From 6f5dd7b4226f3c77d4d253d8be1e10ac1686ccf9 Mon Sep 17 00:00:00 2001 From: jif Date: Sun, 21 Jun 2026 13:53:16 +0100 Subject: [PATCH 11/86] Use controlled time for remote initialization timeout test (#29329) ## Summary The remote-control initialization timeout test used a 50 ms wall-clock deadline around a 10 ms transport timeout. A busy CI runner could miss that outer deadline even when the rollback behavior was correct. Pause Tokio time and advance it explicitly through the transport timeout instead. The test still verifies that initialization fails and emits the matching connection-closed event, without depending on scheduler speed. --- codex-rs/app-server-transport/Cargo.toml | 1 + .../transport/remote_control/client_tracker.rs | 18 +++++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/codex-rs/app-server-transport/Cargo.toml b/codex-rs/app-server-transport/Cargo.toml index 175890962e..7ad9444921 100644 --- a/codex-rs/app-server-transport/Cargo.toml +++ b/codex-rs/app-server-transport/Cargo.toml @@ -57,3 +57,4 @@ chrono = { workspace = true } codex-config = { workspace = true } pretty_assertions = { workspace = true } tempfile = { workspace = true } +tokio = { workspace = true, features = ["test-util"] } diff --git a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs index 22e5e74eda..aef2bfd25d 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/client_tracker.rs @@ -692,14 +692,14 @@ mod tests { } } - #[tokio::test] + #[tokio::test(start_paused = true)] async fn initialize_timeout_closes_open_connection() { let (server_event_tx, _server_event_rx) = mpsc::channel(CHANNEL_CAPACITY); let (transport_event_tx, mut transport_event_rx) = mpsc::channel(1); let shutdown_token = CancellationToken::new(); let client_tracker = ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token); - let mut handle_message = tokio::spawn(async move { + let handle_message = tokio::spawn(async move { let mut client_tracker = client_tracker; client_tracker .handle_message(initialize_envelope_with_stream_id( @@ -709,13 +709,13 @@ mod tests { .await }); - assert!( - timeout(Duration::from_millis(50), &mut handle_message) - .await - .expect("initialize timeout rollback should not wait for close delivery") - .expect("handle message task should not panic") - .is_err() - ); + tokio::task::yield_now().await; + tokio::time::advance( + REMOTE_CONTROL_TRANSPORT_EVENT_SEND_TIMEOUT + Duration::from_millis(1), + ) + .await; + + assert!(handle_message.await.expect("handle message task").is_err()); let connection_id = match transport_event_rx.recv().await.expect("open event") { TransportEvent::ConnectionOpened { connection_id, .. } => connection_id, other => panic!("expected connection opened, got {other:?}"), From 6d993ca6464570993e9312096d77b964e64bfab5 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 10:49:31 -0700 Subject: [PATCH 12/86] code-mode: define transport-neutral runtime types (#29170) ## Summary - introduce a private `session_runtime` boundary for cell creation requests, observation modes, lifecycle events, output items, and tool metadata - update the cell actor and in-process service to use those transport-neutral types - keep cell ID allocation on the owning session side ## Motivation Cell lifecycle vocabulary currently lives inside the cell actor implementation. That makes the service adapter and future session runtime depend on actor-specific types, increasing the size and complexity of the runtime ownership change. This is the first reviewable slice of the session-runtime stack. It separates the transport-neutral data model without moving lifecycle ownership or changing behavior. Later slices will move session state behind this boundary, harden terminal and shutdown behavior, and split cell creation from observation. ## Behavior There are no public API or user-visible behavior changes in this PR. In particular: - `CodeModeSession::execute` and `wait` are unchanged - cell IDs remain allocated by the owning session - cell admission, observation, termination, and shutdown behavior are unchanged --- .../code-mode/src/cell_actor/conversions.rs | 8 +- codex-rs/code-mode/src/cell_actor/mod.rs | 12 +-- codex-rs/code-mode/src/cell_actor/tests.rs | 1 + codex-rs/code-mode/src/cell_actor/types.rs | 78 ++---------------- codex-rs/code-mode/src/lib.rs | 1 + codex-rs/code-mode/src/service.rs | 16 ++-- codex-rs/code-mode/src/session_runtime/mod.rs | 10 +++ .../code-mode/src/session_runtime/types.rs | 79 +++++++++++++++++++ 8 files changed, 114 insertions(+), 91 deletions(-) create mode 100644 codex-rs/code-mode/src/session_runtime/mod.rs create mode 100644 codex-rs/code-mode/src/session_runtime/types.rs diff --git a/codex-rs/code-mode/src/cell_actor/conversions.rs b/codex-rs/code-mode/src/cell_actor/conversions.rs index ebc12d29f5..7fb2a25c63 100644 --- a/codex-rs/code-mode/src/cell_actor/conversions.rs +++ b/codex-rs/code-mode/src/cell_actor/conversions.rs @@ -5,10 +5,10 @@ use codex_code_mode_protocol::ImageDetail; use codex_code_mode_protocol::ToolDefinition; use codex_protocol::ToolName; -use super::CellImageDetail; -use super::CellOutputItem; -use super::CellRequest; -use super::CellToolKind; +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ImageDetail as CellImageDetail; +use crate::session_runtime::OutputItem as CellOutputItem; +use crate::session_runtime::ToolKind as CellToolKind; pub(super) fn runtime_request(request: CellRequest) -> ExecuteRequest { ExecuteRequest { diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index d42ce48a24..6e0ffb9e0c 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -22,23 +22,19 @@ use self::conversions::output_item; use self::conversions::runtime_request; use self::types::CellCommand; pub(crate) use self::types::CellError; -pub(crate) use self::types::CellEvent; pub(crate) use self::types::CellEventFuture; pub(crate) use self::types::CellHandle; pub(crate) use self::types::CellHost; -pub(crate) use self::types::CellImageDetail; -pub(crate) use self::types::CellOutputItem; -pub(crate) use self::types::CellRequest; pub(crate) use self::types::CellToolCall; -pub(crate) use self::types::CellToolDefinition; -pub(crate) use self::types::CellToolKind; -pub(crate) use self::types::CellToolName; -pub(crate) use self::types::ObserveMode; use crate::runtime::PendingRuntimeMode; use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeControlCommand; use crate::runtime::RuntimeEvent; use crate::runtime::spawn_runtime; +use crate::session_runtime::CellEvent; +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::ToolName as CellToolName; pub(crate) struct CellActor; diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index 218f1fcde4..d7b72a1037 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -11,6 +11,7 @@ use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use super::*; +use crate::session_runtime::OutputItem as CellOutputItem; struct TestHost; diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index aef0b5211f..d82f898e76 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -4,40 +4,20 @@ use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::time::Duration; use serde_json::Value as JsonValue; use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; +use crate::session_runtime::CellEvent; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::ToolKind; +use crate::session_runtime::ToolName; + pub(crate) type CellEventFuture = Pin> + Send + 'static>>; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum ObserveMode { - YieldAfter(Duration), - PendingFrontier, -} - -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum CellEvent { - Yielded { - content_items: Vec, - }, - Pending { - content_items: Vec, - pending_tool_call_ids: Vec, - }, - Completed { - content_items: Vec, - error_text: Option, - }, - Terminated { - content_items: Vec, - }, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum CellError { Busy, @@ -45,54 +25,10 @@ pub(crate) enum CellError { Closed, } -#[derive(Clone, Debug, PartialEq)] -pub(crate) enum CellOutputItem { - Text { - text: String, - }, - Image { - image_url: String, - detail: Option, - }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum CellImageDetail { - Auto, - Low, - High, - Original, -} - -pub(crate) struct CellRequest { - pub(crate) tool_call_id: String, - pub(crate) enabled_tools: Vec, - pub(crate) source: String, -} - -pub(crate) struct CellToolDefinition { - pub(crate) name: String, - pub(crate) tool_name: CellToolName, - pub(crate) description: String, - pub(crate) kind: CellToolKind, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct CellToolName { - pub(crate) name: String, - pub(crate) namespace: Option, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum CellToolKind { - Function, - Freeform, -} - pub(crate) struct CellToolCall { pub(crate) id: String, - pub(crate) name: CellToolName, - pub(crate) kind: CellToolKind, + pub(crate) name: ToolName, + pub(crate) kind: ToolKind, pub(crate) input: Option, } diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 76f8aa9b97..cbe8bfe6df 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -1,6 +1,7 @@ mod cell_actor; mod runtime; mod service; +mod session_runtime; pub use codex_code_mode_protocol::*; pub use service::CodeModeService; diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index 4c8fa517ae..bf32156a0d 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -33,18 +33,18 @@ use tokio_util::sync::CancellationToken; use crate::cell_actor::CellActor; use crate::cell_actor::CellError; -use crate::cell_actor::CellEvent; use crate::cell_actor::CellEventFuture; use crate::cell_actor::CellHandle; use crate::cell_actor::CellHost; -use crate::cell_actor::CellImageDetail; -use crate::cell_actor::CellOutputItem; -use crate::cell_actor::CellRequest; use crate::cell_actor::CellToolCall; -use crate::cell_actor::CellToolDefinition; -use crate::cell_actor::CellToolKind; -use crate::cell_actor::CellToolName; -use crate::cell_actor::ObserveMode; +use crate::session_runtime::CellEvent; +use crate::session_runtime::CreateCellRequest as CellRequest; +use crate::session_runtime::ImageDetail as CellImageDetail; +use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem as CellOutputItem; +use crate::session_runtime::ToolDefinition as CellToolDefinition; +use crate::session_runtime::ToolKind as CellToolKind; +use crate::session_runtime::ToolName as CellToolName; pub struct NoopCodeModeSessionDelegate; diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs new file mode 100644 index 0000000000..bb09e0e437 --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -0,0 +1,10 @@ +mod types; + +pub(crate) use self::types::CellEvent; +pub(crate) use self::types::CreateCellRequest; +pub(crate) use self::types::ImageDetail; +pub(crate) use self::types::ObserveMode; +pub(crate) use self::types::OutputItem; +pub(crate) use self::types::ToolDefinition; +pub(crate) use self::types::ToolKind; +pub(crate) use self::types::ToolName; diff --git a/codex-rs/code-mode/src/session_runtime/types.rs b/codex-rs/code-mode/src/session_runtime/types.rs new file mode 100644 index 0000000000..f3719c96c3 --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/types.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +/// Selects the next observable frontier for a running cell. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ObserveMode { + YieldAfter(Duration), + PendingFrontier, +} + +/// An observable cell lifecycle event. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum CellEvent { + Yielded { + content_items: Vec, + }, + Pending { + content_items: Vec, + pending_tool_call_ids: Vec, + }, + Completed { + content_items: Vec, + error_text: Option, + }, + Terminated { + content_items: Vec, + }, +} + +/// Output emitted by a cell since its preceding observation. +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum OutputItem { + Text { + text: String, + }, + Image { + image_url: String, + detail: Option, + }, +} + +/// Requested image fidelity for an output image. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ImageDetail { + Auto, + Low, + High, + Original, +} + +/// Transport-neutral input for creating a cell. +/// +/// The owning session assigns the cell ID when it admits the request. +pub(crate) struct CreateCellRequest { + pub(crate) tool_call_id: String, + pub(crate) enabled_tools: Vec, + pub(crate) source: String, +} + +/// Tool metadata exposed to code running inside a cell. +pub(crate) struct ToolDefinition { + pub(crate) name: String, + pub(crate) tool_name: ToolName, + pub(crate) description: String, + pub(crate) kind: ToolKind, +} + +/// A tool name with an optional namespace. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct ToolName { + pub(crate) name: String, + pub(crate) namespace: Option, +} + +/// The JavaScript calling convention for a tool. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ToolKind { + Function, + Freeform, +} From 63f009e9dad2e70454b7ed6434d8aa28dfb52b51 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 11:18:36 -0700 Subject: [PATCH 13/86] code-mode: move session ownership into runtime (#29285) ## Summary - Move code-mode cell ownership and shared stored values from `CodeModeService` into `SessionRuntime`. - Keep the protocol-facing execute/wait behavior behind the existing service adapter. - Add runtime-level ownership and isolation coverage. ## Why This establishes a transport-neutral session boundary before later lifecycle and create/observe changes. ## Impact No intended model-facing behavior change. This is an ownership and layering refactor. ## Validation - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03a-runtime-types`. --- codex-rs/code-mode/src/service.rs | 346 +++++++----------- .../code-mode/src/service_contract_tests.rs | 16 +- codex-rs/code-mode/src/service_tests.rs | 11 +- codex-rs/code-mode/src/session_runtime/mod.rs | 268 ++++++++++++++ .../code-mode/src/session_runtime/types.rs | 93 +++++ 5 files changed, 510 insertions(+), 224 deletions(-) diff --git a/codex-rs/code-mode/src/service.rs b/codex-rs/code-mode/src/service.rs index bf32156a0d..bb7aa842e1 100644 --- a/codex-rs/code-mode/src/service.rs +++ b/codex-rs/code-mode/src/service.rs @@ -1,8 +1,4 @@ -use std::collections::HashMap; use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU64; -use std::sync::atomic::Ordering; use std::time::Duration; use codex_code_mode_protocol::CellId; @@ -27,24 +23,11 @@ use codex_code_mode_protocol::WaitRequest; use codex_code_mode_protocol::WaitToPendingOutcome; use codex_code_mode_protocol::WaitToPendingRequest; use serde_json::Value as JsonValue; -use tokio::sync::Mutex; use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; -use crate::cell_actor::CellActor; -use crate::cell_actor::CellError; -use crate::cell_actor::CellEventFuture; -use crate::cell_actor::CellHandle; -use crate::cell_actor::CellHost; -use crate::cell_actor::CellToolCall; -use crate::session_runtime::CellEvent; -use crate::session_runtime::CreateCellRequest as CellRequest; -use crate::session_runtime::ImageDetail as CellImageDetail; -use crate::session_runtime::ObserveMode; -use crate::session_runtime::OutputItem as CellOutputItem; -use crate::session_runtime::ToolDefinition as CellToolDefinition; -use crate::session_runtime::ToolKind as CellToolKind; -use crate::session_runtime::ToolName as CellToolName; +use crate::session_runtime as runtime; +use crate::session_runtime::SessionRuntime; pub struct NoopCodeModeSessionDelegate; @@ -89,16 +72,8 @@ impl CodeModeSessionProvider for InProcessCodeModeSessionProvider { } } -struct Inner { - stored_values: Mutex>, - cells: Mutex>, - delegate: Arc, - shutting_down: AtomicBool, - next_cell_id: AtomicU64, -} - pub struct CodeModeService { - inner: Arc, + runtime: SessionRuntime, } impl CodeModeService { @@ -108,45 +83,31 @@ impl CodeModeService { pub fn with_delegate(delegate: Arc) -> Self { Self { - inner: Arc::new(Inner { - stored_values: Mutex::new(HashMap::new()), - cells: Mutex::new(HashMap::new()), - delegate, - shutting_down: AtomicBool::new(false), - next_cell_id: AtomicU64::new(1), - }), + runtime: SessionRuntime::new(Arc::new(ProtocolDelegate { delegate })), } } - fn allocate_cell_id(&self) -> CellId { - CellId::new( - self.inner - .next_cell_id - .fetch_add(1, Ordering::Relaxed) - .to_string(), - ) - } - pub async fn execute(&self, request: ExecuteRequest) -> Result { let yield_time_ms = request.yield_time_ms.unwrap_or(DEFAULT_EXEC_YIELD_TIME_MS); - let cell_id = self.allocate_cell_id(); - let initial_event = self - .start_cell( - cell_id.clone(), - request, - ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), ) - .await?; + .await + .map_err(|error| error.to_string())?; + let cell_id = protocol_cell_id(&started.cell_id); let response_cell_id = cell_id.clone(); let (response_tx, response_rx) = oneshot::channel(); tokio::spawn(async move { - let response = initial_event + let response = started + .initial_event() .await - .map_err(|error| cell_error_text(&response_cell_id, error)) + .map_err(|error| error.to_string()) .and_then(|event| runtime_response(&response_cell_id, event)); let _ = response_tx.send(response); }); - Ok(StartedCell::from_result_receiver(cell_id, response_rx)) } @@ -154,45 +115,22 @@ impl CodeModeService { &self, request: ExecuteRequest, ) -> Result { - let cell_id = self.allocate_cell_id(); - let event = self - .start_cell(cell_id.clone(), request, ObserveMode::PendingFrontier) - .await? + let started = self + .runtime + .execute( + runtime_request(request), + runtime::ObserveMode::PendingFrontier, + ) .await - .map_err(|error| cell_error_text(&cell_id, error))?; + .map_err(|error| error.to_string())?; + let cell_id = protocol_cell_id(&started.cell_id); + let event = started + .initial_event() + .await + .map_err(|error| error.to_string())?; pending_outcome(&cell_id, event) } - async fn start_cell( - &self, - cell_id: CellId, - request: ExecuteRequest, - initial_observe_mode: ObserveMode, - ) -> Result { - let stored_values = self.inner.stored_values.lock().await.clone(); - let host = Arc::new(ServiceCellHost { - cell_id: cell_id.clone(), - inner: Arc::clone(&self.inner), - }); - let mut cells = self.inner.cells.lock().await; - if self.inner.shutting_down.load(Ordering::Acquire) { - return Err("code mode session is shutting down".to_string()); - } - if cells.contains_key(&cell_id) { - return Err(format!("exec cell {cell_id} already exists")); - } - let (handle, initial_event, task) = CellActor::prepare( - cell_request(request), - stored_values, - host, - initial_observe_mode, - )?; - cells.insert(cell_id, handle); - drop(cells); - tokio::spawn(task); - Ok(initial_event) - } - pub async fn wait(&self, request: WaitRequest) -> Result { self.begin_wait(request).await.await } @@ -205,24 +143,39 @@ impl CodeModeService { cell_id, yield_time_ms, } = request; - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return missing_wait(cell_id); - }; - wait_for_event( - cell_id, - handle.observe(ObserveMode::YieldAfter(Duration::from_millis( - yield_time_ms, - ))), - ) + let runtime_cell_id = runtime_cell_id(&cell_id); + match self + .runtime + .begin_observe( + &runtime_cell_id, + runtime::ObserveMode::YieldAfter(Duration::from_millis(yield_time_ms)), + ) + .await + { + Ok(pending_event) => Box::pin(async move { + match pending_event.event().await { + Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) + } + Err(error) => Err(error.to_string()), + } + }), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + missing_wait(cell_id) + } + Err(error) => Box::pin(async move { Err(error.to_string()) }), + } } pub async fn terminate(&self, cell_id: CellId) -> Result { - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))); - }; - wait_for_event(cell_id, handle.terminate()).await + match self.runtime.terminate(&runtime_cell_id(&cell_id)).await { + Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => { + Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))) + } + Err(error) => Err(error.to_string()), + } } pub async fn wait_to_pending( @@ -230,40 +183,29 @@ impl CodeModeService { request: WaitToPendingRequest, ) -> Result { let cell_id = request.cell_id; - let handle = self.inner.cells.lock().await.get(&cell_id).cloned(); - let Some(handle) = handle else { - return Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))); - }; - match handle.observe(ObserveMode::PendingFrontier).await { + match self + .runtime + .observe( + &runtime_cell_id(&cell_id), + runtime::ObserveMode::PendingFrontier, + ) + .await + { Ok(event) => Ok(WaitToPendingOutcome::LiveCell(pending_outcome( &cell_id, event, )?)), - Err(CellError::Closed) => Ok(WaitToPendingOutcome::MissingCell(missing_cell_response( - cell_id, - ))), - Err(error) => Err(cell_error_text(&cell_id, error)), + Err(runtime::Error::MissingCell(_) | runtime::Error::ClosedCell(_)) => Ok( + WaitToPendingOutcome::MissingCell(missing_cell_response(cell_id)), + ), + Err(error) => Err(error.to_string()), } } pub async fn shutdown(&self) -> Result<(), String> { - self.inner.shutting_down.store(true, Ordering::Release); - let handles = self - .inner - .cells - .lock() + self.runtime + .shutdown() .await - .values() - .cloned() - .collect::>(); - for handle in handles { - handle.shutdown(); - } - while !self.inner.cells.lock().await.is_empty() { - tokio::task::yield_now().await; - } - Ok(()) + .map_err(|error| error.to_string()) } } @@ -273,20 +215,9 @@ impl Default for CodeModeService { } } -impl Drop for CodeModeService { - fn drop(&mut self) { - self.inner.shutting_down.store(true, Ordering::Release); - if let Ok(cells) = self.inner.cells.try_lock() { - for handle in cells.values() { - handle.shutdown(); - } - } - } -} - impl CodeModeSession for CodeModeService { fn is_alive(&self) -> bool { - !self.inner.shutting_down.load(Ordering::Acquire) + self.runtime.is_alive() } fn execute<'a>( @@ -309,30 +240,28 @@ impl CodeModeSession for CodeModeService { } } -struct ServiceCellHost { - cell_id: CellId, - inner: Arc, +struct ProtocolDelegate { + delegate: Arc, } -impl CellHost for ServiceCellHost { +impl runtime::SessionRuntimeDelegate for ProtocolDelegate { async fn invoke_tool( &self, - invocation: CellToolCall, + invocation: runtime::NestedToolCall, cancellation_token: CancellationToken, ) -> Result { - self.inner - .delegate + self.delegate .invoke_tool( CodeModeNestedToolCall { - cell_id: self.cell_id.clone(), - runtime_tool_call_id: invocation.id, + cell_id: protocol_cell_id(&invocation.cell_id), + runtime_tool_call_id: invocation.runtime_tool_call_id, tool_name: codex_protocol::ToolName { - name: invocation.name.name, - namespace: invocation.name.namespace, + name: invocation.tool_name.name, + namespace: invocation.tool_name.namespace, }, - tool_kind: match invocation.kind { - CellToolKind::Function => CodeModeToolKind::Function, - CellToolKind::Freeform => CodeModeToolKind::Freeform, + tool_kind: match invocation.tool_kind { + runtime::ToolKind::Function => CodeModeToolKind::Function, + runtime::ToolKind::Freeform => CodeModeToolKind::Freeform, }, input: invocation.input, }, @@ -344,45 +273,41 @@ impl CellHost for ServiceCellHost { async fn notify( &self, call_id: String, + cell_id: runtime::CellId, text: String, cancellation_token: CancellationToken, ) -> Result<(), String> { - self.inner - .delegate - .notify(call_id, self.cell_id.clone(), text, cancellation_token) + self.delegate + .notify( + call_id, + protocol_cell_id(&cell_id), + text, + cancellation_token, + ) .await } - async fn commit_stored_values(&self, stored_value_writes: HashMap) { - self.inner - .stored_values - .lock() - .await - .extend(stored_value_writes); - } - - async fn closed(&self) { - self.inner.cells.lock().await.remove(&self.cell_id); - self.inner.delegate.cell_closed(&self.cell_id); + fn cell_closed(&self, cell_id: &runtime::CellId) { + self.delegate.cell_closed(&protocol_cell_id(cell_id)); } } -fn cell_request(request: ExecuteRequest) -> CellRequest { - CellRequest { +fn runtime_request(request: ExecuteRequest) -> runtime::CreateCellRequest { + runtime::CreateCellRequest { tool_call_id: request.tool_call_id, enabled_tools: request .enabled_tools .into_iter() - .map(|definition| CellToolDefinition { + .map(|definition| runtime::ToolDefinition { name: definition.name, - tool_name: CellToolName { + tool_name: runtime::ToolName { name: definition.tool_name.name, namespace: definition.tool_name.namespace, }, description: definition.description, kind: match definition.kind { - CodeModeToolKind::Function => CellToolKind::Function, - CodeModeToolKind::Freeform => CellToolKind::Freeform, + CodeModeToolKind::Function => runtime::ToolKind::Function, + CodeModeToolKind::Freeform => runtime::ToolKind::Freeform, }, }) .collect(), @@ -390,22 +315,20 @@ fn cell_request(request: ExecuteRequest) -> CellRequest { } } -fn wait_for_event( - cell_id: CellId, - event: CellEventFuture, -) -> CodeModeSessionResultFuture<'static, WaitOutcome> { - Box::pin(async move { - match event.await { - Ok(event) => Ok(WaitOutcome::LiveCell(runtime_response(&cell_id, event)?)), - Err(CellError::Closed) => Ok(WaitOutcome::MissingCell(missing_cell_response(cell_id))), - Err(error) => Err(cell_error_text(&cell_id, error)), - } - }) +fn runtime_cell_id(cell_id: &CellId) -> runtime::CellId { + runtime::CellId::new(cell_id.as_str()) } -fn pending_outcome(cell_id: &CellId, event: CellEvent) -> Result { +fn protocol_cell_id(cell_id: &runtime::CellId) -> CellId { + CellId::new(cell_id.as_str().to_string()) +} + +fn pending_outcome( + cell_id: &CellId, + event: runtime::CellEvent, +) -> Result { match event { - CellEvent::Pending { + runtime::CellEvent::Pending { content_items, pending_tool_call_ids, } => Ok(ExecuteToPendingOutcome::Pending { @@ -419,13 +342,16 @@ fn pending_outcome(cell_id: &CellId, event: CellEvent) -> Result Result { +fn runtime_response( + cell_id: &CellId, + event: runtime::CellEvent, +) -> Result { match event { - CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { + runtime::CellEvent::Yielded { content_items } => Ok(RuntimeResponse::Yielded { cell_id: cell_id.clone(), content_items: content_items.into_iter().map(output_item).collect(), }), - CellEvent::Completed { + runtime::CellEvent::Completed { content_items, error_text, } => Ok(RuntimeResponse::Result { @@ -433,36 +359,30 @@ fn runtime_response(cell_id: &CellId, event: CellEvent) -> Result Ok(RuntimeResponse::Terminated { + runtime::CellEvent::Terminated { content_items } => Ok(RuntimeResponse::Terminated { cell_id: cell_id.clone(), content_items: content_items.into_iter().map(output_item).collect(), }), - CellEvent::Pending { .. } => { + runtime::CellEvent::Pending { .. } => { Err("cell returned a pending frontier unexpectedly".to_string()) } } } -fn output_item(item: CellOutputItem) -> FunctionCallOutputContentItem { +fn output_item(item: runtime::OutputItem) -> FunctionCallOutputContentItem { match item { - CellOutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text }, - CellOutputItem::Image { image_url, detail } => FunctionCallOutputContentItem::InputImage { - image_url, - detail: detail.map(|detail| match detail { - CellImageDetail::Auto => ImageDetail::Auto, - CellImageDetail::Low => ImageDetail::Low, - CellImageDetail::High => ImageDetail::High, - CellImageDetail::Original => ImageDetail::Original, - }), - }, - } -} - -fn cell_error_text(cell_id: &CellId, error: CellError) -> String { - match error { - CellError::Busy => format!("exec cell {cell_id} already has an active observer"), - CellError::AlreadyTerminating => format!("exec cell {cell_id} is already terminating"), - CellError::Closed => format!("exec cell {cell_id} closed unexpectedly"), + runtime::OutputItem::Text { text } => FunctionCallOutputContentItem::InputText { text }, + runtime::OutputItem::Image { image_url, detail } => { + FunctionCallOutputContentItem::InputImage { + image_url, + detail: detail.map(|detail| match detail { + runtime::ImageDetail::Auto => ImageDetail::Auto, + runtime::ImageDetail::Low => ImageDetail::Low, + runtime::ImageDetail::High => ImageDetail::High, + runtime::ImageDetail::Original => ImageDetail::Original, + }), + } + } } } diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs index 3d814e98cc..9484310323 100644 --- a/codex-rs/code-mode/src/service_contract_tests.rs +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -288,8 +288,20 @@ async fn observed_natural_completion_wins_over_termination() { ); tokio::time::timeout(Duration::from_secs(1), async { loop { - if service.inner.stored_values.lock().await.get("finished") - == Some(&serde_json::json!(true)) + let response = service + .execute(execute_request(r#"text(String(load("finished")));"#)) + .await + .unwrap() + .initial_response() + .await + .unwrap(); + let RuntimeResponse::Result { content_items, .. } = response else { + panic!("expected stored-value probe to complete"); + }; + if content_items + == vec![FunctionCallOutputContentItem::InputText { + text: "true".to_string(), + }] { break; } diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode/src/service_tests.rs index 688bd89cd2..d90d4b6afb 100644 --- a/codex-rs/code-mode/src/service_tests.rs +++ b/codex-rs/code-mode/src/service_tests.rs @@ -1,5 +1,4 @@ use std::sync::Arc; -use std::sync::atomic::Ordering; use std::time::Duration; use super::CellId; @@ -7,7 +6,6 @@ use super::CodeModeNestedToolCall; use super::CodeModeService; use super::CodeModeSessionDelegate; use super::NotificationFuture; -use super::ObserveMode; use super::RuntimeResponse; use super::ToolInvocationFuture; use super::WaitOutcome; @@ -216,20 +214,15 @@ async fn shutdown_interrupts_cpu_bound_cells() { #[tokio::test] async fn start_cell_rejects_new_cell_after_shutdown_begins() { let service = CodeModeService::new(); - service.inner.shutting_down.store(true, Ordering::Release); + service.shutdown().await.unwrap(); let error = service - .start_cell( - cell_id("late-cell"), - execute_request(""), - ObserveMode::YieldAfter(Duration::from_millis(1)), - ) + .execute(execute_request("text('late');")) .await .err() .unwrap(); assert_eq!(error, "code mode session is shutting down".to_string()); - assert!(service.inner.cells.lock().await.is_empty()); } #[tokio::test] diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index bb09e0e437..f72aadb10b 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -1,10 +1,278 @@ mod types; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; + +use serde_json::Value as JsonValue; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + pub(crate) use self::types::CellEvent; +pub(crate) use self::types::CellId; pub(crate) use self::types::CreateCellRequest; +pub(crate) use self::types::Error; pub(crate) use self::types::ImageDetail; +pub(crate) use self::types::NestedToolCall; pub(crate) use self::types::ObserveMode; pub(crate) use self::types::OutputItem; +pub(crate) use self::types::SessionRuntimeDelegate; pub(crate) use self::types::ToolDefinition; pub(crate) use self::types::ToolKind; pub(crate) use self::types::ToolName; +use crate::cell_actor::CellActor; +use crate::cell_actor::CellError; +use crate::cell_actor::CellEventFuture; +use crate::cell_actor::CellHandle; +use crate::cell_actor::CellHost; +use crate::cell_actor::CellToolCall; + +type RuntimeEventFuture = Pin> + Send + 'static>>; + +/// Owns all cells and shared state for one transport-neutral code-mode session. +pub(crate) struct SessionRuntime { + inner: Arc>, +} + +struct Inner { + stored_values: Mutex>, + cells: Mutex>, + delegate: Arc, + shutting_down: AtomicBool, + next_cell_id: AtomicU64, +} + +impl SessionRuntime { + pub(crate) fn new(delegate: Arc) -> Self { + Self { + inner: Arc::new(Inner { + stored_values: Mutex::new(HashMap::new()), + cells: Mutex::new(HashMap::new()), + delegate, + shutting_down: AtomicBool::new(false), + next_cell_id: AtomicU64::new(1), + }), + } + } + + pub(crate) fn is_alive(&self) -> bool { + !self.inner.shutting_down.load(Ordering::Acquire) + } + + pub(crate) async fn execute( + &self, + request: CreateCellRequest, + initial_observe_mode: ObserveMode, + ) -> Result { + let cell_id = self.allocate_cell_id(); + let initial_event = self + .start_cell(cell_id.clone(), request, initial_observe_mode) + .await?; + Ok(StartedCell { + cell_id, + initial_event, + }) + } + + pub(crate) async fn observe( + &self, + cell_id: &CellId, + mode: ObserveMode, + ) -> Result { + self.begin_observe(cell_id, mode).await?.event().await + } + + pub(crate) async fn begin_observe( + &self, + cell_id: &CellId, + mode: ObserveMode, + ) -> Result { + let handle = self + .inner + .cells + .lock() + .await + .get(cell_id) + .cloned() + .ok_or_else(|| Error::MissingCell(cell_id.clone()))?; + Ok(PendingEvent { + event: map_actor_event(cell_id.clone(), handle.observe(mode)), + }) + } + + pub(crate) async fn terminate(&self, cell_id: &CellId) -> Result { + let handle = self + .inner + .cells + .lock() + .await + .get(cell_id) + .cloned() + .ok_or_else(|| Error::MissingCell(cell_id.clone()))?; + handle + .terminate() + .await + .map_err(|error| actor_error(cell_id, error)) + } + + pub(crate) async fn shutdown(&self) -> Result<(), Error> { + self.inner.shutting_down.store(true, Ordering::Release); + let handles = self + .inner + .cells + .lock() + .await + .values() + .cloned() + .collect::>(); + for handle in handles { + handle.shutdown(); + } + while !self.inner.cells.lock().await.is_empty() { + tokio::task::yield_now().await; + } + Ok(()) + } + + fn allocate_cell_id(&self) -> CellId { + CellId::new( + self.inner + .next_cell_id + .fetch_add(1, Ordering::Relaxed) + .to_string(), + ) + } + + async fn start_cell( + &self, + cell_id: CellId, + request: CreateCellRequest, + initial_observe_mode: ObserveMode, + ) -> Result { + let stored_values = self.inner.stored_values.lock().await.clone(); + let host = Arc::new(RuntimeCellHost { + cell_id: cell_id.clone(), + inner: Arc::clone(&self.inner), + }); + let mut cells = self.inner.cells.lock().await; + if self.inner.shutting_down.load(Ordering::Acquire) { + return Err(Error::ShuttingDown); + } + if cells.contains_key(&cell_id) { + return Err(Error::DuplicateCell(cell_id)); + } + let (handle, initial_event, task) = + CellActor::prepare(request, stored_values, host, initial_observe_mode) + .map_err(Error::Runtime)?; + cells.insert(cell_id.clone(), handle); + drop(cells); + tokio::spawn(task); + Ok(map_actor_event(cell_id, initial_event)) + } + + fn begin_shutdown(&self) { + self.inner.shutting_down.store(true, Ordering::Release); + if let Ok(cells) = self.inner.cells.try_lock() { + for handle in cells.values() { + handle.shutdown(); + } + } + } +} + +impl Drop for SessionRuntime { + fn drop(&mut self) { + self.begin_shutdown(); + } +} + +/// A cell admitted by [`SessionRuntime::execute`]. +pub(crate) struct StartedCell { + pub(crate) cell_id: CellId, + initial_event: RuntimeEventFuture, +} + +impl StartedCell { + pub(crate) async fn initial_event(self) -> Result { + self.initial_event.await + } +} + +/// An admitted observation that has not reached its requested frontier yet. +pub(crate) struct PendingEvent { + event: RuntimeEventFuture, +} + +impl PendingEvent { + pub(crate) async fn event(self) -> Result { + self.event.await + } +} + +struct RuntimeCellHost { + cell_id: CellId, + inner: Arc>, +} + +impl CellHost for RuntimeCellHost { + async fn invoke_tool( + &self, + invocation: CellToolCall, + cancellation_token: CancellationToken, + ) -> Result { + self.inner + .delegate + .invoke_tool( + NestedToolCall { + cell_id: self.cell_id.clone(), + runtime_tool_call_id: invocation.id, + tool_name: invocation.name, + tool_kind: invocation.kind, + input: invocation.input, + }, + cancellation_token, + ) + .await + } + + async fn notify( + &self, + call_id: String, + text: String, + cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.inner + .delegate + .notify(call_id, self.cell_id.clone(), text, cancellation_token) + .await + } + + async fn commit_stored_values(&self, stored_value_writes: HashMap) { + self.inner + .stored_values + .lock() + .await + .extend(stored_value_writes); + } + + async fn closed(&self) { + self.inner.cells.lock().await.remove(&self.cell_id); + self.inner.delegate.cell_closed(&self.cell_id); + } +} + +fn map_actor_event(cell_id: CellId, event: CellEventFuture) -> RuntimeEventFuture { + Box::pin(async move { event.await.map_err(|error| actor_error(&cell_id, error)) }) +} + +fn actor_error(cell_id: &CellId, error: CellError) -> Error { + match error { + CellError::Busy => Error::BusyObserver(cell_id.clone()), + CellError::AlreadyTerminating => Error::AlreadyTerminating(cell_id.clone()), + CellError::Closed => Error::ClosedCell(cell_id.clone()), + } +} diff --git a/codex-rs/code-mode/src/session_runtime/types.rs b/codex-rs/code-mode/src/session_runtime/types.rs index f3719c96c3..b04bd0f236 100644 --- a/codex-rs/code-mode/src/session_runtime/types.rs +++ b/codex-rs/code-mode/src/session_runtime/types.rs @@ -1,5 +1,30 @@ +use std::fmt; +use std::future::Future; use std::time::Duration; +use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; + +/// Identifies one execution cell within a session runtime. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub(crate) struct CellId(String); + +impl CellId { + pub(crate) fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub(crate) fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Display for CellId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + /// Selects the next observable frontier for a running cell. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum ObserveMode { @@ -77,3 +102,71 @@ pub(crate) enum ToolKind { Function, Freeform, } + +/// A nested tool request emitted by a running cell. +pub(crate) struct NestedToolCall { + pub(crate) cell_id: CellId, + pub(crate) runtime_tool_call_id: String, + pub(crate) tool_name: ToolName, + pub(crate) tool_kind: ToolKind, + pub(crate) input: Option, +} + +/// Host callbacks used by cells owned by a [`super::SessionRuntime`]. +/// +/// Implementations must honor cancellation tokens. `cell_closed` is called +/// after the runtime has stopped routing requests to the cell. +pub(crate) trait SessionRuntimeDelegate: Send + Sync + 'static { + fn invoke_tool( + &self, + invocation: NestedToolCall, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn notify( + &self, + call_id: String, + cell_id: CellId, + text: String, + cancellation_token: CancellationToken, + ) -> impl Future> + Send; + + fn cell_closed(&self, cell_id: &CellId); +} + +/// A failure reported by a session runtime operation. +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum Error { + ShuttingDown, + DuplicateCell(CellId), + MissingCell(CellId), + BusyObserver(CellId), + AlreadyTerminating(CellId), + ClosedCell(CellId), + Runtime(String), +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ShuttingDown => formatter.write_str("code mode session is shutting down"), + Self::DuplicateCell(cell_id) => write!(formatter, "exec cell {cell_id} already exists"), + Self::MissingCell(cell_id) => write!(formatter, "exec cell {cell_id} not found"), + Self::BusyObserver(cell_id) => { + write!( + formatter, + "exec cell {cell_id} already has an active observer" + ) + } + Self::AlreadyTerminating(cell_id) => { + write!(formatter, "exec cell {cell_id} is already terminating") + } + Self::ClosedCell(cell_id) => { + write!(formatter, "exec cell {cell_id} closed unexpectedly") + } + Self::Runtime(error_text) => formatter.write_str(error_text), + } + } +} + +impl std::error::Error for Error {} From f774455c3a831dfab2c6f37a1f624b8097f6f2c2 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 12:05:24 -0700 Subject: [PATCH 14/86] code-mode: linearize cell terminal state (#29286) ## Summary - Introduce a single cell terminal-state machine for completion and termination. - Make stored-value commits atomic with the winning terminal outcome. - Buffer terminal results for later observation and cover termination-before-commit behavior. ## Why Completion, termination, observation, and stored-value updates must agree on one linearized outcome under cancellation races. ## Impact Terminal delivery becomes deterministic and terminated cells cannot commit state after termination wins. ## Validation - Focused terminal-state regression passed. - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03b-session-runtime`. --- codex-rs/code-mode/src/cell_actor/mod.rs | 305 ++++++++++-------- codex-rs/code-mode/src/cell_actor/tests.rs | 97 +++++- codex-rs/code-mode/src/cell_actor/types.rs | 282 +++++++++++++--- codex-rs/code-mode/src/session_runtime/mod.rs | 39 ++- .../code-mode/src/session_runtime/tests.rs | 110 +++++++ 5 files changed, 640 insertions(+), 193 deletions(-) create mode 100644 codex-rs/code-mode/src/session_runtime/tests.rs diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index 6e0ffb9e0c..d288bd2dbe 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -25,7 +25,11 @@ pub(crate) use self::types::CellError; pub(crate) use self::types::CellEventFuture; pub(crate) use self::types::CellHandle; pub(crate) use self::types::CellHost; +pub(crate) use self::types::CellState; pub(crate) use self::types::CellToolCall; +pub(crate) use self::types::CompletionCommit; +use self::types::CompletionDelivery; +use self::types::ObservationDelivery; use crate::runtime::PendingRuntimeMode; use crate::runtime::RuntimeCommand; use crate::runtime::RuntimeControlCommand; @@ -34,6 +38,7 @@ use crate::runtime::spawn_runtime; use crate::session_runtime::CellEvent; use crate::session_runtime::CreateCellRequest as CellRequest; use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem; use crate::session_runtime::ToolName as CellToolName; pub(crate) struct CellActor; @@ -44,6 +49,7 @@ impl CellActor { stored_values: HashMap, host: Arc, initial_observe_mode: ObserveMode, + cell_state: Arc, ) -> Result< ( CellHandle, @@ -61,15 +67,14 @@ impl CellActor { event_tx, PendingRuntimeMode::PauseUntilResumed, )?; - let cancellation_token = CancellationToken::new(); - let handle = CellHandle::new(command_tx, cancellation_token.clone()); + let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = run_cell( host, CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token, + cell_state, }, event_rx, command_rx, @@ -88,7 +93,7 @@ struct CellContext { runtime_tx: std::sync::mpsc::Sender, runtime_control_tx: std::sync::mpsc::Sender, runtime_terminate_handle: v8::IsolateHandle, - cancellation_token: CancellationToken, + cell_state: Arc, } struct Observer { @@ -96,115 +101,98 @@ struct Observer { response_tx: oneshot::Sender>, } -struct Termination { - response_tx: Option>>, -} - async fn run_cell( host: Arc, context: CellContext, mut event_rx: mpsc::UnboundedReceiver, - mut command_rx: mpsc::UnboundedReceiver, + command_rx: mpsc::UnboundedReceiver, initial_observer: Observer, ) { let CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token, + cell_state, } = context; + let cancellation_token = cell_state.cancellation_token(); + let callback_cancellation_token = cancellation_token.child_token(); let mut content_items = Vec::new(); let mut pending_tool_call_ids = Vec::new(); - let mut completed_event = None; let mut observer = Some(initial_observer); - let mut termination: Option = None; + let mut termination = false; let mut runtime_closed = false; let mut runtime_paused = false; let mut yield_timer: Option>> = None; let mut notification_tasks = JoinSet::new(); let mut tool_tasks = JoinSet::new(); - + let mut command_rx = Some(command_rx); loop { let yield_deadline_elapsed = yield_timer .as_ref() .is_some_and(|yield_timer| yield_timer.deadline() <= tokio::time::Instant::now()); tokio::select! { biased; - maybe_command = command_rx.recv() => { - let Some(command) = maybe_command else { - if completed_event.is_some() { - break; - } - termination = Some(Termination { response_tx: None }); - begin_termination( - &runtime_tx, - &runtime_control_tx, - &runtime_terminate_handle, - &cancellation_token, + _ = cancellation_token.cancelled(), if !termination => { + termination = true; + yield_timer = None; + drop(command_rx.take()); + begin_termination( + &runtime_tx, + &runtime_control_tx, + &runtime_terminate_handle, + &cancellation_token, + ); + if runtime_closed { + finish_callbacks( + &callback_cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::Cancel, + ).await; + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), + CellEvent::Terminated { + content_items: std::mem::take(&mut content_items), + }, ); - if runtime_closed { - break; - } + break; + } + } + maybe_command = async { + match command_rx.as_mut() { + Some(command_rx) => command_rx.recv().await, + None => std::future::pending::>().await, + } + } => { + let Some(CellCommand::Observe { mode, response_tx }) = maybe_command else { + cancellation_token.cancel(); continue; }; - match command { - CellCommand::Observe { mode, response_tx } => { - if let Some(event) = completed_event.take() { - let _ = response_tx.send(Ok(event)); - break; - } - if observer.is_some() || termination.is_some() { - let _ = response_tx.send(Err(CellError::Busy)); - continue; - } - observer = Some(Observer { mode, response_tx }); - yield_timer = observer.as_ref().and_then(observer_timer); - resume_for_observation( - mode, - &mut runtime_paused, - &runtime_tx, - &runtime_control_tx, - ); - } - CellCommand::Terminate { response_tx } => { - if let Some(event) = completed_event.take() { - if let Some(response_tx) = response_tx { - let _ = response_tx.send(Ok(event)); - } - break; - } - if termination.is_some() { - if let Some(response_tx) = response_tx { - let _ = response_tx.send(Err(CellError::AlreadyTerminating)); - } - continue; - } - termination = Some(Termination { response_tx }); - yield_timer = None; - begin_termination( - &runtime_tx, - &runtime_control_tx, - &runtime_terminate_handle, - &cancellation_token, - ); - if runtime_closed { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::Cancel, - ).await; - send_termination_events( - observer.take(), - termination.take(), - CellEvent::Terminated { - content_items: std::mem::take(&mut content_items), - }, - ); - break; - } - } + let response_tx = match cell_state.route_observation(response_tx) { + ObservationDelivery::Running(response_tx) => response_tx, + ObservationDelivery::Delivered => break, + ObservationDelivery::Buffered | ObservationDelivery::Closed => continue, + }; + if observer + .as_ref() + .is_some_and(|observer| observer.response_tx.is_closed()) + { + observer = None; + yield_timer = None; } + if observer.is_some() || termination { + let _ = response_tx.send(Err(CellError::Busy)); + continue; + } + observer = Some(Observer { mode, response_tx }); + yield_timer = observer.as_ref().and_then(observer_timer); + resume_for_observation( + mode, + &mut runtime_paused, + &runtime_tx, + &runtime_control_tx, + ); } _ = async { if let Some(yield_timer) = yield_timer.as_mut() { @@ -230,38 +218,57 @@ async fn run_cell( }, if !yield_deadline_elapsed => { let Some(event) = maybe_event else { runtime_closed = true; - if termination.is_some() { + if termination || cancellation_token.is_cancelled() { finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, ).await; - send_termination_events( - observer.take(), - termination.take(), + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), CellEvent::Terminated { content_items: std::mem::take(&mut content_items), }, ); break; } - if completed_event.is_none() { - finish_callbacks( - &cancellation_token, - &mut notification_tasks, - &mut tool_tasks, - CallbackCompletion::DrainNotifications, - ).await; - let event = CellEvent::Completed { - content_items: std::mem::take(&mut content_items), - error_text: Some("exec runtime ended unexpectedly".to_string()), - }; - if send_or_buffer_completion( + finish_callbacks( + &callback_cancellation_token, + &mut notification_tasks, + &mut tool_tasks, + CallbackCompletion::DrainNotifications, + ) + .await; + let event = CellEvent::Completed { + content_items: std::mem::take(&mut content_items), + error_text: Some("exec runtime ended unexpectedly".to_string()), + }; + let rejected_event = match host + .commit_completion( + HashMap::new(), event, - &mut observer, - &mut completed_event, - ) { + Arc::clone(&cell_state), + ) + .await + { + CompletionCommit::Committed => None, + CompletionCommit::Rejected(event) => Some(event), + }; + match cell_state.deliver_completion( + observer.take().map(|observer| observer.response_tx), + ) { + CompletionDelivery::Delivered => break, + CompletionDelivery::Buffered => {} + CompletionDelivery::Rejected(response_tx) => { + finish_termination( + &cell_state, + response_tx, + CellEvent::Terminated { + content_items: rejected_completion_content(rejected_event), + }, + ); break; } } @@ -314,7 +321,7 @@ async fn run_cell( Arc::clone(&host), call_id, text, - cancellation_token.child_token(), + callback_cancellation_token.child_token(), ); } RuntimeEvent::ToolCall { id, name, kind, input } => { @@ -332,22 +339,22 @@ async fn run_cell( input, }, runtime_tx.clone(), - cancellation_token.child_token(), + callback_cancellation_token.child_token(), ); } RuntimeEvent::Result { stored_value_writes, error_text } => { runtime_closed = true; yield_timer = None; - if termination.is_some() { + if termination || cancellation_token.is_cancelled() { finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, ).await; - send_termination_events( - observer.take(), - termination.take(), + finish_termination( + &cell_state, + observer.take().map(|observer| observer.response_tx), CellEvent::Terminated { content_items: std::mem::take(&mut content_items), }, @@ -355,22 +362,42 @@ async fn run_cell( break; } finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::DrainNotifications, - ).await; - host.commit_stored_values(stored_value_writes).await; + ) + .await; let event = CellEvent::Completed { content_items: std::mem::take(&mut content_items), error_text, }; - if send_or_buffer_completion( - event, - &mut observer, - &mut completed_event, + let rejected_event = match host + .commit_completion( + stored_value_writes, + event, + Arc::clone(&cell_state), + ) + .await + { + CompletionCommit::Committed => None, + CompletionCommit::Rejected(event) => Some(event), + }; + match cell_state.deliver_completion( + observer.take().map(|observer| observer.response_tx), ) { - break; + CompletionDelivery::Delivered => break, + CompletionDelivery::Buffered => {} + CompletionDelivery::Rejected(response_tx) => { + finish_termination( + &cell_state, + response_tx, + CellEvent::Terminated { + content_items: rejected_completion_content(rejected_event), + }, + ); + break; + } } } } @@ -383,6 +410,9 @@ async fn run_cell( } } } + // Reject requests that arrive while asynchronous terminal cleanup runs. + cell_state.tombstone(); + drop(command_rx.take()); begin_termination( &runtime_tx, &runtime_control_tx, @@ -390,7 +420,7 @@ async fn run_cell( &cancellation_token, ); finish_callbacks( - &cancellation_token, + &callback_cancellation_token, &mut notification_tasks, &mut tool_tasks, CallbackCompletion::Cancel, @@ -399,34 +429,29 @@ async fn run_cell( host.closed().await; } -fn send_or_buffer_completion( - event: CellEvent, - observer: &mut Option, - completed_event: &mut Option, -) -> bool { - if observer.is_some() { - send_observer_event(observer.take(), event); - true - } else { - *completed_event = Some(event); - false - } -} - fn send_observer_event(observer: Option, event: CellEvent) { if let Some(observer) = observer { let _ = observer.response_tx.send(Ok(event)); } } -fn send_termination_events( - observer: Option, - termination: Option, +fn rejected_completion_content(event: Option) -> Vec { + match event { + Some(CellEvent::Completed { content_items, .. }) => content_items, + None => Vec::new(), + Some(event) => panic!("completion commit rejected an unexpected event: {event:?}"), + } +} + +fn finish_termination( + cell_state: &CellState, + observer_tx: Option>>, event: CellEvent, ) { - send_observer_event(observer, event.clone()); - if let Some(response_tx) = termination.and_then(|termination| termination.response_tx) { - let _ = response_tx.send(Ok(event)); + if let Some(event) = cell_state.finish_termination(event) + && let Some(observer_tx) = observer_tx + { + let _ = observer_tx.send(Ok(event)); } } diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index d7b72a1037..7404b9fe10 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -33,7 +33,14 @@ impl CellHost for TestHost { Ok(()) } - async fn commit_stored_values(&self, _stored_value_writes: HashMap) {} + async fn commit_completion( + &self, + _stored_value_writes: HashMap, + event: CellEvent, + cell_state: Arc, + ) -> CompletionCommit { + cell_state.commit_completion(event, || {}) + } async fn closed(&self) {} } @@ -64,14 +71,15 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne PendingRuntimeMode::PauseUntilResumed, ) .unwrap(); - let handle = CellHandle::new(command_tx, CancellationToken::new()); + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = tokio::spawn(run_cell( Arc::new(TestHost), CellContext { runtime_tx, runtime_control_tx, runtime_terminate_handle, - cancellation_token: CancellationToken::new(), + cell_state, }, event_rx, command_rx, @@ -142,3 +150,86 @@ async fn queued_termination_preempts_unobserved_runtime_completion() { assert_eq!(harness.initial_event_rx.await.unwrap(), terminated); harness.task.await.unwrap(); } + +#[tokio::test] +async fn only_the_first_termination_claims_a_buffered_completion() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + assert_eq!( + cell_state.commit_completion(completion.clone(), || {}), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let first_termination = cell_state.request_termination(); + assert_eq!( + cell_state.request_termination().await, + Err(CellError::AlreadyTerminating) + ); + assert_eq!(first_termination.await, Ok(completion.clone())); + assert_eq!( + cell_state.finish_termination(CellEvent::Terminated { + content_items: Vec::new(), + }), + Some(completion) + ); +} + +#[tokio::test] +async fn termination_claim_prevents_stored_value_commit() { + let cell_state = CellState::new(CancellationToken::new()); + let termination = cell_state.request_termination(); + let mut commit_ran = false; + let completion = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + + assert_eq!( + cell_state.commit_completion(completion.clone(), || commit_ran = true), + CompletionCommit::Rejected(completion) + ); + assert!(!commit_ran); + + let terminated = CellEvent::Terminated { + content_items: Vec::new(), + }; + assert_eq!( + cell_state.finish_termination(terminated.clone()), + Some(terminated.clone()) + ); + assert_eq!(termination.await, Ok(terminated)); +} + +#[test] +fn failed_completion_delivery_rebuffers_the_event() { + let cell_state = CellState::new(CancellationToken::new()); + let event = CellEvent::Completed { + content_items: Vec::new(), + error_text: None, + }; + assert_eq!( + cell_state.commit_completion(event.clone(), || {}), + CompletionCommit::Committed + ); + let (response_tx, response_rx) = oneshot::channel(); + drop(response_rx); + assert!(matches!( + cell_state.deliver_completion(Some(response_tx)), + CompletionDelivery::Buffered + )); + assert!(cell_state.accepting_observations()); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(event))); +} diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index d82f898e76..020ae3dcb1 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -2,8 +2,7 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::AtomicBool; -use std::sync::atomic::Ordering; +use std::sync::Mutex; use serde_json::Value as JsonValue; use tokio::sync::mpsc; @@ -32,10 +31,11 @@ pub(crate) struct CellToolCall { pub(crate) input: Option, } -/// Connects a cell actor to session-owned callbacks and lifecycle state. +/// Connects a cell actor to session-owned callbacks and stored values. /// -/// Implementations must honor callback cancellation and must not return from -/// `closed` until the session can no longer route requests to the cell. +/// Implementations should forward callback cancellation to downstream work. +/// Implementations must not return from `closed` until the session can no longer +/// route requests to the cell. pub(crate) trait CellHost: Send + Sync + 'static { fn invoke_tool( &self, @@ -50,10 +50,12 @@ pub(crate) trait CellHost: Send + Sync + 'static { cancellation_token: CancellationToken, ) -> impl Future> + Send; - fn commit_stored_values( + fn commit_completion( &self, stored_value_writes: HashMap, - ) -> impl Future + Send; + event: CellEvent, + cell_state: Arc, + ) -> impl Future + Send; fn closed(&self) -> impl Future + Send; } @@ -61,23 +63,21 @@ pub(crate) trait CellHost: Send + Sync + 'static { #[derive(Clone)] pub(crate) struct CellHandle { command_tx: mpsc::UnboundedSender, - cancellation_token: CancellationToken, - termination_requested: Arc, + state: Arc, } impl CellHandle { pub(super) fn new( command_tx: mpsc::UnboundedSender, - cancellation_token: CancellationToken, + state: Arc, ) -> Self { - Self { - command_tx, - cancellation_token, - termination_requested: Arc::new(AtomicBool::new(false)), - } + Self { command_tx, state } } pub(crate) fn observe(&self, mode: ObserveMode) -> CellEventFuture { + if !self.state.accepting_observations() { + return closed_event(); + } let (response_tx, response_rx) = oneshot::channel(); if self .command_tx @@ -90,33 +90,232 @@ impl CellHandle { } pub(crate) fn terminate(&self) -> CellEventFuture { - if self - .termination_requested - .compare_exchange(false, true, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - return Box::pin(async { Err(CellError::AlreadyTerminating) }); - } - let (response_tx, response_rx) = oneshot::channel(); - if self - .command_tx - .send(CellCommand::Terminate { - response_tx: Some(response_tx), - }) - .is_err() - { - self.termination_requested.store(false, Ordering::Relaxed); - return closed_event(); - } - response_event(response_rx) + self.state.request_termination() } pub(crate) fn shutdown(&self) { - self.termination_requested.store(true, Ordering::Relaxed); + self.state.cancellation_token().cancel(); + } +} + +/// The single linearization point for a cell's terminal outcome. +/// +/// Callback cancellation tokens are children of the cell token, so a terminal +/// decision cancels runtime work and its callbacks together. +/// +/// The mutex is held only for synchronous phase transitions and terminal +/// delivery. Runtime execution, observation waits, and callbacks never run +/// while it is held. +pub(crate) struct CellState { + phase: Mutex, + cancellation_token: CancellationToken, +} + +enum CellPhase { + Running, + Terminating { + response_tx: oneshot::Sender>, + }, + Completed(CellEvent), + CompletionClaimed(CellEvent), + Tombstone, +} + +pub(crate) enum CompletionDelivery { + Delivered, + Buffered, + Rejected(Option>>), +} + +/// Result of atomically publishing a completed cell and its session side effects. +#[derive(Debug, PartialEq)] +pub(crate) enum CompletionCommit { + Committed, + Rejected(CellEvent), +} + +pub(crate) enum ObservationDelivery { + Running(oneshot::Sender>), + Delivered, + Buffered, + Closed, +} + +impl CellState { + pub(crate) fn new(cancellation_token: CancellationToken) -> Self { + Self { + phase: Mutex::new(CellPhase::Running), + cancellation_token, + } + } + + pub(crate) fn accepting_observations(&self) -> bool { + let accepting_phase = matches!( + *self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner), + CellPhase::Running | CellPhase::Completed(_) + ); + accepting_phase && !self.cancellation_token.is_cancelled() + } + + pub(crate) fn request_termination(&self) -> CellEventFuture { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => { + let (response_tx, response_rx) = oneshot::channel(); + *phase = CellPhase::Terminating { response_tx }; + self.cancellation_token.cancel(); + response_event(response_rx) + } + CellPhase::Terminating { response_tx } => { + *phase = CellPhase::Terminating { response_tx }; + Box::pin(async { Err(CellError::AlreadyTerminating) }) + } + CellPhase::Completed(event) => { + *phase = CellPhase::CompletionClaimed(event.clone()); + self.cancellation_token.cancel(); + ready_event(event) + } + CellPhase::CompletionClaimed(event) => { + *phase = CellPhase::CompletionClaimed(event); + Box::pin(async { Err(CellError::AlreadyTerminating) }) + } + CellPhase::Tombstone => closed_event(), + } + } + + pub(crate) fn commit_completion( + &self, + event: CellEvent, + commit: impl FnOnce(), + ) -> CompletionCommit { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if !matches!(*phase, CellPhase::Running) || self.cancellation_token.is_cancelled() { + return CompletionCommit::Rejected(event); + } + commit(); + *phase = CellPhase::Completed(event); + CompletionCommit::Committed + } + + pub(crate) fn deliver_completion( + &self, + response_tx: Option>>, + ) -> CompletionDelivery { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Completed(event) => event, + previous => { + *phase = previous; + return CompletionDelivery::Rejected(response_tx); + } + }; + let Some(response_tx) = response_tx else { + *phase = CellPhase::Completed(event); + return CompletionDelivery::Buffered; + }; + match response_tx.send(Ok(event)) { + Ok(()) => { + self.cancellation_token.cancel(); + CompletionDelivery::Delivered + } + Err(Ok(event)) => { + *phase = CellPhase::Completed(event); + CompletionDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } + } + } + + pub(crate) fn route_observation( + &self, + response_tx: oneshot::Sender>, + ) -> ObservationDelivery { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => { + *phase = CellPhase::Running; + ObservationDelivery::Running(response_tx) + } + CellPhase::Completed(event) => match response_tx.send(Ok(event)) { + Ok(()) => { + self.cancellation_token.cancel(); + ObservationDelivery::Delivered + } + Err(Ok(event)) => { + *phase = CellPhase::Completed(event); + ObservationDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } + }, + CellPhase::Terminating { + response_tx: termination_tx, + } => { + *phase = CellPhase::Terminating { + response_tx: termination_tx, + }; + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + CellPhase::CompletionClaimed(event) => { + *phase = CellPhase::CompletionClaimed(event); + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + CellPhase::Tombstone => { + let _ = response_tx.send(Err(CellError::Closed)); + ObservationDelivery::Closed + } + } + } + + pub(crate) fn finish_termination(&self, event: CellEvent) -> Option { + let mut phase = self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let observer_event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Running => Some(event), + CellPhase::Terminating { response_tx } => { + let _ = response_tx.send(Ok(event.clone())); + Some(event) + } + CellPhase::Completed(completed_event) => Some(completed_event), + CellPhase::CompletionClaimed(completed_event) => Some(completed_event), + CellPhase::Tombstone => None, + }; self.cancellation_token.cancel(); - let _ = self - .command_tx - .send(CellCommand::Terminate { response_tx: None }); + observer_event + } + + pub(crate) fn tombstone(&self) { + *self + .phase + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = CellPhase::Tombstone; + self.cancellation_token.cancel(); + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + self.cancellation_token.clone() } } @@ -125,15 +324,16 @@ pub(super) enum CellCommand { mode: ObserveMode, response_tx: oneshot::Sender>, }, - Terminate { - response_tx: Option>>, - }, } fn response_event(response_rx: oneshot::Receiver>) -> CellEventFuture { Box::pin(async move { response_rx.await.unwrap_or(Err(CellError::Closed)) }) } +fn ready_event(event: CellEvent) -> CellEventFuture { + Box::pin(async move { Ok(event) }) +} + fn closed_event() -> CellEventFuture { Box::pin(async { Err(CellError::Closed) }) } diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index f72aadb10b..362dccda74 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -29,7 +29,9 @@ use crate::cell_actor::CellError; use crate::cell_actor::CellEventFuture; use crate::cell_actor::CellHandle; use crate::cell_actor::CellHost; +use crate::cell_actor::CellState; use crate::cell_actor::CellToolCall; +use crate::cell_actor::CompletionCommit; type RuntimeEventFuture = Pin> + Send + 'static>>; @@ -165,9 +167,15 @@ impl SessionRuntime { if cells.contains_key(&cell_id) { return Err(Error::DuplicateCell(cell_id)); } - let (handle, initial_event, task) = - CellActor::prepare(request, stored_values, host, initial_observe_mode) - .map_err(Error::Runtime)?; + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let (handle, initial_event, task) = CellActor::prepare( + request, + stored_values, + host, + initial_observe_mode, + cell_state, + ) + .map_err(Error::Runtime)?; cells.insert(cell_id.clone(), handle); drop(cells); tokio::spawn(task); @@ -251,12 +259,21 @@ impl CellHost for RuntimeCellHost { .await } - async fn commit_stored_values(&self, stored_value_writes: HashMap) { - self.inner - .stored_values - .lock() - .await - .extend(stored_value_writes); + async fn commit_completion( + &self, + stored_value_writes: HashMap, + event: CellEvent, + cell_state: Arc, + ) -> CompletionCommit { + let cancellation_token = cell_state.cancellation_token(); + let mut stored_values = tokio::select! { + biased; + _ = cancellation_token.cancelled() => { + return CompletionCommit::Rejected(event); + } + stored_values = self.inner.stored_values.lock() => stored_values, + }; + cell_state.commit_completion(event, || stored_values.extend(stored_value_writes)) } async fn closed(&self) { @@ -276,3 +293,7 @@ fn actor_error(cell_id: &CellId, error: CellError) -> Error { CellError::Closed => Error::ClosedCell(cell_id.clone()), } } + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode/src/session_runtime/tests.rs new file mode 100644 index 0000000000..b4457b3f7b --- /dev/null +++ b/codex-rs/code-mode/src/session_runtime/tests.rs @@ -0,0 +1,110 @@ +use std::collections::HashMap; +use std::future::Future; +use std::sync::Arc; +use std::task::Context; +use std::task::Poll; +use std::task::Waker; +use std::time::Duration; + +use pretty_assertions::assert_eq; +use serde_json::Value as JsonValue; +use tokio_util::sync::CancellationToken; + +use super::*; +use crate::cell_actor::CompletionCommit; + +struct RecordingDelegate; + +impl SessionRuntimeDelegate for RecordingDelegate { + async fn invoke_tool( + &self, + _invocation: NestedToolCall, + _cancellation_token: CancellationToken, + ) -> Result { + Ok(JsonValue::Null) + } + + async fn notify( + &self, + _call_id: String, + _cell_id: CellId, + _text: String, + _cancellation_token: CancellationToken, + ) -> Result<(), String> { + Ok(()) + } + + fn cell_closed(&self, _cell_id: &CellId) {} +} + +#[tokio::test] +async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_load_it() { + let runtime = SessionRuntime::new(Arc::new(RecordingDelegate)); + let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let host = RuntimeCellHost { + cell_id: CellId::new("terminating-writer"), + inner: Arc::clone(&runtime.inner), + }; + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "uncommitted output".to_string(), + }], + error_text: None, + }; + + let stored_values = runtime.inner.stored_values.lock().await; + let commit = host.commit_completion( + HashMap::from([( + "candidate".to_string(), + JsonValue::String("lost".to_string()), + )]), + completion.clone(), + Arc::clone(&cell_state), + ); + tokio::pin!(commit); + let waker = Waker::noop(); + let mut context = Context::from_waker(waker); + assert!(matches!(commit.as_mut().poll(&mut context), Poll::Pending)); + + let termination = cell_state.request_termination(); + drop(stored_values); + assert_eq!(commit.await, CompletionCommit::Rejected(completion)); + let terminated = CellEvent::Terminated { + content_items: Vec::new(), + }; + assert_eq!( + cell_state.finish_termination(terminated.clone()), + Some(terminated.clone()) + ); + assert_eq!(termination.await, Ok(terminated)); + assert!( + !runtime + .inner + .stored_values + .lock() + .await + .contains_key("candidate") + ); + + let reader = runtime + .execute( + CreateCellRequest { + tool_call_id: "reader".to_string(), + enabled_tools: Vec::new(), + source: r#"text(String(load("candidate")));"#.to_string(), + }, + ObserveMode::YieldAfter(Duration::from_secs(1)), + ) + .await + .unwrap(); + assert_eq!( + reader.initial_event().await, + Ok(CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "undefined".to_string(), + }], + error_text: None, + }) + ); + runtime.shutdown().await.unwrap(); +} From 9c79d87d0606f1076eb309a609be2bac22cec968 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 13:15:38 -0700 Subject: [PATCH 15/86] code-mode: make session shutdown authoritative (#29287) ## Summary - Give each session and cell a hierarchical cancellation token. - Track cell tasks so shutdown waits for admitted actors without polling the registry. - Make shutdown authoritative across concurrent admission and non-cooperative callbacks. ## Why A best-effort registry scan can miss cells admitted concurrently or blocked behind the registry lock. ## Impact Session shutdown reliably stops every admitted cell and rejects new work once shutdown begins. ## Validation - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03c-terminal-state`. --- codex-rs/code-mode/src/cell_actor/types.rs | 9 +-- codex-rs/code-mode/src/runtime/mod.rs | 2 +- .../code-mode/src/service_contract_tests.rs | 5 +- codex-rs/code-mode/src/service_tests.rs | 2 +- codex-rs/code-mode/src/session_runtime/mod.rs | 49 +++++------- .../code-mode/src/session_runtime/tests.rs | 77 +++++++++++++++++++ 6 files changed, 107 insertions(+), 37 deletions(-) diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index 020ae3dcb1..ee345d6ab7 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -92,16 +92,13 @@ impl CellHandle { pub(crate) fn terminate(&self) -> CellEventFuture { self.state.request_termination() } - - pub(crate) fn shutdown(&self) { - self.state.cancellation_token().cancel(); - } } /// The single linearization point for a cell's terminal outcome. /// -/// Callback cancellation tokens are children of the cell token, so a terminal -/// decision cancels runtime work and its callbacks together. +/// The cancellation token is a child of the owning session token. Callback +/// tokens are children of this token, so cancellation flows strictly from the +/// session to the cell and then to its callbacks. /// /// The mutex is held only for synchronous phase transitions and terminal /// delivery. Runtime execution, observation waits, and callbacks never run diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index bfc54f71be..42af5c0b95 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -420,7 +420,7 @@ await new Promise(() => {}); .send(RuntimeCommand::TimeoutFired { id: 1 }) .unwrap(); assert!( - tokio::time::timeout(Duration::from_millis(100), event_rx.recv()) + tokio::time::timeout(Duration::from_secs(1), event_rx.recv()) .await .is_err() ); diff --git a/codex-rs/code-mode/src/service_contract_tests.rs b/codex-rs/code-mode/src/service_contract_tests.rs index 9484310323..8ead39640a 100644 --- a/codex-rs/code-mode/src/service_contract_tests.rs +++ b/codex-rs/code-mode/src/service_contract_tests.rs @@ -289,7 +289,10 @@ async fn observed_natural_completion_wins_over_termination() { tokio::time::timeout(Duration::from_secs(1), async { loop { let response = service - .execute(execute_request(r#"text(String(load("finished")));"#)) + .execute(ExecuteRequest { + yield_time_ms: Some(60_000), + ..execute_request(r#"text(String(load("finished")));"#) + }) .await .unwrap() .initial_response() diff --git a/codex-rs/code-mode/src/service_tests.rs b/codex-rs/code-mode/src/service_tests.rs index d90d4b6afb..6344bfdab9 100644 --- a/codex-rs/code-mode/src/service_tests.rs +++ b/codex-rs/code-mode/src/service_tests.rs @@ -360,7 +360,7 @@ await Promise.all([ } ); - tokio::time::sleep(Duration::from_millis(1100)).await; + tokio::time::sleep(Duration::from_secs(2)).await; let resumed_response = tokio::time::timeout( Duration::from_secs(1), diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index 362dccda74..71cf3d6137 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -4,13 +4,13 @@ use std::collections::HashMap; use std::future::Future; use std::pin::Pin; use std::sync::Arc; -use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use serde_json::Value as JsonValue; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; +use tokio_util::task::TaskTracker; pub(crate) use self::types::CellEvent; pub(crate) use self::types::CellId; @@ -43,8 +43,9 @@ pub(crate) struct SessionRuntime { struct Inner { stored_values: Mutex>, cells: Mutex>, + cell_tasks: TaskTracker, + shutdown_token: CancellationToken, delegate: Arc, - shutting_down: AtomicBool, next_cell_id: AtomicU64, } @@ -54,15 +55,16 @@ impl SessionRuntime { inner: Arc::new(Inner { stored_values: Mutex::new(HashMap::new()), cells: Mutex::new(HashMap::new()), + cell_tasks: TaskTracker::new(), + shutdown_token: CancellationToken::new(), delegate, - shutting_down: AtomicBool::new(false), next_cell_id: AtomicU64::new(1), }), } } pub(crate) fn is_alive(&self) -> bool { - !self.inner.shutting_down.load(Ordering::Acquire) + !self.inner.shutdown_token.is_cancelled() } pub(crate) async fn execute( @@ -70,6 +72,9 @@ impl SessionRuntime { request: CreateCellRequest, initial_observe_mode: ObserveMode, ) -> Result { + if self.inner.shutdown_token.is_cancelled() { + return Err(Error::ShuttingDown); + } let cell_id = self.allocate_cell_id(); let initial_event = self .start_cell(cell_id.clone(), request, initial_observe_mode) @@ -122,21 +127,13 @@ impl SessionRuntime { } pub(crate) async fn shutdown(&self) -> Result<(), Error> { - self.inner.shutting_down.store(true, Ordering::Release); - let handles = self - .inner - .cells - .lock() - .await - .values() - .cloned() - .collect::>(); - for handle in handles { - handle.shutdown(); - } - while !self.inner.cells.lock().await.is_empty() { - tokio::task::yield_now().await; - } + self.begin_shutdown(); + // Taking the registry lock ensures every cell that passed the shutdown + // check has registered its actor with the tracker before we wait. + let cells = self.inner.cells.lock().await; + self.inner.cell_tasks.close(); + drop(cells); + self.inner.cell_tasks.wait().await; Ok(()) } @@ -161,13 +158,13 @@ impl SessionRuntime { inner: Arc::clone(&self.inner), }); let mut cells = self.inner.cells.lock().await; - if self.inner.shutting_down.load(Ordering::Acquire) { + if self.inner.shutdown_token.is_cancelled() { return Err(Error::ShuttingDown); } if cells.contains_key(&cell_id) { return Err(Error::DuplicateCell(cell_id)); } - let cell_state = Arc::new(CellState::new(CancellationToken::new())); + let cell_state = Arc::new(CellState::new(self.inner.shutdown_token.child_token())); let (handle, initial_event, task) = CellActor::prepare( request, stored_values, @@ -177,18 +174,14 @@ impl SessionRuntime { ) .map_err(Error::Runtime)?; cells.insert(cell_id.clone(), handle); + self.inner.cell_tasks.spawn(task); drop(cells); - tokio::spawn(task); Ok(map_actor_event(cell_id, initial_event)) } fn begin_shutdown(&self) { - self.inner.shutting_down.store(true, Ordering::Release); - if let Ok(cells) = self.inner.cells.try_lock() { - for handle in cells.values() { - handle.shutdown(); - } - } + self.inner.shutdown_token.cancel(); + self.inner.cell_tasks.close(); } } diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode/src/session_runtime/tests.rs index b4457b3f7b..a1dc18c0e8 100644 --- a/codex-rs/code-mode/src/session_runtime/tests.rs +++ b/codex-rs/code-mode/src/session_runtime/tests.rs @@ -108,3 +108,80 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa ); runtime.shutdown().await.unwrap(); } + +fn execute_request(source: &str) -> CreateCellRequest { + CreateCellRequest { + tool_call_id: "call-1".to_string(), + enabled_tools: Vec::new(), + source: source.to_string(), + } +} + +#[tokio::test] +#[expect( + clippy::await_holding_invalid_type, + reason = "test holds the registry lock to force admission ahead of shutdown" +)] +async fn shutdown_rejects_cell_admission_queued_before_the_registry_lock() { + let runtime = Arc::new(SessionRuntime::new(Arc::new(RecordingDelegate))); + let cells = runtime.inner.cells.lock().await; + + let execution = runtime.execute( + execute_request("while (true) {}"), + ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)), + ); + tokio::pin!(execution); + std::future::poll_fn(|context| match execution.as_mut().poll(context) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(Ok(_)) => panic!("execution completed before the registry lock was released"), + Poll::Ready(Err(error)) => { + panic!("execution failed before the registry lock was released: {error}") + } + }) + .await; + + let shutdown = runtime.shutdown(); + tokio::pin!(shutdown); + std::future::poll_fn(|context| match shutdown.as_mut().poll(context) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(Ok(())) => panic!("shutdown completed before acquiring the registry lock"), + Poll::Ready(Err(error)) => { + panic!("shutdown failed before acquiring the registry lock: {error}") + } + }) + .await; + + assert!(!runtime.is_alive()); + drop(cells); + assert!(matches!(execution.await, Err(Error::ShuttingDown))); + assert_eq!(shutdown.await, Ok(())); +} + +#[tokio::test] +async fn drop_terminates_cells_when_the_registry_is_locked() { + let runtime = SessionRuntime::new(Arc::new(RecordingDelegate)); + let started = runtime + .execute( + execute_request("while (true) {}"), + ObserveMode::YieldAfter(Duration::from_millis(/*millis*/ 1)), + ) + .await + .unwrap(); + assert_eq!(started.cell_id, CellId::new("1")); + assert_eq!( + started.initial_event().await, + Ok(CellEvent::Yielded { + content_items: Vec::new(), + }) + ); + + let inner = Arc::clone(&runtime.inner); + let cells = inner.cells.lock().await; + drop(runtime); + drop(cells); + + tokio::time::timeout(Duration::from_secs(/*secs*/ 1), inner.cell_tasks.wait()) + .await + .unwrap(); + assert!(inner.cell_tasks.is_empty()); +} From 6bfc58a688a73c287da51797d99c3345caecbae8 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 21 Jun 2026 13:35:25 -0700 Subject: [PATCH 16/86] [prompting] updated plan mode prompt (#29301) Update plan mode prompt to render the implementation plan to the user on relevant follow-ups, such that the user can exit out of plan mode to implement rather than manually switch of plan mode. --- codex-rs/collaboration-mode-templates/templates/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/collaboration-mode-templates/templates/plan.md b/codex-rs/collaboration-mode-templates/templates/plan.md index 8a1a934f83..ca68f41c89 100644 --- a/codex-rs/collaboration-mode-templates/templates/plan.md +++ b/codex-rs/collaboration-mode-templates/templates/plan.md @@ -125,4 +125,4 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o Only produce at most one `` block per turn, and only when you are presenting a complete spec. -If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. +If the user stays in Plan mode and asks for revisions after a prior ``, any new `` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a `` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior `` unchanged. From 3b605b9c63f9123b39493b24d95d9a8428813e9f Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 13:53:37 -0700 Subject: [PATCH 17/86] code-mode: preserve dropped observation output (#29288) ## Summary - Restore yielded output when an observation receiver disappears before delivery. - Preserve pending-frontier output and tool IDs across failed delivery. - Add dropped-observer coverage for yield and pending observations. ## Why Canceling a wait must not consume output or a pending frontier that the caller never received. ## Impact A later observation can recover undelivered incremental output without duplication. ## Validation - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03e-shutdown-hierarchy`. --- codex-rs/code-mode/src/cell_actor/mod.rs | 112 ++++++++-- codex-rs/code-mode/src/cell_actor/tests.rs | 246 ++++++++++++++++++++- 2 files changed, 337 insertions(+), 21 deletions(-) diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index d288bd2dbe..8379f372d8 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -118,6 +118,7 @@ async fn run_cell( let callback_cancellation_token = cancellation_token.child_token(); let mut content_items = Vec::new(); let mut pending_tool_call_ids = Vec::new(); + let mut pending_frontier_ready = false; let mut observer = Some(initial_observer); let mut termination = false; let mut runtime_closed = false; @@ -169,6 +170,9 @@ async fn run_cell( cancellation_token.cancel(); continue; }; + if response_tx.is_closed() { + continue; + } let response_tx = match cell_state.route_observation(response_tx) { ObservationDelivery::Running(response_tx) => response_tx, ObservationDelivery::Delivered => break, @@ -185,8 +189,36 @@ async fn run_cell( let _ = response_tx.send(Err(CellError::Busy)); continue; } + if matches!(mode, ObserveMode::PendingFrontier) && pending_frontier_ready { + pending_frontier_ready = false; + match send_cell_event( + response_tx, + CellEvent::Pending { + content_items: std::mem::take(&mut content_items), + pending_tool_call_ids: std::mem::take(&mut pending_tool_call_ids), + }, + ) { + Ok(()) => {} + Err(CellEvent::Pending { + content_items: undelivered_items, + pending_tool_call_ids: undelivered_tool_call_ids, + }) => { + content_items = undelivered_items; + pending_tool_call_ids = undelivered_tool_call_ids; + pending_frontier_ready = true; + } + Err(event) => { + panic!("pending delivery returned an unexpected event: {event:?}") + } + } + continue; + } observer = Some(Observer { mode, response_tx }); yield_timer = observer.as_ref().and_then(observer_timer); + if runtime_paused && matches!(mode, ObserveMode::YieldAfter(_)) { + pending_frontier_ready = false; + pending_tool_call_ids.clear(); + } resume_for_observation( mode, &mut runtime_paused, @@ -202,11 +234,14 @@ async fn run_cell( } } => { yield_timer = None; - send_observer_event( - observer.take(), - CellEvent::Yielded { - content_items: std::mem::take(&mut content_items), - }, + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, ); } maybe_event = async { @@ -285,7 +320,8 @@ async fn run_cell( Some(ObserveMode::PendingFrontier) ) { yield_timer = None; - send_observer_event( + pending_frontier_ready = false; + match send_observer_event( observer.take(), CellEvent::Pending { content_items: std::mem::take(&mut content_items), @@ -293,7 +329,20 @@ async fn run_cell( &mut pending_tool_call_ids, ), }, - ); + ) { + Ok(()) => {} + Err(CellEvent::Pending { + content_items: undelivered_items, + pending_tool_call_ids: undelivered_tool_call_ids, + }) => { + content_items = undelivered_items; + pending_tool_call_ids = undelivered_tool_call_ids; + pending_frontier_ready = true; + } + Err(event) => { + panic!("pending delivery returned an unexpected event: {event:?}") + } + } } else { pending_tool_call_ids.clear(); let _ = runtime_control_tx.send(RuntimeControlCommand::Continue); @@ -302,16 +351,20 @@ async fn run_cell( } RuntimeEvent::ContentItem(item) => content_items.push(output_item(item)), RuntimeEvent::YieldRequested => { - if matches!( + let yield_observer = matches!( observer.as_ref().map(|observer| observer.mode), Some(ObserveMode::YieldAfter(_)) - ) { + ); + if yield_observer { yield_timer = None; - send_observer_event( - observer.take(), - CellEvent::Yielded { - content_items: std::mem::take(&mut content_items), - }, + restore_undelivered_yield( + send_observer_event( + observer.take(), + CellEvent::Yielded { + content_items: std::mem::take(&mut content_items), + }, + ), + &mut content_items, ); } } @@ -429,9 +482,34 @@ async fn run_cell( host.closed().await; } -fn send_observer_event(observer: Option, event: CellEvent) { - if let Some(observer) = observer { - let _ = observer.response_tx.send(Ok(event)); +fn send_observer_event(observer: Option, event: CellEvent) -> Result<(), CellEvent> { + let Some(observer) = observer else { + return Err(event); + }; + send_cell_event(observer.response_tx, event) +} + +fn send_cell_event( + response_tx: oneshot::Sender>, + event: CellEvent, +) -> Result<(), CellEvent> { + match response_tx.send(Ok(event)) { + Ok(()) => Ok(()), + Err(Ok(event)) => Err(event), + Err(Err(error)) => panic!("cell event delivery returned an actor error: {error:?}"), + } +} + +fn restore_undelivered_yield(delivery: Result<(), CellEvent>, content_items: &mut Vec) { + match delivery { + Ok(()) => {} + Err(CellEvent::Yielded { + content_items: mut undelivered_items, + }) => { + undelivered_items.append(content_items); + *content_items = undelivered_items; + } + Err(event) => panic!("yield delivery returned an unexpected event: {event:?}"), } } diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index 7404b9fe10..e217122b27 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -1,5 +1,8 @@ use std::collections::HashMap; use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; +use std::sync::mpsc as std_mpsc; use std::time::Duration; use codex_code_mode_protocol::ExecuteRequest; @@ -11,10 +14,15 @@ use tokio::sync::oneshot; use tokio_util::sync::CancellationToken; use super::*; -use crate::session_runtime::OutputItem as CellOutputItem; +use crate::session_runtime::OutputItem; struct TestHost; +#[derive(Default)] +struct RecordingHost { + notified: AtomicBool, +} + impl CellHost for TestHost { async fn invoke_tool( &self, @@ -45,20 +53,59 @@ impl CellHost for TestHost { async fn closed(&self) {} } +impl CellHost for RecordingHost { + async fn invoke_tool( + &self, + _invocation: CellToolCall, + _cancellation_token: CancellationToken, + ) -> Result { + Err("unexpected tool call".to_string()) + } + + async fn notify( + &self, + _call_id: String, + _text: String, + _cancellation_token: CancellationToken, + ) -> Result<(), String> { + self.notified.store(true, Ordering::Release); + Ok(()) + } + + async fn commit_completion( + &self, + _stored_value_writes: HashMap, + event: CellEvent, + cell_state: Arc, + ) -> CompletionCommit { + cell_state.commit_completion(event, || {}) + } + + async fn closed(&self) {} +} + struct CellActorHarness { event_tx: mpsc::UnboundedSender, handle: CellHandle, initial_event_rx: oneshot::Receiver>, task: tokio::task::JoinHandle<()>, + runtime_control_rx: std_mpsc::Receiver, _runtime_event_rx: mpsc::UnboundedReceiver, } fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarness { + spawn_cell_actor_harness_with_host(initial_observe_mode, Arc::new(TestHost)) +} + +fn spawn_cell_actor_harness_with_host( + initial_observe_mode: ObserveMode, + host: Arc, +) -> CellActorHarness { let (event_tx, event_rx) = mpsc::unbounded_channel(); let (command_tx, command_rx) = mpsc::unbounded_channel(); let (initial_event_tx, initial_event_rx) = oneshot::channel(); let (runtime_event_tx, runtime_event_rx) = mpsc::unbounded_channel(); - let (runtime_tx, runtime_control_tx, runtime_terminate_handle) = spawn_runtime( + let (runtime_tx, _runtime_control_tx, runtime_terminate_handle) = spawn_runtime( HashMap::new(), ExecuteRequest { tool_call_id: "call-1".to_string(), @@ -71,10 +118,11 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne PendingRuntimeMode::PauseUntilResumed, ) .unwrap(); + let (runtime_control_tx, runtime_control_rx) = std_mpsc::channel(); let cell_state = Arc::new(CellState::new(CancellationToken::new())); let handle = CellHandle::new(command_tx, Arc::clone(&cell_state)); let task = tokio::spawn(run_cell( - Arc::new(TestHost), + host, CellContext { runtime_tx, runtime_control_tx, @@ -94,10 +142,21 @@ fn spawn_cell_actor_harness(initial_observe_mode: ObserveMode) -> CellActorHarne handle, initial_event_rx, task, + runtime_control_rx, _runtime_event_rx: runtime_event_rx, } } +async fn wait_for_notification(host: &RecordingHost) { + tokio::time::timeout(Duration::from_secs(1), async { + while !host.notified.load(Ordering::Acquire) { + tokio::task::yield_now().await; + } + }) + .await + .expect("notification barrier timed out"); +} + #[tokio::test] async fn yield_timer_preempts_buffered_runtime_output() { let harness = spawn_cell_actor_harness(ObserveMode::YieldAfter(Duration::ZERO)); @@ -123,7 +182,7 @@ async fn yield_timer_preempts_buffered_runtime_output() { assert_eq!( termination.await, Ok(CellEvent::Terminated { - content_items: vec![CellOutputItem::Text { + content_items: vec![OutputItem::Text { text: "queued output".to_string(), }], }) @@ -151,6 +210,185 @@ async fn queued_termination_preempts_unobserved_runtime_completion() { harness.task.await.unwrap(); } +#[tokio::test] +async fn observation_dropped_before_dequeue_does_not_consume_output() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + drop( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::from_secs(60))), + ); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "survives pre-dequeue cancellation".to_string(), + }, + )) + .unwrap(); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-command".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "survives pre-dequeue cancellation".to_string(), + }], + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn dropped_yield_observer_preserves_output_for_the_next_observation() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + let dropped_observation = harness + .handle + .observe(ObserveMode::YieldAfter(Duration::from_secs(60))); + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Err(CellError::Busy) + ); + drop(dropped_observation); + harness + .event_tx + .send(RuntimeEvent::ContentItem( + FunctionCallOutputContentItem::InputText { + text: "survives active cancellation".to_string(), + }, + )) + .unwrap(); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-observer".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness + .handle + .observe(ObserveMode::YieldAfter(Duration::ZERO)) + .await, + Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "survives active cancellation".to_string(), + }], + }) + ); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + +#[tokio::test] +async fn dropped_pending_observer_preserves_the_frontier_for_the_next_observation() { + let host = Arc::new(RecordingHost::default()); + let harness = spawn_cell_actor_harness_with_host( + ObserveMode::YieldAfter(Duration::from_secs(60)), + Arc::clone(&host), + ); + harness.event_tx.send(RuntimeEvent::YieldRequested).unwrap(); + assert!(harness.initial_event_rx.await.unwrap().is_ok()); + + let dropped_observation = harness.handle.observe(ObserveMode::PendingFrontier); + assert_eq!( + harness.handle.observe(ObserveMode::PendingFrontier).await, + Err(CellError::Busy) + ); + drop(dropped_observation); + harness + .event_tx + .send(RuntimeEvent::ToolCall { + id: "tool-1".to_string(), + name: codex_protocol::ToolName { + name: "echo".to_string(), + namespace: None, + }, + kind: codex_code_mode_protocol::CodeModeToolKind::Function, + input: Some(serde_json::json!({})), + }) + .unwrap(); + harness.event_tx.send(RuntimeEvent::Pending).unwrap(); + harness + .event_tx + .send(RuntimeEvent::Notify { + call_id: "after-dropped-pending".to_string(), + text: "barrier".to_string(), + }) + .unwrap(); + wait_for_notification(&host).await; + + assert_eq!( + harness.handle.observe(ObserveMode::PendingFrontier).await, + Ok(CellEvent::Pending { + content_items: Vec::new(), + pending_tool_call_ids: vec!["tool-1".to_string()], + }) + ); + assert!(matches!( + harness.runtime_control_rx.try_recv(), + Err(std_mpsc::TryRecvError::Empty) + )); + + let termination = harness.handle.terminate(); + drop(harness.event_tx); + assert_eq!( + termination.await, + Ok(CellEvent::Terminated { + content_items: Vec::new(), + }) + ); + harness.task.await.unwrap(); +} + #[tokio::test] async fn only_the_first_termination_claims_a_buffered_completion() { let cell_state = CellState::new(CancellationToken::new()); From eb8c1ee85f18d055d00b3a0912dd0e55e62a82e8 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Sun, 21 Jun 2026 15:35:01 -0700 Subject: [PATCH 18/86] code-mode: preserve initial yield at completion (#29289) ## Summary - Retain the first pre-observation `yield_control()` boundary when a cell completes before observation. - Deliver the preserved yield before the buffered completion. - Keep later unattached yields as no-ops. ## Why Create followed by the initial wait must preserve the former execute response boundary even when the script runs to completion first. ## Impact The first wait observes the same initial yield boundary as before create and observe were decoupled. ## Validation - Focused initial-yield signature regression passed. - Stack-tip validation: `just test -p codex-code-mode -p codex-code-mode-protocol` (70 passed). - Parent branch: `cconger/code-mode-runtime-compact-03e2-observation-delivery`. --- codex-rs/code-mode/src/cell_actor/mod.rs | 4 +- codex-rs/code-mode/src/cell_actor/tests.rs | 169 +++++++++++++++++- codex-rs/code-mode/src/cell_actor/types.rs | 158 +++++++++++++--- codex-rs/code-mode/src/session_runtime/mod.rs | 5 +- .../code-mode/src/session_runtime/tests.rs | 1 + 5 files changed, 304 insertions(+), 33 deletions(-) diff --git a/codex-rs/code-mode/src/cell_actor/mod.rs b/codex-rs/code-mode/src/cell_actor/mod.rs index 8379f372d8..24eca22517 100644 --- a/codex-rs/code-mode/src/cell_actor/mod.rs +++ b/codex-rs/code-mode/src/cell_actor/mod.rs @@ -173,7 +173,7 @@ async fn run_cell( if response_tx.is_closed() { continue; } - let response_tx = match cell_state.route_observation(response_tx) { + let response_tx = match cell_state.route_observation(mode, response_tx) { ObservationDelivery::Running(response_tx) => response_tx, ObservationDelivery::Delivered => break, ObservationDelivery::Buffered | ObservationDelivery::Closed => continue, @@ -284,6 +284,7 @@ async fn run_cell( .commit_completion( HashMap::new(), event, + /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ) .await @@ -429,6 +430,7 @@ async fn run_cell( .commit_completion( stored_value_writes, event, + /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ) .await diff --git a/codex-rs/code-mode/src/cell_actor/tests.rs b/codex-rs/code-mode/src/cell_actor/tests.rs index e217122b27..a4d93fc3e6 100644 --- a/codex-rs/code-mode/src/cell_actor/tests.rs +++ b/codex-rs/code-mode/src/cell_actor/tests.rs @@ -45,9 +45,10 @@ impl CellHost for TestHost { &self, _stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { - cell_state.commit_completion(event, || {}) + cell_state.commit_completion(event, pending_initial_yield_items, || {}) } async fn closed(&self) {} @@ -76,9 +77,10 @@ impl CellHost for RecordingHost { &self, _stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { - cell_state.commit_completion(event, || {}) + cell_state.commit_completion(event, pending_initial_yield_items, || {}) } async fn closed(&self) {} @@ -397,7 +399,11 @@ async fn only_the_first_termination_claims_a_buffered_completion() { error_text: None, }; assert_eq!( - cell_state.commit_completion(completion.clone(), || {}), + cell_state.commit_completion( + completion.clone(), + /*pending_initial_yield_items*/ None, + || {} + ), CompletionCommit::Committed ); assert!(matches!( @@ -430,7 +436,11 @@ async fn termination_claim_prevents_stored_value_commit() { }; assert_eq!( - cell_state.commit_completion(completion.clone(), || commit_ran = true), + cell_state.commit_completion( + completion.clone(), + /*pending_initial_yield_items*/ None, + || commit_ran = true + ), CompletionCommit::Rejected(completion) ); assert!(!commit_ran); @@ -453,7 +463,11 @@ fn failed_completion_delivery_rebuffers_the_event() { error_text: None, }; assert_eq!( - cell_state.commit_completion(event.clone(), || {}), + cell_state.commit_completion( + event.clone(), + /*pending_initial_yield_items*/ None, + || {} + ), CompletionCommit::Committed ); let (response_tx, response_rx) = oneshot::channel(); @@ -466,8 +480,151 @@ fn failed_completion_delivery_rebuffers_the_event() { let (response_tx, mut response_rx) = oneshot::channel(); assert!(matches!( - cell_state.route_observation(response_tx), + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), ObservationDelivery::Delivered )); assert_eq!(response_rx.try_recv(), Ok(Ok(event))); } + +#[test] +fn buffered_initial_yield_precedes_buffered_completion_for_yield_observer() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + completion.clone(), + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Buffered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "before".to_string(), + }], + })) + ); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); +} + +#[test] +fn pending_observer_merges_initial_yield_and_completion_output() { + let cell_state = CellState::new(CancellationToken::new()); + assert_eq!( + cell_state.commit_completion( + CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }, + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), + ObservationDelivery::Delivered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Completed { + content_items: vec![ + OutputItem::Text { + text: "before".to_string(), + }, + OutputItem::Text { + text: "after".to_string(), + }, + ], + error_text: None, + })) + ); +} + +#[test] +fn dropped_pending_observation_preserves_the_initial_yield_boundary() { + let cell_state = CellState::new(CancellationToken::new()); + let completion = CellEvent::Completed { + content_items: vec![OutputItem::Text { + text: "after".to_string(), + }], + error_text: None, + }; + assert_eq!( + cell_state.commit_completion( + completion.clone(), + Some(vec![OutputItem::Text { + text: "before".to_string(), + }]), + || {} + ), + CompletionCommit::Committed + ); + assert!(matches!( + cell_state.deliver_completion(/*response_tx*/ None), + CompletionDelivery::Buffered + )); + + let (response_tx, response_rx) = oneshot::channel(); + drop(response_rx); + assert!(matches!( + cell_state.route_observation(ObserveMode::PendingFrontier, response_tx), + ObservationDelivery::Buffered + )); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Buffered + )); + assert_eq!( + response_rx.try_recv(), + Ok(Ok(CellEvent::Yielded { + content_items: vec![OutputItem::Text { + text: "before".to_string(), + }], + })) + ); + + let (response_tx, mut response_rx) = oneshot::channel(); + assert!(matches!( + cell_state.route_observation(ObserveMode::YieldAfter(Duration::ZERO), response_tx), + ObservationDelivery::Delivered + )); + assert_eq!(response_rx.try_recv(), Ok(Ok(completion))); +} diff --git a/codex-rs/code-mode/src/cell_actor/types.rs b/codex-rs/code-mode/src/cell_actor/types.rs index ee345d6ab7..672820b93e 100644 --- a/codex-rs/code-mode/src/cell_actor/types.rs +++ b/codex-rs/code-mode/src/cell_actor/types.rs @@ -11,6 +11,7 @@ use tokio_util::sync::CancellationToken; use crate::session_runtime::CellEvent; use crate::session_runtime::ObserveMode; +use crate::session_runtime::OutputItem; use crate::session_runtime::ToolKind; use crate::session_runtime::ToolName; @@ -54,6 +55,7 @@ pub(crate) trait CellHost: Send + Sync + 'static { &self, stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> impl Future + Send; @@ -113,7 +115,11 @@ enum CellPhase { Terminating { response_tx: oneshot::Sender>, }, - Completed(CellEvent), + Completed { + // Set only when `yield_control()` races the create-to-first-observe handoff. + pending_initial_yield_items: Option>, + event: CellEvent, + }, CompletionClaimed(CellEvent), Tombstone, } @@ -152,7 +158,7 @@ impl CellState { .phase .lock() .unwrap_or_else(std::sync::PoisonError::into_inner), - CellPhase::Running | CellPhase::Completed(_) + CellPhase::Running | CellPhase::Completed { .. } ); accepting_phase && !self.cancellation_token.is_cancelled() } @@ -173,7 +179,11 @@ impl CellState { *phase = CellPhase::Terminating { response_tx }; Box::pin(async { Err(CellError::AlreadyTerminating) }) } - CellPhase::Completed(event) => { + CellPhase::Completed { + pending_initial_yield_items, + event, + } => { + let event = prepend_initial_yield(event, pending_initial_yield_items); *phase = CellPhase::CompletionClaimed(event.clone()); self.cancellation_token.cancel(); ready_event(event) @@ -189,6 +199,7 @@ impl CellState { pub(crate) fn commit_completion( &self, event: CellEvent, + pending_initial_yield_items: Option>, commit: impl FnOnce(), ) -> CompletionCommit { let mut phase = self @@ -199,7 +210,10 @@ impl CellState { return CompletionCommit::Rejected(event); } commit(); - *phase = CellPhase::Completed(event); + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; CompletionCommit::Committed } @@ -211,15 +225,22 @@ impl CellState { .phase .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - let event = match std::mem::replace(&mut *phase, CellPhase::Tombstone) { - CellPhase::Completed(event) => event, - previous => { - *phase = previous; - return CompletionDelivery::Rejected(response_tx); - } - }; + let (pending_initial_yield_items, event) = + match std::mem::replace(&mut *phase, CellPhase::Tombstone) { + CellPhase::Completed { + pending_initial_yield_items, + event, + } => (pending_initial_yield_items, event), + previous => { + *phase = previous; + return CompletionDelivery::Rejected(response_tx); + } + }; let Some(response_tx) = response_tx else { - *phase = CellPhase::Completed(event); + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; return CompletionDelivery::Buffered; }; match response_tx.send(Ok(event)) { @@ -228,7 +249,10 @@ impl CellState { CompletionDelivery::Delivered } Err(Ok(event)) => { - *phase = CellPhase::Completed(event); + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; CompletionDelivery::Buffered } Err(Err(error)) => { @@ -239,6 +263,7 @@ impl CellState { pub(crate) fn route_observation( &self, + mode: ObserveMode, response_tx: oneshot::Sender>, ) -> ObservationDelivery { let mut phase = self @@ -250,19 +275,56 @@ impl CellState { *phase = CellPhase::Running; ObservationDelivery::Running(response_tx) } - CellPhase::Completed(event) => match response_tx.send(Ok(event)) { - Ok(()) => { - self.cancellation_token.cancel(); - ObservationDelivery::Delivered + CellPhase::Completed { + pending_initial_yield_items: Some(content_items), + event, + } if matches!(mode, ObserveMode::YieldAfter(_)) => { + match response_tx.send(Ok(CellEvent::Yielded { content_items })) { + Ok(()) => { + *phase = CellPhase::Completed { + pending_initial_yield_items: None, + event, + }; + ObservationDelivery::Buffered + } + Err(Ok(CellEvent::Yielded { content_items })) => { + *phase = CellPhase::Completed { + pending_initial_yield_items: Some(content_items), + event, + }; + ObservationDelivery::Buffered + } + Err(Ok(event)) => { + panic!("initial yield delivery returned an unexpected event: {event:?}") + } + Err(Err(error)) => { + panic!("initial yield delivery returned an actor error: {error:?}") + } } - Err(Ok(event)) => { - *phase = CellPhase::Completed(event); - ObservationDelivery::Buffered + } + CellPhase::Completed { + pending_initial_yield_items, + event, + } => { + let delivered_event = + prepend_initial_yield(event.clone(), pending_initial_yield_items.clone()); + match response_tx.send(Ok(delivered_event)) { + Ok(()) => { + self.cancellation_token.cancel(); + ObservationDelivery::Delivered + } + Err(Ok(_)) => { + *phase = CellPhase::Completed { + pending_initial_yield_items, + event, + }; + ObservationDelivery::Buffered + } + Err(Err(error)) => { + panic!("completion delivery unexpectedly carried an actor error: {error:?}") + } } - Err(Err(error)) => { - panic!("completion delivery unexpectedly carried an actor error: {error:?}") - } - }, + } CellPhase::Terminating { response_tx: termination_tx, } => { @@ -295,7 +357,10 @@ impl CellState { let _ = response_tx.send(Ok(event.clone())); Some(event) } - CellPhase::Completed(completed_event) => Some(completed_event), + CellPhase::Completed { + pending_initial_yield_items, + event, + } => Some(prepend_initial_yield(event, pending_initial_yield_items)), CellPhase::CompletionClaimed(completed_event) => Some(completed_event), CellPhase::Tombstone => None, }; @@ -316,6 +381,49 @@ impl CellState { } } +fn prepend_initial_yield( + event: CellEvent, + pending_initial_yield_items: Option>, +) -> CellEvent { + let Some(mut pending_initial_yield_items) = pending_initial_yield_items else { + return event; + }; + match event { + CellEvent::Yielded { mut content_items } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Yielded { + content_items: pending_initial_yield_items, + } + } + CellEvent::Pending { + mut content_items, + pending_tool_call_ids, + } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Pending { + content_items: pending_initial_yield_items, + pending_tool_call_ids, + } + } + CellEvent::Completed { + mut content_items, + error_text, + } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Completed { + content_items: pending_initial_yield_items, + error_text, + } + } + CellEvent::Terminated { mut content_items } => { + pending_initial_yield_items.append(&mut content_items); + CellEvent::Terminated { + content_items: pending_initial_yield_items, + } + } + } +} + pub(super) enum CellCommand { Observe { mode: ObserveMode, diff --git a/codex-rs/code-mode/src/session_runtime/mod.rs b/codex-rs/code-mode/src/session_runtime/mod.rs index 71cf3d6137..0f7557a750 100644 --- a/codex-rs/code-mode/src/session_runtime/mod.rs +++ b/codex-rs/code-mode/src/session_runtime/mod.rs @@ -256,6 +256,7 @@ impl CellHost for RuntimeCellHost { &self, stored_value_writes: HashMap, event: CellEvent, + pending_initial_yield_items: Option>, cell_state: Arc, ) -> CompletionCommit { let cancellation_token = cell_state.cancellation_token(); @@ -266,7 +267,9 @@ impl CellHost for RuntimeCellHost { } stored_values = self.inner.stored_values.lock() => stored_values, }; - cell_state.commit_completion(event, || stored_values.extend(stored_value_writes)) + cell_state.commit_completion(event, pending_initial_yield_items, || { + stored_values.extend(stored_value_writes); + }) } async fn closed(&self) { diff --git a/codex-rs/code-mode/src/session_runtime/tests.rs b/codex-rs/code-mode/src/session_runtime/tests.rs index a1dc18c0e8..e0838d89d8 100644 --- a/codex-rs/code-mode/src/session_runtime/tests.rs +++ b/codex-rs/code-mode/src/session_runtime/tests.rs @@ -59,6 +59,7 @@ async fn termination_rejects_a_waiting_store_commit_before_the_next_cell_can_loa JsonValue::String("lost".to_string()), )]), completion.clone(), + /*pending_initial_yield_items*/ None, Arc::clone(&cell_state), ); tokio::pin!(commit); From b21f0e7a98c05a570cc227e2ec62a2e29c3a7225 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Sun, 21 Jun 2026 20:11:50 -0700 Subject: [PATCH 19/86] [codex] Add internal auto-compaction opt-out (#28260) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - add a default-on `auto_compaction` feature flag as an internal escape hatch - skip pre-turn, model-switch/hash, and mid-turn automatic compaction when the flag is disabled - preserve manual `/compact` behavior and surface the existing context-window error when the provider runs out of room - add integration coverage for disabled pre-turn and mid-turn compaction ## Motivation Long-running SPO optimization rollouts need the option to preserve their full context and fail on context exhaustion instead of entering another compaction window. This deliberately uses the existing feature-flag mechanism rather than adding a dedicated public config or app-server API. Disable it with: ```sh codex --disable auto_compaction ``` ## Testing - `just test -p codex-features` — 51 passed - `just test -p codex-core auto_compaction_feature_disabled` — 2 passed - `just fix -p codex-core -p codex-features` - `just write-config-schema` - `just test -p codex-core` — the new compaction tests passed; the overall local run had 54 unrelated environment failures, primarily missing first-party test binaries and shell-snapshot timeouts --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/session/turn.rs | 9 +- codex-rs/core/src/tools/spec_plan.rs | 4 +- codex-rs/core/tests/suite/compact.rs | 208 ++++++++++++++++++++++ codex-rs/core/tests/suite/token_budget.rs | 48 +++++ codex-rs/features/src/lib.rs | 8 + 6 files changed, 281 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2655f2e743..003ca63393 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -440,6 +440,9 @@ "auth_elicitation": { "type": "boolean" }, + "auto_compaction": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, @@ -4734,6 +4737,9 @@ "auth_elicitation": { "type": "boolean" }, + "auto_compaction": { + "type": "boolean" + }, "browser_use": { "type": "boolean" }, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index a35d9b60ca..909048ce66 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -333,7 +333,10 @@ pub(crate) async fn run_turn( } // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. - if token_limit_reached && needs_follow_up { + if turn_context.features.enabled(Feature::AutoCompaction) + && token_limit_reached + && needs_follow_up + { if let Err(err) = run_auto_compact( &sess, &turn_context, @@ -844,6 +847,10 @@ async fn run_pre_sampling_compact( turn_context: &Arc, client_session: &mut ModelClientSession, ) -> CodexResult<()> { + if !turn_context.features.enabled(Feature::AutoCompaction) { + return Ok(()); + } + maybe_run_previous_model_inline_compact(sess, turn_context, client_session).await?; let token_status = auto_compact_token_status(sess.as_ref(), turn_context.as_ref()).await; // Compact if the configured auto-compaction budget or usable context window is exhausted. diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index af7acf17a0..3e2268d92d 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -708,7 +708,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut } if features.enabled(Feature::TokenBudget) { - planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); + if features.enabled(Feature::AutoCompaction) { + planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly); + } planned_tools.add(GetContextRemainingHandler); } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 92ec66e161..e40a13dcd4 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,6 @@ use anyhow::Result; use anyhow::anyhow; +use codex_core::CodexThread; use codex_core::compact::SUMMARIZATION_PROMPT; use codex_core::compact::SUMMARY_PREFIX; use codex_core::config::Config; @@ -35,6 +36,7 @@ use core_test_support::responses; use core_test_support::responses::ev_reasoning_item; use core_test_support::responses::mount_models_once; use core_test_support::skip_if_no_network; +use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; @@ -92,6 +94,48 @@ const REMOTE_V2_SUMMARY: &str = "global-instructions-remote-v2-summary"; pub(super) const COMPACT_WARNING_MESSAGE: &str = "Heads up: Long threads and multiple compactions can cause the model to be less accurate. Start a new thread when possible to keep threads small and targeted."; +async fn build_auto_compaction_disabled_codex(server: &MockServer) -> TestCodex { + let mut model_provider = non_openai_model_provider(server); + model_provider.stream_max_retries = Some(0); + test_codex() + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + config.model_context_window = Some(100); + config.model_auto_compact_token_limit = Some(90); + let _ = config.features.disable(Feature::AutoCompaction); + }) + .build(server) + .await + .expect("build codex") +} + +async fn submit_context_window_exceeded_turn(codex: &Arc, text: &str) { + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: text.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await + .expect("submit context window exceeded turn"); + let error_message = wait_for_event_match(codex, |event| match event { + EventMsg::Error(err) => Some(err.message.clone()), + _ => None, + }) + .await; + wait_for_event(codex, |event| matches!(event, EventMsg::TurnComplete(_))).await; + assert!( + error_message.contains("ran out of room in the model's context window"), + "expected context window exceeded message, got {error_message}" + ); +} + fn ev_shell_command_call(call_id: &str, command: &str) -> serde_json::Value { ev_function_call( call_id, @@ -2276,6 +2320,93 @@ async fn pre_sampling_compact_runs_when_comp_hash_changes() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_comp_hash_model_switch_compaction() { + skip_if_no_network!(); + + let server = MockServer::start().await; + let previous_model = "gpt-5.3-codex"; + let next_model = "gpt-5.2"; + + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![ + model_info_with_optional_comp_hash(previous_model, Some("hash-a")), + model_info_with_optional_comp_hash(next_model, Some("hash-b")), + ], + }, + ) + .await; + let request_log = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_assistant_message("m1", "before switch"), + ev_completed_with_tokens("r1", /*total_tokens*/ 100), + ]), + sse(vec![ + ev_assistant_message("m2", "after switch"), + ev_completed_with_tokens("r2", /*total_tokens*/ 100), + ]), + ], + ) + .await; + let model_provider = non_openai_model_provider(&server); + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_model(previous_model) + .with_config(move |config| { + config.model_provider = model_provider; + set_test_compact_prompt(config); + let _ = config.features.disable(Feature::AutoCompaction); + }); + let test = builder.build(&server).await.expect("build test codex"); + + test.codex + .submit(disabled_permission_user_turn( + "before switch", + test.cwd.path().to_path_buf(), + previous_model.to_string(), + )) + .await + .expect("submit first user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.codex + .submit(disabled_permission_user_turn( + "after switch", + test.cwd.path().to_path_buf(), + next_model.to_string(), + )) + .await + .expect("submit second user turn"); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = request_log.requests(); + assert_eq!(models_mock.requests().len(), 1); + assert_eq!( + requests.len(), + 2, + "disabled auto-compaction should skip compaction on a comp-hash model switch" + ); + let first = requests[0].body_json(); + let second = requests[1].body_json(); + assert_eq!(first["model"].as_str(), Some(previous_model)); + assert_eq!(second["model"].as_str(), Some(next_model)); + assert!(second.to_string().contains("before switch")); + assert!(second.to_string().contains("after switch")); + assert!( + !body_contains_text(&second.to_string(), SUMMARIZATION_PROMPT), + "disabled auto-compaction should preserve history instead of requesting a summary" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn pre_sampling_compact_skips_when_either_comp_hash_is_missing() { skip_if_no_network!(); @@ -3655,6 +3786,45 @@ async fn snapshot_request_shape_mid_turn_continuation_compaction() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_mid_turn_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let over_limit_tokens = 100 * 95 / 100 + 1; + let first_turn = sse(vec![ + ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + ev_completed_with_tokens("r1", over_limit_tokens), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + first_turn, + sse_failed( + "response-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + let test = build_auto_compaction_disabled_codex(&server).await; + + submit_context_window_exceeded_turn(&test.codex, FUNCTION_CALL_LIMIT_MSG).await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2); + let continuation_request = &requests[1]; + continuation_request.function_call_output(DUMMY_CALL_ID); + assert!( + !body_contains_text( + &continuation_request.body_json().to_string(), + SUMMARIZATION_PROMPT + ), + "disabled auto-compaction should continue without a compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn auto_compact_clamps_config_limit_to_context_window() { skip_if_no_network!(); @@ -4488,6 +4658,44 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_skips_pre_turn_compaction() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let first_turn = sse(vec![ + ev_assistant_message("m1", FIRST_REPLY), + ev_completed_with_tokens("r1", /*total_tokens*/ 500), + ]); + let request_log = mount_sse_sequence( + &server, + vec![ + first_turn, + sse_failed( + "response-failed", + "context_length_exceeded", + CONTEXT_LIMIT_MESSAGE, + ), + ], + ) + .await; + let test = build_auto_compaction_disabled_codex(&server).await; + + test.submit_turn("USER_ONE") + .await + .expect("submit first turn"); + submit_context_window_exceeded_turn(&test.codex, "USER_TWO").await; + + let requests = request_log.requests(); + assert_eq!(requests.len(), 2); + let second_request_body = requests[1].body_json().to_string(); + assert!(second_request_body.contains("USER_TWO")); + assert!( + !body_contains_text(&second_request_body, SUMMARIZATION_PROMPT), + "disabled auto-compaction should sample without a pre-turn compaction request" + ); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_request_shape_manual_compact_without_previous_user_messages() { skip_if_no_network!(); diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index 4d448f9e7e..ec86ce5946 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -549,3 +549,51 @@ async fn new_context_tool_starts_new_window_before_follow_up() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auto_compaction_feature_disabled_hides_new_context_tool() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + vec![sse(vec![ + ev_response_created("resp-1"), + ev_completed("resp-1"), + ])], + ) + .await; + let test = test_codex() + .with_config(|config| { + config.model_context_window = Some(CONFIGURED_CONTEXT_WINDOW); + config + .features + .enable(Feature::TokenBudget) + .expect("test config should allow token budget"); + config + .features + .disable(Feature::AutoCompaction) + .expect("test config should allow disabling auto-compaction"); + }) + .build(&server) + .await?; + + test.submit_turn("preserve the current context window") + .await?; + + let requests = responses.requests(); + assert_eq!(requests.len(), 1); + let tool_names = tool_names(&requests[0]); + assert!( + tool_names + .iter() + .any(|name| name == "get_context_remaining"), + "token budget should continue to expose get_context_remaining" + ); + assert!( + !tool_names.iter().any(|name| name == "new_context"), + "disabled auto-compaction should hide new_context" + ); + + Ok(()) +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f2a30b07b6..3a04621fe0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -231,6 +231,8 @@ pub enum Feature { RealtimeConversation, /// Prevent idle system sleep while a turn is actively running. PreventIdleSleep, + /// Enable automatic context compaction before or during a turn. + AutoCompaction, /// Enable remote compaction v2 over the normal Responses API. RemoteCompactionV2, /// Use Agent Identity for ChatGPT-authenticated sessions. @@ -1335,6 +1337,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::AutoCompaction, + key: "auto_compaction", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::RemoteCompactionV2, key: "remote_compaction_v2", From 566f7bf6314cbf213de523a0268d8df89f93ef62 Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Sun, 21 Jun 2026 20:39:14 -0700 Subject: [PATCH 20/86] Propagate safety buffering events to app-server clients (#29371) Responses API safety buffering metadata currently stops at the transport boundary, so app-server clients cannot render the in-progress safety review state. This change: - decodes and deduplicates `safety_buffering` metadata from Responses API SSE and WebSocket events without suppressing the original response event - emits a typed core event containing the requested model plus backend use cases and reasons - forwards that event as `turn/safetyBuffering/updated` through app-server v2 and updates generated protocol schemas - keeps the side-channel event out of persisted rollouts and turn timing This supports the Codex Apps buffering UX and depends on the Responses API backend work in https://github.com/openai/openai/pull/1044569 and https://github.com/openai/openai/pull/1044571. Validation: - focused `codex-core` safety-buffering integration test passes - `cargo check -p codex-core -p codex-app-server -p codex-app-server-protocol` - `just fix -p codex-api -p codex-protocol -p codex-core -p codex-app-server-protocol -p codex-app-server -p codex-rollout -p codex-rollout-trace -p codex-otel` - `just fmt` - broad package test run: 4,430/4,492 passed; 62 unrelated local-environment/concurrency failures involved unavailable test binaries, MCP subprocess setup, and app-server timeouts --- .../schema/json/ServerNotification.json | 53 ++++++++++++++ .../codex_app_server_protocol.schemas.json | 55 ++++++++++++++ .../codex_app_server_protocol.v2.schemas.json | 55 ++++++++++++++ ...delSafetyBufferingUpdatedNotification.json | 35 +++++++++ .../schema/typescript/ServerNotification.ts | 3 +- ...ModelSafetyBufferingUpdatedNotification.ts | 5 ++ .../schema/typescript/v2/index.ts | 1 + .../src/protocol/common.rs | 28 +++++++ .../src/protocol/v2/model.rs | 11 +++ codex-rs/app-server/README.md | 1 + .../app-server/src/bespoke_event_handling.rs | 15 ++++ codex-rs/codex-api/src/common.rs | 7 ++ .../src/endpoint/responses_websocket.rs | 11 +++ codex-rs/codex-api/src/sse/responses.rs | 73 +++++++++++++++++++ codex-rs/core/src/session/turn.rs | 13 ++++ codex-rs/core/src/turn_timing.rs | 1 + codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/safety_buffering.rs | 63 ++++++++++++++++ codex-rs/mcp-server/src/codex_tool_runner.rs | 1 + codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/protocol.rs | 10 +++ codex-rs/rollout-trace/src/protocol_event.rs | 2 + codex-rs/rollout/src/policy.rs | 1 + .../tui/src/app/app_server_event_targets.rs | 3 + codex-rs/tui/src/chatwidget/protocol.rs | 1 + 25 files changed, 449 insertions(+), 1 deletion(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts create mode 100644 codex-rs/core/tests/suite/safety_buffering.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6ba699dce7..f1fbee5c57 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2583,6 +2583,39 @@ ], "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "type": "object" + }, "ModelVerification": { "enum": [ "trustedAccessForCyber" @@ -6608,6 +6641,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f8f8db58b5..bf7f0e5b30 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5091,6 +5091,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { @@ -12559,6 +12579,41 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 81a79c0c97..7a080b56bd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8983,6 +8983,41 @@ "title": "ModelReroutedNotification", "type": "object" }, + "ModelSafetyBufferingUpdatedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" + }, "ModelServiceTier": { "properties": { "description": { @@ -13412,6 +13447,26 @@ "title": "Turn/moderationMetadataNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/safetyBuffering/updated" + ], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json new file mode 100644 index 0000000000..5ed692480b --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json @@ -0,0 +1,35 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "model", + "reasons", + "threadId", + "turnId", + "useCases" + ], + "title": "ModelSafetyBufferingUpdatedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 82814111dc..f3f828b8ac 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -30,6 +30,7 @@ import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOau import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; +import type { ModelSafetyBufferingUpdatedNotification } from "./v2/ModelSafetyBufferingUpdatedNotification"; import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { ProcessExitedNotification } from "./v2/ProcessExitedNotification"; @@ -73,4 +74,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/deleted", "params": ThreadDeletedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/goal/updated", "params": ThreadGoalUpdatedNotification } | { "method": "thread/goal/cleared", "params": ThreadGoalClearedNotification } | { "method": "thread/settings/updated", "params": ThreadSettingsUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "process/outputDelta", "params": ProcessOutputDeltaNotification } | { "method": "process/exited", "params": ProcessExitedNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "remoteControl/status/changed", "params": RemoteControlStatusChangedNotification } | { "method": "externalAgentConfig/import/progress", "params": ExternalAgentConfigImportProgressNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "turn/moderationMetadata", "params": TurnModerationMetadataNotification } | { "method": "model/safetyBuffering/updated", "params": ModelSafetyBufferingUpdatedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts new file mode 100644 index 0000000000..484f8c6146 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelSafetyBufferingUpdatedNotification = { threadId: string, turnId: string, model: string, useCases: Array, reasons: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 9b1bd3b6e8..419cf8c3c6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -260,6 +260,7 @@ export type { ModelProviderCapabilitiesReadParams } from "./ModelProviderCapabil export type { ModelProviderCapabilitiesReadResponse } from "./ModelProviderCapabilitiesReadResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; +export type { ModelSafetyBufferingUpdatedNotification } from "./ModelSafetyBufferingUpdatedNotification"; export type { ModelServiceTier } from "./ModelServiceTier"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; export type { ModelVerification } from "./ModelVerification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2f322aa5ff..eb3e6c82ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1655,6 +1655,7 @@ server_notification_definitions! { ModelVerification => "model/verification" (v2::ModelVerificationNotification), #[experimental("turn/moderationMetadata")] TurnModerationMetadata => "turn/moderationMetadata" (v2::TurnModerationMetadataNotification), + ModelSafetyBufferingUpdated => "model/safetyBuffering/updated" (v2::ModelSafetyBufferingUpdatedNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), @@ -3347,6 +3348,33 @@ mod tests { Ok(()) } + #[test] + fn serialize_model_safety_buffering_updated_notification() -> Result<()> { + let notification = ServerNotification::ModelSafetyBufferingUpdated( + v2::ModelSafetyBufferingUpdatedNotification { + thread_id: "thr_123".to_string(), + turn_id: "turn_123".to_string(), + model: "gpt-5.4".to_string(), + use_cases: vec!["cyber".to_string()], + reasons: vec!["user_risk".to_string()], + }, + ); + assert_eq!( + json!({ + "method": "model/safetyBuffering/updated", + "params": { + "threadId": "thr_123", + "turnId": "turn_123", + "model": "gpt-5.4", + "useCases": ["cyber"], + "reasons": ["user_risk"] + } + }), + serde_json::to_value(¬ification)?, + ); + Ok(()) + } + #[test] fn serialize_thread_realtime_output_audio_delta_notification() -> Result<()> { let notification = ServerNotification::ThreadRealtimeOutputAudioDelta( diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index d8fa249582..2ce1819024 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -162,3 +162,14 @@ pub struct TurnModerationMetadataNotification { pub turn_id: String, pub metadata: JsonValue, } + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelSafetyBufferingUpdatedNotification { + pub thread_id: String, + pub turn_id: String, + pub model: String, + pub use_cases: Vec, + pub reasons: Vec, +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index f8ceb69d42..8de6e3b8c7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1353,6 +1353,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. +- `model/safetyBuffering/updated` — `{ threadId, turnId, model, useCases, reasons }` when a response enters safety buffering. This notification is transient and is not persisted in rollout history. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. - `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index a77b0f4664..bc69505066 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -40,6 +40,7 @@ use codex_app_server_protocol::McpServerElicitationRequestResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelSafetyBufferingUpdatedNotification; use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; @@ -351,6 +352,20 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::TurnModerationMetadata(notification)) .await; } + EventMsg::SafetyBuffering(event) => { + let notification = ModelSafetyBufferingUpdatedNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + model: event.model, + use_cases: event.use_cases, + reasons: event.reasons, + }; + outgoing + .send_server_notification(ServerNotification::ModelSafetyBufferingUpdated( + notification, + )) + .await; + } EventMsg::RealtimeConversationStarted(event) => { let notification = ThreadRealtimeStartedNotification { thread_id: conversation_id.to_string(), diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 17753e799a..aeea85e7fc 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -72,6 +72,7 @@ pub struct MemorySummarizeOutput { #[derive(Debug)] pub enum ResponseEvent { Created, + SafetyBuffering(SafetyBuffering), OutputItemDone(ResponseItem), OutputItemAdded(ResponseItem), /// Emitted when the server includes `OpenAI-Model` on the stream response. @@ -113,6 +114,12 @@ pub enum ResponseEvent { ModelsEtag(String), } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct SafetyBuffering { + pub use_cases: Vec, + pub reasons: Vec, +} + #[derive(Debug, Serialize, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ReasoningContext { diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 4ea564f139..fa01530102 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -690,6 +690,7 @@ async fn run_websocket_response_stream( } let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); + let safety_buffering = event.safety_buffering(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -724,6 +725,16 @@ async fn run_websocket_response_stream( "response event consumer dropped".to_string(), )); } + if let Some(buffering) = safety_buffering + && tx_event + .send(Ok(ResponseEvent::SafetyBuffering(buffering))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index a451f67af0..0c20e10392 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -1,5 +1,6 @@ use crate::common::ResponseEvent; use crate::common::ResponseStream; +use crate::common::SafetyBuffering; use crate::error::ApiError; use crate::rate_limits::parse_all_rate_limits; use crate::telemetry::SseTelemetry; @@ -157,6 +158,7 @@ pub struct ResponsesStreamEvent { delta: Option, summary_index: Option, content_index: Option, + safety_buffering: Option, } impl ResponsesStreamEvent { @@ -217,6 +219,10 @@ impl ResponsesStreamEvent { .cloned() .map(|metadata| TurnModerationMetadataEvent { metadata }) } + + pub(crate) fn safety_buffering(&self) -> Option { + serde_json::from_value(self.safety_buffering.as_ref()?.clone()).ok() + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -480,6 +486,7 @@ pub async fn process_sse( }; let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); + let safety_buffering = event.safety_buffering(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -509,6 +516,14 @@ pub async fn process_sse( { return; } + if let Some(buffering) = safety_buffering + && tx_event + .send(Ok(ResponseEvent::SafetyBuffering(buffering))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -1294,6 +1309,64 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_all_safety_buffering_notifications_without_dropping_response_events() + { + let events = run_sse(vec![ + json!({ + "type": "response.created", + "response": { "id": "resp-1" }, + "safety_buffering": false + }), + json!({ + "type": "response.output_text.delta", + "delta": "hello", + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + json!({ + "type": "response.output_text.delta", + "delta": " world", + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + json!({ + "type": "response.completed", + "response": { "id": "resp-1" }, + "safety_buffering": { + "use_cases": ["cyber"], + "reasons": ["user_risk"] + } + }), + ]) + .await; + + assert_eq!(events.len(), 7); + assert_matches!(&events[0], ResponseEvent::Created); + assert_matches!( + &events[1], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[2], ResponseEvent::OutputTextDelta(delta) if delta == "hello"); + assert_matches!( + &events[3], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[4], ResponseEvent::OutputTextDelta(delta) if delta == " world"); + assert_matches!( + &events[5], + ResponseEvent::SafetyBuffering(buffering) + if buffering.use_cases == ["cyber"] && buffering.reasons == ["user_risk"] + ); + assert_matches!(&events[6], ResponseEvent::Completed { response_id, .. } if response_id == "resp-1"); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 909048ce66..2630ba1cc9 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -100,6 +100,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::PlanDeltaEvent; use codex_protocol::protocol::ReasoningContentDeltaEvent; use codex_protocol::protocol::ReasoningRawContentDeltaEvent; +use codex_protocol::protocol::SafetyBufferingEvent; use codex_protocol::protocol::TurnDiffEvent; use codex_protocol::protocol::WarningEvent; use codex_protocol::user_input::UserInput; @@ -1527,6 +1528,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option<(String, Option< | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::TurnModerationMetadata(_) + | EventMsg::SafetyBuffering(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -2199,6 +2201,17 @@ async fn try_run_sampling_request( sess.emit_turn_moderation_metadata(&turn_context, metadata) .await; } + ResponseEvent::SafetyBuffering(buffering) => { + sess.send_event( + &turn_context, + EventMsg::SafetyBuffering(SafetyBufferingEvent { + model: turn_context.model_info.slug.clone(), + use_cases: buffering.use_cases, + reasons: buffering.reasons, + }), + ) + .await; + } ResponseEvent::ServerReasoningIncluded(included) => { sess.set_server_reasoning_included(included).await; } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 8a93a91833..e7a90800b0 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -339,6 +339,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { | ResponseEvent::ServerModel(_) | ResponseEvent::ModelVerifications(_) | ResponseEvent::TurnModerationMetadata(_) + | ResponseEvent::SafetyBuffering(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 492f309795..a9298ec6d9 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -104,6 +104,7 @@ mod review; mod rmcp_client; mod rollout_budget; mod rollout_list_find; +mod safety_buffering; mod safety_check_downgrade; mod search_tool; mod shell_command; diff --git a/codex-rs/core/tests/suite/safety_buffering.rs b/codex-rs/core/tests/suite/safety_buffering.rs new file mode 100644 index 0000000000..6c2df1b6da --- /dev/null +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -0,0 +1,63 @@ +use anyhow::Ok; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::SafetyBufferingEvent; +use codex_protocol::user_input::UserInput; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn emits_safety_buffering_with_the_requested_model() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let mut created = ev_response_created("resp-1"); + created["safety_buffering"] = json!({ + "use_cases": ["cyber"], + "reasons": ["policy-check"], + }); + mount_sse_once(&server, sse(vec![created, ev_completed("resp-1")])).await; + + let test = test_codex().build(&server).await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "Check this request".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let event = wait_for_event_match(&test.codex, |event| match event { + EventMsg::SafetyBuffering(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!( + event, + SafetyBufferingEvent { + model: test.session_configured.model.clone(), + use_cases: vec!["cyber".to_string()], + reasons: vec!["policy-check".to_string()], + } + ); + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + Ok(()) +} diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index d605b640ca..cbc113ff79 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -266,6 +266,7 @@ async fn run_codex_tool_session_inner( EventMsg::Warning(_) | EventMsg::GuardianWarning(_) | EventMsg::ModelVerification(_) + | EventMsg::SafetyBuffering(_) | EventMsg::TurnModerationMetadata(_) => { continue; } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 8f0471088f..c8e35b9f7d 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1209,6 +1209,7 @@ impl SessionTelemetry { ResponseEvent::ServerModel(_) => "server_model".into(), ResponseEvent::ModelVerifications(_) => "model_verifications".into(), ResponseEvent::TurnModerationMetadata(_) => "turn_moderation_metadata".into(), + ResponseEvent::SafetyBuffering(_) => "safety_buffering".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 081649b7a4..483618f060 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1251,6 +1251,9 @@ pub enum EventMsg { /// Backend moderation metadata intended for first-party turn presentation. TurnModerationMetadata(TurnModerationMetadataEvent), + /// Backend indicates that response output is waiting on a safety review. + SafetyBuffering(SafetyBufferingEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1929,6 +1932,13 @@ pub struct TurnModerationMetadataEvent { pub metadata: Value, } +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct SafetyBufferingEvent { + pub model: String, + pub use_cases: Vec, + pub reasons: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/rollout-trace/src/protocol_event.rs b/codex-rs/rollout-trace/src/protocol_event.rs index a998c98fc0..42e0db04d2 100644 --- a/codex-rs/rollout-trace/src/protocol_event.rs +++ b/codex-rs/rollout-trace/src/protocol_event.rs @@ -344,6 +344,7 @@ pub(crate) fn tool_runtime_trace_event(event: &EventMsg) -> Option Option<&'static s EventMsg::Warning(_) => Some("warning"), EventMsg::ShutdownComplete => Some("shutdown_complete"), EventMsg::GuardianWarning(_) + | EventMsg::SafetyBuffering(_) | EventMsg::RealtimeConversationStarted(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 169f4360d3..b342d699b7 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -121,6 +121,7 @@ pub fn should_persist_event_msg(ev: &EventMsg) -> bool { | EventMsg::RealtimeConversationSdp(_) | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) + | EventMsg::SafetyBuffering(_) | EventMsg::ModelReroute(_) | EventMsg::ModelVerification(_) | EventMsg::TurnModerationMetadata(_) diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 7e84e42271..212c45b4be 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -123,6 +123,9 @@ pub(super) fn server_notification_thread_target( ServerNotification::ModelVerification(notification) => { Some(notification.thread_id.as_str()) } + ServerNotification::ModelSafetyBufferingUpdated(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::TurnModerationMetadata(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/chatwidget/protocol.rs b/codex-rs/tui/src/chatwidget/protocol.rs index e2cf31b034..085255a1cf 100644 --- a/codex-rs/tui/src/chatwidget/protocol.rs +++ b/codex-rs/tui/src/chatwidget/protocol.rs @@ -208,6 +208,7 @@ impl ChatWidget { | ServerNotification::ExternalAgentConfigImportProgress(_) | ServerNotification::ExternalAgentConfigImportCompleted(_) | ServerNotification::FsChanged(_) + | ServerNotification::ModelSafetyBufferingUpdated(_) | ServerNotification::TurnModerationMetadata(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) From 98845e484070a1f93fa24842db0e429c7cec9f81 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Sun, 21 Jun 2026 22:11:42 -0700 Subject: [PATCH 21/86] chore: fix merge race (auto-compaction feature access) (#29393) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - read the `AutoCompaction` feature flag through `TurnContext::config` - fix both the mid-turn and pre-sampling compaction checks ## Why #28260 was validated against an older base where `TurnContext` exposed a direct `features` field. It was then merged after that field had moved under `config`, leaving the merge result unable to compile with `E0609` on `turn_context.features`. This restores compilation for Bazel, SDK, and argument-comment-lint jobs that build `codex-core`. Behavior is unchanged: disabling `auto_compaction` still skips automatic compaction. ## Validation - `just fmt` - `CODEX_HOME=/private/tmp/codex-fix-auto-compaction-test-home just test -p codex-core auto_compaction_feature_disabled` — 4 passed - `just test -p codex-core` — `codex-core` compiled; 2,722 passed and 89 unrelated local-environment failures remained because the sandbox could not write the default Codex SQLite/proxy paths and some first-party test binaries were unavailable --- codex-rs/core/src/session/turn.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2630ba1cc9..041da729ea 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -334,7 +334,10 @@ pub(crate) async fn run_turn( } // as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop. - if turn_context.features.enabled(Feature::AutoCompaction) + if turn_context + .config + .features + .enabled(Feature::AutoCompaction) && token_limit_reached && needs_follow_up { @@ -848,7 +851,11 @@ async fn run_pre_sampling_compact( turn_context: &Arc, client_session: &mut ModelClientSession, ) -> CodexResult<()> { - if !turn_context.features.enabled(Feature::AutoCompaction) { + if !turn_context + .config + .features + .enabled(Feature::AutoCompaction) + { return Ok(()); } From 6d15bb3d17f2b2a7d2a11ddd0689dc9244bf744c Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 08:36:08 +0100 Subject: [PATCH 22/86] Persist session IDs across thread resume (#29327) ## Summary A cold-resumed subagent kept its durable thread ID but could receive a new session ID, splitting one agent tree across multiple sessions after a restart. Persist the root session ID in every rollout `SessionMeta`, carry it through thread creation, and restore it before initializing the resumed `Session` and `AgentControl`. ## Behavior For a nested agent tree: ```text root session R parent thread P child thread C ``` The child rollout stores: ```text session_id: R parent_thread_id: P id: C ``` After a cold resume, the child still belongs to root session `R` while its immediate parent remains `P`. The integration coverage uses distinct values for all three IDs so it catches restoring the session from `parent_thread_id`. ## Legacy rollouts Previous rollouts have `id` but no `session_id`. `SessionMetaLine` deserialization treats a missing `session_id` as `id`, keeping those files readable, listable, and resumable. When a legacy subagent is resumed through its root, that synthesized child ID no longer overrides the inherited root-scoped `AgentControl`. New rollouts always persist the explicit root session ID. --- .../external_agent_session_import.rs | 1 + .../thread_processor_tests.rs | 3 ++ .../thread_summary_tests.rs | 1 + codex-rs/app-server/tests/common/rollout.rs | 10 ++++- .../tests/suite/conversation_summary.rs | 1 + .../tests/suite/v2/client_metadata.rs | 10 ++++- .../tests/suite/v2/remote_thread_store.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 2 + .../tests/suite/v2/thread_unarchive.rs | 1 + codex-rs/cli/src/doctor/thread_inventory.rs | 4 +- .../core/src/personality_migration_tests.rs | 1 + codex-rs/core/src/session/session.rs | 39 +++++++++++++----- codex-rs/core/src/session/tests.rs | 17 ++++++-- codex-rs/core/tests/suite/client.rs | 9 ++++- .../core/tests/suite/personality_migration.rs | 2 + codex-rs/core/tests/suite/review.rs | 1 + .../core/tests/suite/rollout_list_find.rs | 1 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/protocol/src/protocol.rs | 40 ++++++++++++++++++- codex-rs/rollout/src/compression_tests.rs | 1 + codex-rs/rollout/src/metadata_tests.rs | 3 ++ codex-rs/rollout/src/recorder.rs | 22 +++++++--- codex-rs/rollout/src/recorder_tests.rs | 27 +++++++++---- codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/state_db_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 8 +++- codex-rs/state/src/extract.rs | 2 + codex-rs/state/src/runtime/threads.rs | 2 + codex-rs/thread-store/src/in_memory.rs | 2 + .../thread-store/src/local/create_thread.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 1 + .../thread-store/src/local/read_thread.rs | 3 ++ .../thread-store/src/local/test_support.rs | 1 + .../thread-store/src/thread_metadata_sync.rs | 1 + codex-rs/thread-store/src/types.rs | 3 ++ codex-rs/tui/src/lib.rs | 1 + 38 files changed, 193 insertions(+), 34 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index d4abb155ec..0e17cb5737 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -200,6 +200,7 @@ impl ExternalAgentSessionImporter { }; let now = Utc::now(); let create_params = CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 0ac9b7fa24..1f5ae12f44 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -1004,6 +1004,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, timestamp: timestamp.clone(), model_provider: None, @@ -1060,6 +1061,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: parent_thread_id.into(), id: conversation_id, timestamp: timestamp.clone(), source: SessionSource::SubAgent(SubAgentSource::ThreadSpawn { @@ -1110,6 +1112,7 @@ mod thread_processor_behavior_tests { let timestamp = "2025-09-05T16:53:11.850Z".to_string(); let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: Some(forked_from_id), timestamp: timestamp.clone(), diff --git a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs index f8902e132d..8bead5529b 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary_tests.rs @@ -13,6 +13,7 @@ fn extract_conversation_summary_prefers_plain_user_messages() -> Result<()> { let head = vec![ json!({ + "session_id": conversation_id.to_string(), "id": conversation_id.to_string(), "timestamp": timestamp, "cwd": "/", diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6d446e1c42..7b389e8fc1 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo; @@ -127,11 +128,12 @@ pub fn create_fake_rollout_with_source( model_provider, git_info, source, + /*session_id*/ None, /*parent_thread_id*/ None, ) } -/// Create a minimal rollout file with an explicit session source and control parent. +/// Create a minimal rollout file with an explicit root session and control parent. #[allow(clippy::too_many_arguments)] pub fn create_fake_parented_rollout_with_source( codex_home: &Path, @@ -141,6 +143,7 @@ pub fn create_fake_parented_rollout_with_source( model_provider: Option<&str>, git_info: Option, source: SessionSource, + session_id: SessionId, parent_thread_id: ThreadId, ) -> Result { create_fake_rollout_with_source_and_parent_thread_id( @@ -151,6 +154,7 @@ pub fn create_fake_parented_rollout_with_source( model_provider, git_info, source, + Some(session_id), Some(parent_thread_id), ) } @@ -164,11 +168,13 @@ fn create_fake_rollout_with_source_and_parent_thread_id( model_provider: Option<&str>, git_info: Option, source: SessionSource, + session_id: Option, parent_thread_id: Option, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); let conversation_id = ThreadId::from_string(&uuid_str)?; + let session_id = session_id.unwrap_or_else(|| conversation_id.into()); let file_path = rollout_path(codex_home, filename_ts, &uuid_str); let dir = file_path @@ -178,6 +184,7 @@ fn create_fake_rollout_with_source_and_parent_thread_id( // Build JSONL lines let meta = SessionMeta { + session_id, id: conversation_id, forked_from_id: None, parent_thread_id, @@ -264,6 +271,7 @@ pub fn create_fake_rollout_with_text_elements( // Build JSONL lines let meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 6ad9f1c633..17f73db3ed 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -121,6 +121,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> let thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000125")?; store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index 6b95c036d0..e55cd0c24d 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -300,7 +300,7 @@ async fn review_start_sends_parent_lineage_in_turn_metadata_for_thread_fork_v2() } #[tokio::test] -async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { +async fn turn_start_sends_nested_subagent_lineage_after_cold_thread_resume_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -321,6 +321,8 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - /*supports_websockets*/ false, )?; + let root_thread_id = CoreThreadId::new(); + let root_thread_id_str = root_thread_id.to_string(); let parent_thread_id = CoreThreadId::new(); let parent_thread_id_str = parent_thread_id.to_string(); let subagent_thread_id = create_fake_parented_rollout_with_source( @@ -331,6 +333,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - Some("mock_provider"), /*git_info*/ None, SessionSource::SubAgent(SubAgentSource::Other("guardian".to_string())), + root_thread_id.into(), parent_thread_id, )?; @@ -350,6 +353,7 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - .await??; let ThreadResumeResponse { thread, .. } = to_response::(resume_resp)?; assert_eq!(thread.id, subagent_thread_id); + assert_eq!(thread.session_id, root_thread_id_str); assert_eq!(thread.parent_thread_id, Some(parent_thread_id_str.clone())); assert_eq!( thread.source, @@ -390,6 +394,10 @@ async fn turn_start_sends_other_subagent_lineage_after_cold_thread_resume_v2() - Some(parent_thread_id_str.as_str()) ); assert_eq!(metadata["subagent_kind"].as_str(), Some("guardian")); + assert_eq!( + metadata["session_id"].as_str(), + Some(thread.session_id.as_str()) + ); assert_eq!(metadata["thread_id"].as_str(), Some(thread.id.as_str())); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("forked_from_thread_id").is_none()); diff --git a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs index baf11aff5e..6deec70ffe 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_thread_store.rs @@ -147,6 +147,7 @@ async fn thread_delete_with_non_local_thread_store_does_not_create_local_persist let unloaded_thread_id = ThreadId::from_string(&Uuid::new_v4().to_string())?; thread_store .create_thread(StoreCreateThreadParams { + session_id: unloaded_thread_id.into(), thread_id: unloaded_thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 9bdff3bb8b..9779513024 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -1252,6 +1252,7 @@ async fn thread_list_filters_by_subagent_variant() -> Result<()> { Some("mock_provider"), /*git_info*/ None, CoreSessionSource::SubAgent(SubAgentSource::Review), + parent_thread_id.into(), parent_thread_id, )?; let compact_id = create_fake_rollout_with_source( diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index a6ed5e4a63..58990aaf04 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1357,6 +1357,7 @@ async fn seed_pathless_store_thread( ) -> Result<()> { store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 07ee66caf2..0d1fcd1e5d 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2054,6 +2054,7 @@ stream_max_retries = 0 let rollout_dir = rollout_path.parent().expect("rollout parent directory"); std::fs::create_dir_all(rollout_dir)?; let session_meta = SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, @@ -2735,6 +2736,7 @@ async fn thread_resume_rejects_mismatched_path_for_running_thread_id() -> Result "timestamp": "2025-01-01T00:00:00Z", "type": "session_meta", "payload": { + "session_id": thread_uuid, "id": thread_uuid, "timestamp": "2025-01-01T00:00:00Z", "cwd": codex_home.path(), diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 7267ab073a..46a989545c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -208,6 +208,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { let parent_thread_id = ThreadId::from_string("00000000-0000-4000-8000-000000000127")?; store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: Some(parent_thread_id), diff --git a/codex-rs/cli/src/doctor/thread_inventory.rs b/codex-rs/cli/src/doctor/thread_inventory.rs index 9a8eef6ee4..c50e6debdc 100644 --- a/codex-rs/cli/src/doctor/thread_inventory.rs +++ b/codex-rs/cli/src/doctor/thread_inventory.rs @@ -816,11 +816,13 @@ mod tests { }; std::fs::create_dir_all(&root).expect("rollout dir"); let path = root.join(format!("rollout-{timestamp}-{thread_id}.jsonl")); + let parsed_thread_id = ThreadId::from_string(thread_id).expect("thread id"); let rollout_line = RolloutLine { timestamp: timestamp.to_string(), item: RolloutItem::SessionMeta(codex_protocol::protocol::SessionMetaLine { meta: codex_protocol::protocol::SessionMeta { - id: ThreadId::from_string(thread_id).expect("thread id"), + session_id: parsed_thread_id.into(), + id: parsed_thread_id, timestamp: timestamp.to_string(), cwd: self.codex_home.path().to_path_buf(), originator: "test".to_string(), diff --git a/codex-rs/core/src/personality_migration_tests.rs b/codex-rs/core/src/personality_migration_tests.rs index 4cf8ab9d8f..57f06317a1 100644 --- a/codex-rs/core/src/personality_migration_tests.rs +++ b/codex-rs/core/src/personality_migration_tests.rs @@ -43,6 +43,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 0c07f5e951..9317428340 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -525,6 +525,33 @@ impl Session { } InitialHistory::Resumed(resumed_history) => resumed_history.conversation_id, }; + let resumed_session_id = match &initial_history { + InitialHistory::Resumed(resumed) => { + resumed.history.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.session_id), + _ => None, + }) + } + InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => None, + }; + // Legacy subagent rollouts synthesize session_id from their own thread id. + let resumed_session_id = resumed_session_id.filter(|session_id| { + !session_configuration.session_source.is_non_root_agent() + || *session_id != SessionId::from(thread_id) + }); + let session_id = resumed_session_id.unwrap_or_else(|| { + if session_configuration.session_source.is_non_root_agent() { + agent_control.session_id() + } else { + SessionId::from(thread_id) + } + }); + let agent_control = agent_control.with_session_id( + session_id, + config + .effective_agent_max_threads(MultiAgentVersion::V2) + .unwrap_or(usize::MAX), + ); let time_provider = crate::current_time::resolve_time_provider( config.current_time_reminder.as_ref(), external_time_provider, @@ -546,6 +573,7 @@ impl Session { let live_thread = match &initial_history { InitialHistory::New | InitialHistory::Cleared | InitialHistory::Forked(_) => { let params = CreateThreadParams { + session_id, thread_id, extra_config: config.extra_config.clone(), forked_from_id, @@ -952,17 +980,6 @@ impl Session { config.analytics_enabled, ) }); - let session_id = if session_configuration.session_source.is_non_root_agent() { - agent_control.session_id() - } else { - SessionId::from(thread_id) - }; - let agent_control = agent_control.with_session_id( - session_id, - config - .effective_agent_max_threads(MultiAgentVersion::V2) - .unwrap_or(usize::MAX), - ); // Keep one stable manager handle for the session so extension resource clients // automatically observe the manager installed at startup and on later refreshes. let mcp_connection_manager = Arc::new(arc_swap::ArcSwap::from_pointee( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6f84da3c79..f3f96b74a7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1900,6 +1900,7 @@ fn session_meta_item( ) -> RolloutItem { RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version, ..SessionMeta::default() @@ -3750,6 +3751,7 @@ async fn attach_thread_persistence(session: &mut Session) -> PathBuf { let live_thread = LiveThread::create( Arc::clone(&session.services.thread_store), CreateThreadParams { + session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, forked_from_id: None, @@ -5453,7 +5455,7 @@ async fn resumed_root_session_uses_thread_id_as_session_id() { } #[tokio::test] -async fn resumed_subagent_session_keeps_inherited_session_id() { +async fn resumed_subagent_session_restores_persisted_session_id() { let parent_thread_id = ThreadId::new(); let parent_session_id = SessionId::from(parent_thread_id); let thread_id = ThreadId::new(); @@ -5467,11 +5469,19 @@ async fn resumed_subagent_session_keeps_inherited_session_id() { let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: vec![RolloutItem::SessionMeta(SessionMetaLine { + meta: SessionMeta { + session_id: parent_session_id, + id: thread_id, + source: session_source.clone(), + ..SessionMeta::default() + }, + git: None, + })], rollout_path: None, }), session_source, - AgentControl::default().with_session_id(parent_session_id, /*max_threads*/ usize::MAX), + AgentControl::default(), ) .await .expect("resume should succeed"); @@ -6590,6 +6600,7 @@ async fn shutdown_complete_does_not_append_to_thread_store_after_shutdown() { let live_thread = LiveThread::create( Arc::clone(&thread_store), CreateThreadParams { + session_id: session.session_id(), thread_id: session.thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index fe543a875b..f9b2fe42ef 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -516,6 +516,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": convo_id, "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", "instructions": "be nice", @@ -726,12 +727,14 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { metadata: None, }; let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + let thread_id = ThreadId::default(); let rollout = vec![ RolloutLine { timestamp: "2024-01-01T00:00:00.000Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { - id: ThreadId::default(), + session_id: thread_id.into(), + id: thread_id, parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), @@ -860,12 +863,14 @@ async fn resume_replays_image_tool_outputs_with_detail() { let image_url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA="; let function_call_id = "view-image-call"; let custom_call_id = "js-repl-call"; + let thread_id = ThreadId::default(); let rollout = vec![ RolloutLine { timestamp: "2024-01-01T00:00:00.000Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { - id: ThreadId::default(), + session_id: thread_id.into(), + id: thread_id, parent_thread_id: None, timestamp: "2024-01-01T00:00:00Z".to_string(), cwd: ".".into(), diff --git a/codex-rs/core/tests/suite/personality_migration.rs b/codex-rs/core/tests/suite/personality_migration.rs index 5302274153..bbab961817 100644 --- a/codex-rs/core/tests/suite/personality_migration.rs +++ b/codex-rs/core/tests/suite/personality_migration.rs @@ -59,6 +59,7 @@ async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::R let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -109,6 +110,7 @@ async fn write_rollout_with_meta_only(dir: &Path, thread_id: ThreadId) -> io::Re let session_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 6b0f8552a2..99794939dc 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -511,6 +511,7 @@ async fn review_input_isolated_from_parent_history() { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": convo_id, "id": convo_id, "timestamp": "2024-01-01T00:00:00Z", "cwd": ".", diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs index 680301d9f9..1461c1af35 100644 --- a/codex-rs/core/tests/suite/rollout_list_find.rs +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -42,6 +42,7 @@ fn write_minimal_rollout_with_id_at_path(file: &Path, id: Uuid) { "timestamp": "2024-01-01T00:00:00.000Z", "type": "session_meta", "payload": { + "session_id": id, "id": id, "timestamp": "2024-01-01T00:00:00Z", "cwd": ".", diff --git a/codex-rs/core/tests/suite/sqlite_state.rs b/codex-rs/core/tests/suite/sqlite_state.rs index eae82de9e1..36a51f80c3 100644 --- a/codex-rs/core/tests/suite/sqlite_state.rs +++ b/codex-rs/core/tests/suite/sqlite_state.rs @@ -356,6 +356,7 @@ async fn backfill_scans_existing_rollouts() -> Result<()> { fs::create_dir_all(parent).expect("should create rollout directory"); let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 483618f060..5900ffe3cf 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -57,7 +57,9 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use schemars::JsonSchema; use serde::Deserialize; +use serde::Deserializer; use serde::Serialize; +use serde::de::Error as _; use serde_json::Value; use serde_with::serde_as; use strum_macros::Display; @@ -2914,6 +2916,7 @@ pub enum MultiAgentVersion { /// and should be used when there is no config override. #[derive(Serialize, Deserialize, Clone, Debug, JsonSchema, TS)] pub struct SessionMeta { + pub session_id: SessionId, pub id: ThreadId, #[serde(skip_serializing_if = "Option::is_none")] pub forked_from_id: Option, @@ -2956,8 +2959,10 @@ pub struct SessionMeta { impl Default for SessionMeta { fn default() -> Self { + let id = ThreadId::default(); SessionMeta { - id: ThreadId::default(), + session_id: id.into(), + id, forked_from_id: None, parent_thread_id: None, timestamp: String::new(), @@ -2978,7 +2983,7 @@ impl Default for SessionMeta { } } -#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] +#[derive(Serialize, Debug, Clone, JsonSchema, TS)] pub struct SessionMetaLine { #[serde(flatten)] pub meta: SessionMeta, @@ -2986,6 +2991,35 @@ pub struct SessionMetaLine { pub git: Option, } +impl<'de> Deserialize<'de> for SessionMetaLine { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct SessionMetaLineFields { + #[serde(flatten)] + meta: SessionMeta, + git: Option, + } + + let mut value = Value::deserialize(deserializer)?; + let fields = value + .as_object_mut() + .ok_or_else(|| D::Error::custom("session metadata must be an object"))?; + if !fields.contains_key("session_id") { + let thread_id = fields + .get("id") + .cloned() + .ok_or_else(|| D::Error::missing_field("id"))?; + fields.insert("session_id".to_string(), thread_id); + } + let SessionMetaLineFields { meta, git } = + serde_json::from_value(value).map_err(D::Error::custom)?; + Ok(Self { meta, git }) + } +} + #[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum RolloutItem { @@ -5390,6 +5424,7 @@ mod tests { let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?; let older_meta = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version: Some(MultiAgentVersion::V2), ..Default::default() @@ -5398,6 +5433,7 @@ mod tests { }; let newer_meta_without_version = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, multi_agent_version: None, ..Default::default() diff --git a/codex-rs/rollout/src/compression_tests.rs b/codex-rs/rollout/src/compression_tests.rs index c2a8b7d7d3..60f47d078f 100644 --- a/codex-rs/rollout/src/compression_tests.rs +++ b/codex-rs/rollout/src/compression_tests.rs @@ -456,6 +456,7 @@ fn write_rollout(path: &std::path::Path, thread_id: ThreadId, message: &str) -> fs::create_dir_all(parent)?; let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs index e6771e37c3..5f923f16d0 100644 --- a/codex-rs/rollout/src/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -33,6 +33,7 @@ async fn extract_metadata_from_rollout_uses_session_meta() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, @@ -88,6 +89,7 @@ async fn extract_metadata_from_rollout_returns_latest_memory_mode() { .join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, @@ -355,6 +357,7 @@ fn write_rollout_in_sessions_with_cwd( std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir"); let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl")); let session_meta = SessionMeta { + session_id: id.into(), id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 7ba111e944..084acb0d73 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -10,6 +10,7 @@ use std::sync::Arc; use std::sync::Mutex; use chrono::SecondsFormat; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; @@ -81,10 +82,11 @@ pub struct RolloutRecorder { #[derive(Clone)] pub enum RolloutRecorderParams { Create { + session_id: SessionId, conversation_id: ThreadId, forked_from_id: Option, parent_thread_id: Option, - source: SessionSource, + source: Box, thread_source: Option, base_instructions: BaseInstructions, dynamic_tools: Vec, @@ -167,10 +169,11 @@ impl RolloutRecorderParams { dynamic_tools: Vec, ) -> Self { Self::Create { + session_id: conversation_id.into(), conversation_id, forked_from_id, parent_thread_id, - source, + source: Box::new(source), thread_source, base_instructions, dynamic_tools, @@ -178,6 +181,13 @@ impl RolloutRecorderParams { } } + pub fn with_session_id(mut self, session_id: SessionId) -> Self { + if let Self::Create { session_id: id, .. } = &mut self { + *id = session_id; + } + self + } + pub fn with_multi_agent_version( mut self, multi_agent_version: Option, @@ -696,6 +706,7 @@ impl RolloutRecorder { ) -> std::io::Result { let (file, deferred_log_file_info, rollout_path, meta) = match params { RolloutRecorderParams::Create { + session_id, conversation_id, forked_from_id, parent_thread_id, @@ -707,7 +718,7 @@ impl RolloutRecorder { } => { let log_file_info = precompute_log_file_info(config, conversation_id)?; let path = log_file_info.path.clone(); - let session_id = log_file_info.conversation_id; + let thread_id = log_file_info.conversation_id; let started_at = log_file_info.timestamp; let timestamp_format: &[FormatItem] = format_description!( @@ -719,7 +730,8 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?; let session_meta = SessionMeta { - id: session_id, + session_id, + id: thread_id, forked_from_id, parent_thread_id, timestamp, @@ -729,7 +741,7 @@ impl RolloutRecorder { agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), agent_path: source.get_agent_path().map(Into::into), - source, + source: *source, thread_source, model_provider: Some(config.model_provider_id().to_string()), base_instructions: Some(base_instructions), diff --git a/codex-rs/rollout/src/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs index 8701578ebf..738fde8992 100644 --- a/codex-rs/rollout/src/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -3,6 +3,7 @@ use super::*; use crate::config::RolloutConfig; use chrono::TimeZone; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AgentMessageEvent; @@ -45,6 +46,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result anyhow::Result<()> { let session_meta_line = SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -145,7 +148,7 @@ async fn state_db_init_backfills_before_returning() -> anyhow::Result<()> { } #[tokio::test] -async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Result<()> { +async fn load_rollout_items_defaults_legacy_session_id() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let rollout_path = home.path().join("rollout.jsonl"); let mut file = File::create(&rollout_path)?; @@ -210,7 +213,10 @@ async fn load_rollout_items_skips_legacy_ghost_snapshot_lines() -> std::io::Resu assert_eq!(loaded_thread_id, Some(thread_id)); assert_eq!(parse_errors, 0); assert_eq!(items.len(), 2); - assert!(matches!(items[0], RolloutItem::SessionMeta(_))); + let RolloutItem::SessionMeta(session_meta) = &items[0] else { + panic!("expected session metadata"); + }; + assert_eq!(session_meta.meta.session_id, SessionId::from(thread_id)); assert!(matches!( items[1], RolloutItem::ResponseItem(ResponseItem::Message { .. }) @@ -234,6 +240,7 @@ async fn load_rollout_items_preserves_legacy_guardian_assessment_lines() -> std: "timestamp": ts, "type": "session_meta", "payload": { + "session_id": thread_id, "id": thread_id, "timestamp": ts, "cwd": ".", @@ -297,6 +304,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo "timestamp": ts, "type": "session_meta", "payload": { + "session_id": thread_id, "id": thread_id, "timestamp": ts, "cwd": ".", @@ -365,6 +373,7 @@ async fn load_rollout_items_filters_legacy_ghost_snapshots_from_compaction_histo async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); let config = test_config(home.path()); + let session_id = SessionId::default(); let thread_id = ThreadId::new(); let recorder = RolloutRecorder::new( &config, @@ -376,7 +385,8 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< /*thread_source*/ None, BaseInstructions::default(), Vec::new(), - ), + ) + .with_session_id(session_id), ) .await?; @@ -421,10 +431,12 @@ async fn recorder_materializes_on_flush_with_pending_items() -> std::io::Result< assert!(rollout_path.exists(), "rollout file should be materialized"); let text = std::fs::read_to_string(&rollout_path)?; - assert!( - text.contains("\"type\":\"session_meta\""), - "expected session metadata in rollout" - ); + let first_line = text.lines().next().expect("session metadata line"); + let session_meta: RolloutLine = serde_json::from_str(first_line)?; + let RolloutItem::SessionMeta(session_meta) = session_meta.item else { + panic!("expected session metadata in rollout"); + }; + assert_eq!(session_meta.meta.session_id, session_id); let buffered_idx = text .find("buffered-event") .expect("buffered event in rollout"); @@ -732,6 +744,7 @@ async fn list_threads_state_db_only_skips_jsonl_repair_scan() -> std::io::Result "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts, "cwd": home.path().display().to_string(), diff --git a/codex-rs/rollout/src/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs index 97496d394e..33e4ae4a59 100644 --- a/codex-rs/rollout/src/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -25,6 +25,7 @@ fn write_rollout_with_metadata(path: &Path, thread_id: ThreadId) -> std::io::Res timestamp: timestamp.clone(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs index ba92ca27f1..4b2ae4f25c 100644 --- a/codex-rs/rollout/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -158,6 +158,7 @@ fn write_rollout_with_user_message( timestamp: "2026-06-01T14:26:25Z".to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 0e3ae912dd..21d712e0d1 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -297,6 +297,7 @@ fn write_session_file_with_provider( let mut file = File::create(file_path)?; let mut payload = serde_json::json!({ + "session_id": uuid, "id": uuid, "timestamp": ts_str, "cwd": ".", @@ -370,6 +371,7 @@ fn write_goal_started_session_file( "timestamp": ts_str, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts_str, "cwd": ".", @@ -451,6 +453,7 @@ fn write_session_file_with_delayed_user_event( Uuid::from_u128(100 + i as u128) }; let payload = serde_json::json!({ + "session_id": uuid, "id": id, "timestamp": ts_str, "cwd": ".", @@ -483,8 +486,9 @@ fn write_session_file_with_meta_payload( root: &Path, ts_str: &str, uuid: Uuid, - payload: serde_json::Value, + mut payload: serde_json::Value, ) -> std::io::Result<()> { + payload["session_id"] = serde_json::json!(uuid); let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]"); let dt = PrimitiveDateTime::parse(ts_str, format) @@ -1088,6 +1092,7 @@ async fn test_get_thread_contents() { "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": ts, "cwd": ".", @@ -1267,6 +1272,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { timestamp: ts.to_string(), item: RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: conversation_id.into(), id: conversation_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index e156cc9113..b419ef3286 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -321,6 +321,7 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: Some( ThreadId::from_string(&Uuid::now_v7().to_string()).expect("thread id"), @@ -513,6 +514,7 @@ mod tests { &mut metadata, &RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/state/src/runtime/threads.rs b/codex-rs/state/src/runtime/threads.rs index 576f760264..a448dce56b 100644 --- a/codex-rs/state/src/runtime/threads.rs +++ b/codex-rs/state/src/runtime/threads.rs @@ -1957,6 +1957,7 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, @@ -2018,6 +2019,7 @@ mod tests { ); let items = vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, forked_from_id: None, parent_thread_id: None, diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index a60409b740..a93e3644c0 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -110,6 +110,7 @@ mod tests { ] { store .create_thread(CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, @@ -231,6 +232,7 @@ impl InMemoryThreadStore { let mut state = self.state.lock().await; state.calls.create_thread += 1; let session_meta = SessionMeta { + session_id: params.session_id, id: params.thread_id, forked_from_id: params.forked_from_id, parent_thread_id: params.parent_thread_id, diff --git a/codex-rs/thread-store/src/local/create_thread.rs b/codex-rs/thread-store/src/local/create_thread.rs index 05924881d5..51a96b0815 100644 --- a/codex-rs/thread-store/src/local/create_thread.rs +++ b/codex-rs/thread-store/src/local/create_thread.rs @@ -36,6 +36,7 @@ pub(super) async fn create_thread( params.base_instructions, params.dynamic_tools, ) + .with_session_id(params.session_id) .with_multi_agent_version(params.multi_agent_version), ) .await diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index 19a0d2955b..e158c901ab 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -1123,6 +1123,7 @@ mod tests { fn create_thread_params(thread_id: ThreadId) -> CreateThreadParams { CreateThreadParams { + session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, diff --git a/codex-rs/thread-store/src/local/read_thread.rs b/codex-rs/thread-store/src/local/read_thread.rs index a1e1c59190..1271e4fcf5 100644 --- a/codex-rs/thread-store/src/local/read_thread.rs +++ b/codex-rs/thread-store/src/local/read_thread.rs @@ -821,6 +821,7 @@ mod tests { "timestamp": "2025-01-03T12:00:00Z", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12:00:00Z", "cwd": rollout_cwd, @@ -926,6 +927,7 @@ mod tests { "timestamp": "2025-01-03T12-00-00", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12-00-00", "cwd": home.path(), @@ -1091,6 +1093,7 @@ mod tests { "timestamp": "2025-01-03T12:00:00Z", "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "timestamp": "2025-01-03T12:00:00Z", "cwd": home.path(), diff --git a/codex-rs/thread-store/src/local/test_support.rs b/codex-rs/thread-store/src/local/test_support.rs index 98321880ff..10a55c4fd6 100644 --- a/codex-rs/thread-store/src/local/test_support.rs +++ b/codex-rs/thread-store/src/local/test_support.rs @@ -77,6 +77,7 @@ pub(super) fn write_session_file_with_fork( "timestamp": ts, "type": "session_meta", "payload": { + "session_id": uuid, "id": uuid, "forked_from_id": forked_from_id, "timestamp": ts, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 0336006fb8..7940f1c80f 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -579,6 +579,7 @@ mod tests { fn session_meta(thread_id: ThreadId) -> SessionMetaLine { SessionMetaLine { meta: SessionMeta { + session_id: thread_id.into(), id: thread_id, timestamp: "2025-01-03T12:00:00Z".to_string(), source: SessionSource::Exec, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 2e3c60723f..74412bb9cd 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use chrono::DateTime; use chrono::Utc; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; @@ -63,6 +64,8 @@ pub struct ExtraConfig {} /// Parameters required to create a persisted thread. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateThreadParams { + /// Session id shared by the root thread and all of its subagents. + pub session_id: SessionId, /// Thread id generated by Codex before opening persistence. pub thread_id: ThreadId, /// Optional extra configuration fields for the thread. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f189b23149..82d9a72bd8 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -2041,6 +2041,7 @@ mod tests { std::fs::create_dir_all(parent)?; let session_meta = codex_protocol::protocol::SessionMeta { + session_id: thread_id.into(), id: thread_id, timestamp: meta_rfc3339.to_string(), cwd: cwd.to_path_buf(), From c03742ca0a78a8e54cd881032a2327363678b5aa Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 09:05:36 +0100 Subject: [PATCH 23/86] Simplify multi-agent mode controls (#29324) ## Why Multi-agent delegation policy was split across `multiAgentMode`, `features.multi_agent_mode`, and `usage_hint_enabled`. These controls could disagree: a requested mode could be downgraded by the feature flag, and disabling usage hints also disabled mode instructions. Some clients also need multi-agent tools without adding delegation-policy text to model context. The previous two-mode API could not express that directly. ## What changed `multiAgentMode` is now the only live delegation-policy control: | Mode | Behavior | | --- | --- | | `none` | Keep multi-agent tools available without adding mode instructions. | | `explicitRequestOnly` | Only delegate after an explicit user request. | | `proactive` | Delegate when parallel work materially improves speed or quality. | - new threads default to `explicitRequestOnly`; omitting the mode on later turns keeps the current value - thread start, resume, fork, and settings responses always report the concrete current mode instead of `null` - mode selection remains sticky across turns and resume - usage-hint text no longer controls whether mode instructions apply - `features.multi_agent_mode` and `usage_hint_enabled` remain accepted as ignored compatibility settings so existing configs continue to load - app-server documentation and generated schemas describe the three-mode API ## Tests - `just test -p codex-core multi_agent_mode` - `just test -p codex-core multi_agent_v2_config_from_feature_table` - `just test -p codex-core spawn_agent_description` - `just test -p codex-features` - `just test -p codex-app-server-protocol` - `just test -p codex-app-server multi_agent_mode` --- .../schema/json/ClientRequest.json | 3 +- .../schema/json/ServerNotification.json | 3 +- .../codex_app_server_protocol.schemas.json | 3 +- .../codex_app_server_protocol.v2.schemas.json | 3 +- .../schema/json/v2/ThreadForkResponse.json | 3 +- .../schema/json/v2/ThreadResumeResponse.json | 3 +- .../v2/ThreadSettingsUpdatedNotification.json | 3 +- .../schema/json/v2/ThreadStartParams.json | 3 +- .../schema/json/v2/ThreadStartResponse.json | 3 +- .../schema/json/v2/TurnStartParams.json | 3 +- .../schema/typescript/MultiAgentMode.ts | 8 +- .../src/protocol/common.rs | 5 +- .../src/protocol/v2/tests.rs | 7 +- .../src/protocol/v2/thread.rs | 22 +- .../src/protocol/v2/turn.rs | 5 +- codex-rs/app-server/README.md | 8 +- .../tests/suite/v2/thread_settings_update.rs | 7 +- .../app-server/tests/suite/v2/turn_start.rs | 30 +-- codex-rs/core/config.schema.json | 1 + codex-rs/core/src/agent/control/spawn.rs | 1 - codex-rs/core/src/agent/control_tests.rs | 54 ++-- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/config/config_tests.rs | 2 - codex-rs/core/src/config/mod.rs | 8 - .../context/multi_agent_mode_instructions.rs | 2 + codex-rs/core/src/context_manager/updates.rs | 5 +- codex-rs/core/src/session/config_lock.rs | 1 - codex-rs/core/src/session/mod.rs | 21 +- codex-rs/core/src/session/multi_agents.rs | 35 +-- codex-rs/core/src/session/session.rs | 4 +- codex-rs/core/src/session/tests.rs | 7 +- codex-rs/core/src/session/turn_context.rs | 4 +- codex-rs/core/src/thread_manager.rs | 2 +- .../src/tools/handlers/multi_agents_spec.rs | 11 - .../tools/handlers/multi_agents_spec_tests.rs | 4 - .../src/tools/handlers/multi_agents_tests.rs | 11 +- .../tools/handlers/multi_agents_v2/spawn.rs | 4 - codex-rs/core/src/tools/spec_plan.rs | 2 - codex-rs/core/tests/suite/multi_agent_mode.rs | 247 +++++++++--------- codex-rs/features/src/feature_configs.rs | 1 + codex-rs/features/src/lib.rs | 4 +- codex-rs/features/src/tests.rs | 39 --- codex-rs/protocol/src/config_types.rs | 7 +- codex-rs/protocol/src/protocol.rs | 2 +- 44 files changed, 253 insertions(+), 350 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 4ce6ac1a82..18c147ba36 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1761,8 +1761,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index f1fbee5c57..74fb9e739c 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2645,8 +2645,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index bf7f0e5b30..ec0321d9b1 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12693,8 +12693,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 7a080b56bd..cd0f46bd47 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9097,8 +9097,9 @@ "type": "object" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index 11efbb7e82..c19251667a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index de242b62b3..1a3b22f12c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json index 67a0f5c5d9..ca2b63634a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadSettingsUpdatedNotification.json @@ -109,8 +109,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index eebb5da960..9932456f1b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -195,8 +195,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 4ad961c22c..a754c6bc0f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -663,8 +663,9 @@ ] }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 9460f80688..bec8b7d89a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -142,8 +142,9 @@ "type": "string" }, "MultiAgentMode": { - "description": "Controls whether the model should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help.", + "description": "Controls whether the model receives multi-agent delegation instructions and, when it does, whether it should only spawn sub-agents after an explicit user request or may delegate proactively when doing so would help. `none` leaves the multi-agent tools available without injecting delegation instructions.", "enum": [ + "none", "explicitRequestOnly", "proactive" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts index 93b790a592..b59be94f8e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/MultiAgentMode.ts @@ -3,7 +3,9 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. /** - * Controls whether the model should only spawn sub-agents after an explicit - * user request or may delegate proactively when doing so would help. + * Controls whether the model receives multi-agent delegation instructions and, + * when it does, whether it should only spawn sub-agents after an explicit user + * request or may delegate proactively when doing so would help. `none` leaves + * the multi-agent tools available without injecting delegation instructions. */ -export type MultiAgentMode = "explicitRequestOnly" | "proactive"; +export type MultiAgentMode = "none" | "explicitRequestOnly" | "proactive"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index eb3e6c82ff..0b1e11133d 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1701,6 +1701,7 @@ mod tests { use codex_protocol::ThreadId; use codex_protocol::account::AmazonBedrockCredentialSource; use codex_protocol::account::PlanType; + use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; use codex_protocol::parse_command::ParsedCommand; use codex_protocol::protocol::RealtimeConversationVersion; @@ -2584,7 +2585,7 @@ mod tests { sandbox: v2::SandboxPolicy::DangerFullAccess, active_permission_profile: None, reasoning_effort: None, - multi_agent_mode: None, + multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, }, }; @@ -2633,7 +2634,7 @@ mod tests { }, "activePermissionProfile": null, "reasoningEffort": null, - "multiAgentMode": null + "multiAgentMode": "explicitRequestOnly" } }), serde_json::to_value(&response)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 0161cd4ef9..74e534be00 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -1,5 +1,6 @@ use super::*; use codex_protocol::approvals::ElicitationRequest as CoreElicitationRequest; +use codex_protocol::config_types::MultiAgentMode; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::FileChangeItem; @@ -3708,7 +3709,11 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { resume.multi_agent_mode, fork.multi_agent_mode, ), - (None, None, None) + ( + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + MultiAgentMode::ExplicitRequestOnly, + ) ); let foreign_source: LegacyAppPathString = diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 3c527d6219..6a51f71e51 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -94,9 +94,9 @@ pub struct ThreadStartParams { pub developer_instructions: Option, #[ts(optional = nullable)] pub personality: Option, - /// Set the initial multi-agent mode for this thread. - /// Omitted leaves the thread without a selected mode. Eligible multi-agent - /// v2 turns still default to `explicitRequestOnly`. + /// Set the initial multi-agent mode for this thread. `none` leaves the + /// multi-agent tools available without injecting mode instructions. + /// Omitted defaults to `explicitRequestOnly`. #[experimental("thread/start.multiAgentMode")] #[ts(optional = nullable)] pub multi_agent_mode: Option, @@ -186,10 +186,10 @@ pub struct ThreadStartResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/start.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } impl ThreadStartResponse { @@ -279,10 +279,10 @@ pub struct ThreadSettings { pub effort: Option, pub summary: Option, pub collaboration_mode: CollaborationMode, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/settings.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, pub personality: Option, } @@ -419,10 +419,10 @@ pub struct ThreadResumeResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/resume.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, /// `thread/turns/list` page returned when requested by `initialTurnsPage`. #[experimental("thread/resume.initialTurnsPage")] #[serde(default)] @@ -578,10 +578,10 @@ pub struct ThreadForkResponse { #[serde(default)] pub active_permission_profile: Option, pub reasoning_effort: Option, - /// Current selected multi-agent mode for this thread, if one was selected. + /// Current multi-agent mode for this thread. #[experimental("thread/fork.multiAgentMode")] #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } impl ThreadForkResponse { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 7ecb9f94b7..1fc35d1515 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -151,8 +151,9 @@ pub struct TurnStartParams { #[ts(optional = nullable)] pub collaboration_mode: Option, - /// Controls whether multi-agent v2 delegation requires an explicit user request. - /// Omitted keeps the loaded session's current mode. + /// Controls multi-agent v2 delegation instructions. `none` leaves the + /// multi-agent tools available without injecting mode instructions. Omitted + /// keeps the loaded session's current mode. #[experimental("turn/start.multiAgentMode")] #[ts(optional = nullable)] pub multi_agent_mode: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8de6e3b8c7..e823eb1007 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -137,17 +137,17 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode; omission leaves the selected mode unset, while eligible multi-agent v2 turns still default to `explicitRequestOnly`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. For permissions, prefer experimental `permissions` profile selection by id; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `multiAgentMode` selects the initial thread mode and defaults to `explicitRequestOnly` when omitted; use `none` to keep multi-agent tools available without injecting mode instructions. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. Experimental `selectedCapabilityRoots` selects environment-owned plugin or standalone-skill roots. Skills found below those roots are listed and read through the owning environment. Stdio MCP servers declared by selected plugins are also started in that environment; HTTP MCP declarations remain inactive. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. Multi-agent mode restores the last effective mode from rollout history when available; clients can select another mode on the first `turn/start`. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current selected mode or `null` when no mode was selected. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. `instructionSources` lists loaded instruction files using each source environment's native absolute path syntax, including files loaded from remote environments. Experimental clients can read `runtimeWorkspaceRoots` for the thread-scoped runtime roots and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. Their experimental `multiAgentMode` field, and the corresponding thread setting, report the thread's current mode. Turn construction separately determines whether that mode is applicable to the selected model and runtime configuration. - `thread/list` — page through stored threads; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Experimental clients can use `parentThreadId` to filter direct spawned children represented by persisted spawn-edge state. Review and Guardian threads are not included because they do not participate in that spawn-edge lifecycle. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. Subagent threads also include `parentThreadId` when the immediate parent is known. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/turns/list` — experimental; page through a stored thread’s turn history without resuming it; supports cursor-based pagination with `sortDirection`, `itemsView`, `nextCursor`, and `backwardsCursor`. - `thread/turns/items/list` — experimental; reserved for paging full items for one turn. The API shape is present, but app-server currently returns an unsupported-method JSON-RPC error. - `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`. -- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `explicitRequestOnly` or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. +- `thread/settings/update` — experimental; queue a partial update to a loaded thread’s next-turn settings without starting a turn or adding transcript items. Omitted fields leave settings unchanged; `serviceTier: null` clears the tier; `multiAgentMode` selects `none`, `explicitRequestOnly`, or `proactive` for subsequent turns; `sandboxPolicy` and `permissions` cannot be combined. Returns `{}` when the update is accepted and emits `thread/settings/updated` with the full effective settings only if they actually change. `turn/start` settings overrides emit the same notification when they change the stored settings. - `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success. - `memory/reset` — experimental; clear the current `CODEX_HOME/memories` directory and reset persisted memory stage data in sqlite while preserving existing thread memory modes; returns `{}` on success. - `thread/goal/set` — create or update the single persisted goal for a materialized thread; returns the current goal and emits `thread/goal/updated`. @@ -168,7 +168,7 @@ Example with notification opt-out: - `thread/backgroundTerminals/list` — list running background terminals for a loaded thread (experimental; requires `capabilities.experimentalApi`); returns `data` with the running terminal ids. - `thread/backgroundTerminals/terminate` — terminate one running background terminal by app-server `processId` (experimental; requires `capabilities.experimentalApi`); returns whether a process was terminated. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `explicitRequestOnly` or `proactive`; omission keeps the loaded session's current selected mode, including an unset mode. The requested mode is retained for the loaded session without rejecting unsupported configurations. Eligible multi-agent v2 turns default an unset mode to explicit-request-only, use the selected mode when `features.multi_agent_mode` is enabled, and otherwise use explicit-request-only developer instructions. +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Experimental `runtimeWorkspaceRoots` replaces the thread-scoped runtime workspace roots used to materialize `:workspace_roots`; paths must be absolute. Prefer experimental `permissions` profile selection by id for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". Experimental `multiAgentMode` accepts `none`, `explicitRequestOnly`, or `proactive`; `none` keeps the tools available without injecting mode instructions, and omission keeps the loaded session's current mode. The requested mode is retained for the loaded session without rejecting unsupported configurations, and eligible multi-agent v2 turns use it directly. - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. `clientUserMessageId` is optional; when supplied, the corresponding `userMessage` item echoes it as `clientId`. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. diff --git a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs index efdf13aae9..9ad25ca992 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_settings_update.rs @@ -117,10 +117,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re write_mock_responses_config_toml( codex_home.path(), &server.uri(), - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), /*auto_compact_limit*/ 200_000, /*requires_openai_auth*/ None, "mock_provider", @@ -158,7 +155,7 @@ async fn thread_settings_update_multi_agent_mode_applies_to_future_turns() -> Re assert_eq!(updated.thread_id, thread.id); assert_eq!( updated.thread_settings.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); start_text_turn(&mut mcp, thread.id).await?; diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 10d1599967..3edd8da6fb 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -1768,10 +1768,7 @@ async fn turn_start_accepts_multi_agent_mode_v2() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -1845,10 +1842,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { codex_home.path(), &server.uri(), "never", - &BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), + &BTreeMap::from([(Feature::MultiAgentV2, true)]), )?; let mut mcp = TestAppServer::new(codex_home.path()).await?; @@ -1871,7 +1865,7 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { multi_agent_mode, .. } = to_response::(thread_resp)?; - assert_eq!(multi_agent_mode, Some(MultiAgentMode::Proactive)); + assert_eq!(multi_agent_mode, MultiAgentMode::Proactive); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -1911,27 +1905,29 @@ async fn thread_start_multi_agent_mode_initializes_first_turn() -> Result<()> { } #[tokio::test] -async fn thread_start_reports_selected_multi_agent_mode() -> Result<()> { +async fn thread_start_reports_multi_agent_mode() -> Result<()> { skip_if_no_network!(Ok(())); let cases = [ ( BTreeMap::from([(Feature::MultiAgentV2, true)]), Some(MultiAgentMode::Proactive), - Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, + ), + ( + BTreeMap::from([(Feature::MultiAgentV2, true)]), + Some(MultiAgentMode::None), + MultiAgentMode::None, ), ( BTreeMap::new(), Some(MultiAgentMode::Proactive), - Some(MultiAgentMode::Proactive), + MultiAgentMode::Proactive, ), ( - BTreeMap::from([ - (Feature::MultiAgentV2, true), - (Feature::MultiAgentMode, true), - ]), - None, + BTreeMap::from([(Feature::MultiAgentV2, true)]), None, + MultiAgentMode::ExplicitRequestOnly, ), ]; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 003ca63393..b6736c9407 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1725,6 +1725,7 @@ "type": "string" }, "usage_hint_enabled": { + "description": "Deprecated compatibility field. Its value is ignored.", "type": "boolean" }, "usage_hint_text": { diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 3f746ef790..05fac6aaf3 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -498,7 +498,6 @@ impl AgentControl { } if preserve_reference_context_item && multi_agent_version == MultiAgentVersion::V2 - && config.multi_agent_v2.usage_hint_enabled && let Some(subagent_usage_hint_text) = config.multi_agent_v2.subagent_usage_hint_text.clone() && let Some(subagent_usage_hint_message) = diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 8dbcab3380..7eac69a88f 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -850,10 +850,7 @@ async fn spawn_thread_subagent_uses_supplied_initial_multi_agent_mode_without_hi .config_snapshot() .await; - assert_eq!( - child_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(child_snapshot.multi_agent_mode, MultiAgentMode::Proactive); } #[tokio::test] @@ -987,7 +984,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("child thread should be registered"); assert_eq!( child_thread.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; @@ -1025,18 +1022,13 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { "full-history forked child should preserve the parent diff baseline" ); - let mut disabled_hint_child_config = harness.config.clone(); - let _ = disabled_hint_child_config - .features - .enable(Feature::MultiAgentV2); - disabled_hint_child_config.multi_agent_v2.usage_hint_enabled = false; - disabled_hint_child_config - .multi_agent_v2 - .subagent_usage_hint_text = Some("Disabled child subagent guidance.".to_string()); - let disabled_hint_child_thread_id = harness + let mut no_hint_child_config = harness.config.clone(); + let _ = no_hint_child_config.features.enable(Feature::MultiAgentV2); + no_hint_child_config.multi_agent_v2.subagent_usage_hint_text = None; + let no_hint_child_thread_id = harness .control .spawn_agent_with_metadata( - disabled_hint_child_config, + no_hint_child_config, text_input("child task without hints"), Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn { parent_thread_id, @@ -1052,24 +1044,17 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { }, ) .await - .expect("forked spawn should honor disabled usage hints") + .expect("forked spawn should honor an empty subagent usage hint") .thread_id; - let disabled_hint_child_thread = harness + let no_hint_child_thread = harness .manager - .get_thread(disabled_hint_child_thread_id) + .get_thread(no_hint_child_thread_id) .await - .expect("disabled-hint child thread should be registered"); - let disabled_hint_history = disabled_hint_child_thread - .codex - .session - .clone_history() - .await; + .expect("no-hint child thread should be registered"); + let no_hint_history = no_hint_child_thread.codex.session.clone_history().await; assert!( - !history_contains_text( - disabled_hint_history.raw_items(), - "Disabled child subagent guidance.", - ), - "full-history forked child should not add subagent guidance when usage hints are disabled" + !history_contains_text(no_hint_history.raw_items(), "Child subagent guidance."), + "full-history forked child should not add empty subagent guidance" ); let expected = ( @@ -1099,9 +1084,9 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { .expect("child shutdown should submit"); let _ = harness .control - .shutdown_live_agent(disabled_hint_child_thread_id) + .shutdown_live_agent(no_hint_child_thread_id) .await - .expect("disabled-hint child shutdown should submit"); + .expect("no-hint child shutdown should submit"); let _ = parent_thread .submit(Op::Shutdown {}) .await @@ -1406,7 +1391,7 @@ async fn spawn_agent_fork_last_n_turns_keeps_only_recent_turns() { .expect("child thread should be registered"); assert_eq!( child_thread.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); let history = child_thread.codex.session.clone_history().await; @@ -2422,10 +2407,7 @@ async fn resume_thread_subagent_restores_stored_metadata_and_effective_multi_age assert_eq!(resumed_agent_path, Some(agent_path)); assert_eq!(resumed_nickname, Some(original_nickname)); assert_eq!(resumed_role, Some("explorer".to_string())); - assert_eq!( - resumed_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(resumed_snapshot.multi_agent_mode, MultiAgentMode::Proactive); let _ = harness .control diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 0f63323d48..85ca1e1a61 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -72,7 +72,7 @@ pub struct ThreadConfigSnapshot { pub reasoning_summary: Option, pub personality: Option, pub collaboration_mode: CollaborationMode, - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, pub session_source: SessionSource, pub forked_from_thread_id: Option, pub parent_thread_id: Option, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index ce93740166..be332cd603 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -10114,7 +10114,6 @@ max_concurrent_threads_per_session = 5 min_wait_timeout_ms = 2500 max_wait_timeout_ms = 120000 default_wait_timeout_ms = 30000 -usage_hint_enabled = false usage_hint_text = "Custom delegation guidance." root_agent_usage_hint_text = "Root guidance." subagent_usage_hint_text = "Subagent guidance." @@ -10142,7 +10141,6 @@ non_code_mode_only = true ), (None, Some(4)) ); - assert!(!config.multi_agent_v2.usage_hint_enabled); assert_eq!( config.multi_agent_v2.usage_hint_text.as_deref(), Some("Custom delegation guidance.") diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index d5986501a4..dba933b111 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -241,8 +241,6 @@ You may also see them addressed as to=/root/..., which indicates your identity i "#; const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.spawn_agent` without a configured namespace or `to=functions.agents.spawn_agent` with `tool_namespace = "agents"`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. -The goal is to correctly solve the problem in as little time as possible. Therefore, if at any point you can parallelize work by delegating tasks to another agent, you should do so to save time. - All agents share the same directory. In detail: - All agents have access to the same container and filesystem as you. - All agents use the same current working directory. @@ -1128,7 +1126,6 @@ pub struct MultiAgentV2Config { pub min_wait_timeout_ms: i64, pub max_wait_timeout_ms: i64, pub default_wait_timeout_ms: i64, - pub usage_hint_enabled: bool, pub usage_hint_text: Option, pub root_agent_usage_hint_text: Option, pub subagent_usage_hint_text: Option, @@ -1144,7 +1141,6 @@ impl MultiAgentV2Config { min_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MIN_WAIT_TIMEOUT_MS, max_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_MAX_WAIT_TIMEOUT_MS, default_wait_timeout_ms: DEFAULT_MULTI_AGENT_V2_DEFAULT_WAIT_TIMEOUT_MS, - usage_hint_enabled: true, usage_hint_text: None, root_agent_usage_hint_text: Some(default_multi_agent_v2_usage_hint_text( DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, @@ -2492,9 +2488,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config let default_wait_timeout_ms = base .and_then(|config| config.default_wait_timeout_ms) .unwrap_or(default.default_wait_timeout_ms); - let usage_hint_enabled = base - .and_then(|config| config.usage_hint_enabled) - .unwrap_or(default.usage_hint_enabled); let usage_hint_text = base .and_then(|config| config.usage_hint_text.as_ref()) .cloned() @@ -2523,7 +2516,6 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config min_wait_timeout_ms, max_wait_timeout_ms, default_wait_timeout_ms, - usage_hint_enabled, usage_hint_text, root_agent_usage_hint_text, subagent_usage_hint_text, diff --git a/codex-rs/core/src/context/multi_agent_mode_instructions.rs b/codex-rs/core/src/context/multi_agent_mode_instructions.rs index a272e7c2bb..fc4debe5a2 100644 --- a/codex-rs/core/src/context/multi_agent_mode_instructions.rs +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -4,6 +4,7 @@ use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const NO_MULTI_AGENT_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive. Any earlier multi-agent mode developer message no longer applies."; const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier instruction requiring an explicit user request before spawning sub-agents no longer applies. Use sub-agents when parallel work would materially improve speed or quality. This mode remains active until a later multi-agent mode developer message changes it."; #[derive(Debug, Clone, PartialEq, Eq)] @@ -32,6 +33,7 @@ impl ContextualUserFragment for MultiAgentModeInstructions { fn body(&self) -> String { match self.multi_agent_mode { + MultiAgentMode::None => NO_MULTI_AGENT_MODE_TEXT.to_string(), MultiAgentMode::ExplicitRequestOnly => { EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string() } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 7cf877a728..22f2dbe6fe 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -103,10 +103,8 @@ fn build_multi_agent_mode_update_item( ) -> Option { let effective_multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( next.multi_agent_version, - &next.config.multi_agent_v2, &next.session_source, next.multi_agent_mode, - next.config.features.enabled(Feature::MultiAgentMode), ); let previous = previous?; if previous.multi_agent_mode == effective_multi_agent_mode { @@ -114,6 +112,9 @@ fn build_multi_agent_mode_update_item( } match effective_multi_agent_mode { + Some(MultiAgentMode::None) => { + Some(MultiAgentModeInstructions::new(MultiAgentMode::None).render()) + } Some(multi_agent_mode) => Some(MultiAgentModeInstructions::new(multi_agent_mode).render()), None if previous.multi_agent_mode == Some(MultiAgentMode::Proactive) => { Some(MultiAgentModeInstructions::new(MultiAgentMode::ExplicitRequestOnly).render()) diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index 2553f71504..e91c4f4895 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -327,7 +327,6 @@ mod tests { min_wait_timeout_ms: Some(_), max_wait_timeout_ms: Some(_), default_wait_timeout_ms: Some(_), - usage_hint_enabled: Some(_), hide_spawn_agent_metadata: Some(_), .. }) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index c85898555b..f3c7a950dd 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -586,7 +586,7 @@ impl Codex { .await; let multi_agent_version = resolve_multi_agent_version(&conversation_history, inherited_multi_agent_version); - let multi_agent_mode = initial_multi_agent_mode; + let multi_agent_mode = initial_multi_agent_mode.unwrap_or_default(); config .validate_multi_agent_v2_config() .map_err(|err| CodexErr::InvalidRequest(err.to_string()))?; @@ -3290,19 +3290,20 @@ impl Session { { items.push(usage_hint_message); } - if let Some(multi_agent_mode) = multi_agents::effective_multi_agent_mode( + match multi_agents::effective_multi_agent_mode( turn_context.multi_agent_version, - &turn_context.config.multi_agent_v2, &session_source, turn_context.multi_agent_mode, - turn_context - .config - .features - .enabled(Feature::MultiAgentMode), ) { - items.push(ContextualUserFragment::into( - MultiAgentModeInstructions::new(multi_agent_mode), - )); + Some( + multi_agent_mode + @ (MultiAgentMode::ExplicitRequestOnly | MultiAgentMode::Proactive), + ) => { + items.push(ContextualUserFragment::into( + MultiAgentModeInstructions::new(multi_agent_mode), + )); + } + Some(MultiAgentMode::None) | None => {} } if let Some(contextual_user_message) = crate::context_manager::updates::build_contextual_user_message(contextual_user_sections) diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 38651380eb..82fcfd1282 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -14,10 +14,6 @@ pub(super) fn usage_hint_text<'a>( } let multi_agent_v2 = &turn_context.config.multi_agent_v2; - if !multi_agent_v2.usage_hint_enabled { - return None; - } - configured_usage_hint_text_for_source(multi_agent_v2, session_source) } @@ -39,30 +35,23 @@ fn configured_usage_hint_text_for_source<'a>( } } -fn multi_agent_mode_is_applicable( - multi_agent_version: MultiAgentVersion, - multi_agent_v2: &MultiAgentV2Config, - session_source: &SessionSource, -) -> bool { - multi_agent_version == MultiAgentVersion::V2 - && multi_agent_v2.usage_hint_enabled - && configured_usage_hint_text_for_source(multi_agent_v2, session_source).is_some() -} - pub(crate) fn effective_multi_agent_mode( multi_agent_version: MultiAgentVersion, - multi_agent_v2: &MultiAgentV2Config, session_source: &SessionSource, - requested_multi_agent_mode: Option, - multi_agent_mode_enabled: bool, + multi_agent_mode: MultiAgentMode, ) -> Option { - if !multi_agent_mode_is_applicable(multi_agent_version, multi_agent_v2, session_source) { + if multi_agent_version != MultiAgentVersion::V2 { return None; } - Some(if multi_agent_mode_enabled { - requested_multi_agent_mode.unwrap_or_default() - } else { - MultiAgentMode::ExplicitRequestOnly - }) + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { .. }) + | SessionSource::Cli + | SessionSource::VSCode + | SessionSource::Exec + | SessionSource::Mcp + | SessionSource::Custom(_) + | SessionSource::Unknown => Some(multi_agent_mode), + SessionSource::Internal(_) | SessionSource::SubAgent(_) => None, + } } diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 9317428340..759d159cc2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -52,7 +52,7 @@ pub(crate) struct SessionConfiguration { pub(super) provider: ModelProviderInfo, pub(super) collaboration_mode: CollaborationMode, - pub(super) multi_agent_mode: Option, + pub(super) multi_agent_mode: MultiAgentMode, pub(super) model_reasoning_summary: Option, pub(super) service_tier: Option, @@ -231,7 +231,7 @@ impl SessionConfiguration { next_configuration.collaboration_mode = collaboration_mode; } if let Some(multi_agent_mode) = updates.multi_agent_mode { - next_configuration.multi_agent_mode = Some(multi_agent_mode); + next_configuration.multi_agent_mode = multi_agent_mode; } if let Some(summary) = updates.reasoning_summary { next_configuration.model_reasoning_summary = Some(summary); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index f3f96b74a7..edd5ace9ed 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7895,15 +7895,14 @@ async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_feature_dis } #[tokio::test] -async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_disabled() { +async fn build_initial_context_omits_multi_agent_v2_usage_hints_when_hint_is_empty() { let (session, turn_context, _rx_event) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { let _ = config.features.enable(Feature::MultiAgentV2); - config.multi_agent_v2.usage_hint_enabled = false; - config.multi_agent_v2.root_agent_usage_hint_text = Some("Root guidance.".to_string()); - config.multi_agent_v2.subagent_usage_hint_text = Some("Subagent guidance.".to_string()); + config.multi_agent_v2.root_agent_usage_hint_text = None; + config.multi_agent_v2.subagent_usage_hint_text = None; }, ) .await; diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index bb434fa2c9..2fb02e7fc1 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -125,7 +125,7 @@ pub struct TurnContext { pub(crate) developer_instructions: Option, pub(crate) user_instructions: Option, pub(crate) collaboration_mode: CollaborationMode, - pub(crate) multi_agent_mode: Option, + pub(crate) multi_agent_mode: MultiAgentMode, pub(crate) multi_agent_version: MultiAgentVersion, pub(crate) personality: Option, pub(crate) approval_policy: Constrained, @@ -386,10 +386,8 @@ impl TurnContext { multi_agent_version: Some(self.multi_agent_version), multi_agent_mode: super::multi_agents::effective_multi_agent_mode( self.multi_agent_version, - &self.config.multi_agent_v2, &self.session_source, self.multi_agent_mode, - self.config.features.enabled(Feature::MultiAgentMode), ), realtime_active: Some(self.realtime_active), effort: self.reasoning_effort.clone(), diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 3a0e374750..2cce3bb8a8 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -976,7 +976,7 @@ impl ThreadManager { }; let initial_multi_agent_mode = match source_thread_id { Some(thread_id) => match self.get_thread(thread_id).await { - Ok(thread) => thread.config_snapshot().await.multi_agent_mode, + Ok(thread) => Some(thread.config_snapshot().await.multi_agent_mode), Err(_) => history.get_latest_effective_multi_agent_mode(), }, None => history.get_latest_effective_multi_agent_mode(), diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs index 70f71f3114..08f8ca071f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec.rs @@ -24,7 +24,6 @@ pub struct SpawnAgentToolOptions { pub available_models: Vec, pub agent_type_description: String, pub hide_agent_type_model_reasoning: bool, - pub include_usage_hint: bool, pub usage_hint_text: Option, } @@ -66,7 +65,6 @@ pub fn create_spawn_agent_tool_v1(options: SpawnAgentToolOptions) -> ToolSpec { available_models_description.as_deref(), inherited_model_guidance, return_value_description, - options.include_usage_hint, options.usage_hint_text, ), strict: false, @@ -99,7 +97,6 @@ pub fn create_spawn_agent_tool_v2(options: SpawnAgentToolOptions) -> ToolSpec { description: spawn_agent_tool_description_v2( available_models_description.as_deref(), inherited_model_guidance, - options.include_usage_hint, options.usage_hint_text, ), strict: false, @@ -648,7 +645,6 @@ fn spawn_agent_tool_description( available_models_description: Option<&str>, inherited_model_guidance: Option<&str>, return_value_description: &str, - include_usage_hint: bool, usage_hint_text: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); @@ -660,9 +656,6 @@ fn spawn_agent_tool_description( Spawn a sub-agent for a well-scoped task. {return_value_description} {inherited_model_guidance}"# ); - if !include_usage_hint { - return tool_description; - } if let Some(usage_hint_text) = usage_hint_text { return format!( r#" @@ -711,7 +704,6 @@ Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegati fn spawn_agent_tool_description_v2( available_models_description: Option<&str>, inherited_model_guidance: Option<&str>, - include_usage_hint: bool, usage_hint_text: Option, ) -> String { let agent_role_guidance = available_models_description.unwrap_or_default(); @@ -731,9 +723,6 @@ The new agent's canonical task name will be provided to it along with the messag Note that passing `fork_turns="none"` will not pass any surrounding context to the spawned subagent, which may cause the agent to lack the context it needs to complete its task, whereas `fork_turns="all"` will provide the subagent with all surrounding context."# ); - if !include_usage_hint { - return tool_description; - } if let Some(usage_hint_text) = usage_hint_text { return format!( r#" diff --git a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs index 1ef93b77f0..0228f7acb9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_spec_tests.rs @@ -45,7 +45,6 @@ fn spawn_agent_tool_v2_requires_task_name_and_lists_visible_models() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -121,7 +120,6 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() { available_models: Vec::new(), agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -178,7 +176,6 @@ fn spawn_agent_tool_caps_visible_model_summaries() { ], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: false, - include_usage_hint: true, usage_hint_text: None, }); @@ -222,7 +219,6 @@ fn spawn_agent_tool_hides_service_tier_with_spawn_metadata() { available_models: vec![model_preset("visible", /*show_in_picker*/ true)], agent_type_description: "role help".to_string(), hide_agent_type_model_reasoning: true, - include_usage_hint: true, usage_hint_text: None, }); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index eecdfc178d..76881f5a06 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1148,11 +1148,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - turn.multi_agent_mode = Some(MultiAgentMode::Proactive); + turn.multi_agent_mode = MultiAgentMode::Proactive; set_turn_config(&mut turn, config); let session = Arc::new(session); @@ -1191,10 +1187,7 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat child_snapshot.session_source.get_agent_path().as_deref(), Some("/root/test_process") ); - assert_eq!( - child_snapshot.multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); + assert_eq!(child_snapshot.multi_agent_mode, MultiAgentMode::Proactive); assert!(manager.captured_ops().iter().any(|(id, op)| { *id == child_thread_id && matches!( diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index ed63102d6a..c04d46c6e4 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -52,12 +52,8 @@ async fn handle_spawn_agent( let fork_mode = args.fork_mode()?; let multi_agent_mode = crate::session::multi_agents::effective_multi_agent_mode( turn.multi_agent_version, - &turn.config.multi_agent_v2, &turn.session_source, turn.multi_agent_mode, - turn.config - .features - .enabled(codex_features::Feature::MultiAgentMode), ); let role_name = args .agent_type diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index 3e2268d92d..c83422f1a0 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -788,7 +788,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu .config .multi_agent_v2 .hide_spawn_agent_metadata, - include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), }), tool_namespace, @@ -832,7 +831,6 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu available_models: turn_context.available_models.clone(), agent_type_description, hide_agent_type_model_reasoning: false, - include_usage_hint: turn_context.config.multi_agent_v2.usage_hint_enabled, usage_hint_text: turn_context.config.multi_agent_v2.usage_hint_text.clone(), }), exposure, diff --git a/codex-rs/core/tests/suite/multi_agent_mode.rs b/codex-rs/core/tests/suite/multi_agent_mode.rs index ba91f2fd31..21ef31c492 100644 --- a/codex-rs/core/tests/suite/multi_agent_mode.rs +++ b/codex-rs/core/tests/suite/multi_agent_mode.rs @@ -19,6 +19,7 @@ use pretty_assertions::assert_eq; use serde_json::Value; const NO_SPAWN_TEXT: &str = "Do not spawn sub-agents unless the user explicitly asks for sub-agents, delegation, or parallel agent work."; +const NO_MODE_TEXT: &str = "Multi-agent delegation mode instructions are inactive."; const PROACTIVE_TEXT: &str = "Proactive multi-agent delegation is active."; fn developer_texts(input: &[Value]) -> Vec<&str> { @@ -66,7 +67,7 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { let server = start_mock_server().await; let responses = mount_sse_sequence( &server, - (1..=3) + (1..=5) .map(|index| { sse(vec![ ev_response_created(&format!("resp-{index}")), @@ -82,18 +83,24 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); }) .build(&server) .await?; submit_turn(&test.codex, "turn one", /*mode*/ None).await?; - assert_eq!(test.codex.config_snapshot().await.multi_agent_mode, None); + assert_eq!( + test.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::ExplicitRequestOnly + ); submit_turn(&test.codex, "turn two", Some(MultiAgentMode::Proactive)).await?; submit_turn(&test.codex, "turn three", /*mode*/ None).await?; + submit_turn(&test.codex, "turn four", Some(MultiAgentMode::None)).await?; + submit_turn(&test.codex, "turn five", /*mode*/ None).await?; + + assert_eq!( + test.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); let requests = responses.requests(); let inputs = requests @@ -103,6 +110,8 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { let first = developer_texts(&inputs[0]); let second = developer_texts(&inputs[1]); let third = developer_texts(&inputs[2]); + let fourth = developer_texts(&inputs[3]); + let fifth = developer_texts(&inputs[4]); assert_eq!( ( @@ -128,12 +137,102 @@ async fn multi_agent_mode_is_sticky_and_emits_only_on_change() -> Result<()> { ), (2, 1, 1) ); + assert_eq!( + ( + count_containing(&fourth, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&fourth, NO_SPAWN_TEXT), + count_containing(&fourth, PROACTIVE_TEXT), + count_containing(&fourth, NO_MODE_TEXT), + ), + (3, 1, 1, 1) + ); + assert_eq!( + ( + count_containing(&fifth, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&fifth, NO_SPAWN_TEXT), + count_containing(&fifth, PROACTIVE_TEXT), + count_containing(&fifth, NO_MODE_TEXT), + ), + (3, 1, 1, 1) + ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<()> { +async fn multi_agent_mode_none_omits_instructions_and_survives_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let responses = mount_sse_sequence( + &server, + (1..=2) + .map(|index| { + sse(vec![ + ev_response_created(&format!("resp-{index}")), + ev_completed(&format!("resp-{index}")), + ]) + }) + .collect(), + ) + .await; + let initial = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let home = initial.home.clone(); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path"); + + submit_turn(&initial.codex, "before resume", Some(MultiAgentMode::None)).await?; + assert_eq!( + initial.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); + drop(initial); + + let mut resume_builder = test_codex().with_config(|config| { + config + .features + .enable(Feature::MultiAgentV2) + .expect("test config should allow feature update"); + }); + let resumed = resume_builder.resume(&server, home, rollout_path).await?; + submit_turn(&resumed.codex, "after resume", /*mode*/ None).await?; + + assert_eq!( + resumed.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::None + ); + let requests = responses.requests(); + assert_eq!(requests.len(), 2); + for request in requests { + let input = request.input(); + let texts = developer_texts(&input); + assert_eq!( + ( + count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), + count_containing(&texts, NO_SPAWN_TEXT), + count_containing(&texts, PROACTIVE_TEXT), + count_containing(&texts, NO_MODE_TEXT), + ), + (0, 0, 0, 0) + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_agent_mode_applies_without_usage_hint_text() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -148,21 +247,21 @@ async fn multi_agent_mode_feature_uses_explicit_mode_when_disabled() -> Result<( .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); + config.multi_agent_v2.root_agent_usage_hint_text = None; }) .build(&server) .await?; - submit_turn(&test.codex, "hello", /*mode*/ None).await?; + submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?; let input = responses.single_request().input(); let texts = developer_texts(&input); assert_eq!( ( count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), count_containing(&texts, PROACTIVE_TEXT), ), - (1, 1, 0) + (1, 1) ); Ok(()) @@ -175,7 +274,7 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result let server = start_mock_server().await; let responses = mount_sse_sequence( &server, - (1..=4) + (1..=2) .map(|index| { sse(vec![ ev_response_created(&format!("resp-{index}")), @@ -214,18 +313,14 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result .features .enable(Feature::MultiAgentV2) .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); }); let resumed = resume_builder.resume(&server, home, rollout_path).await?; - submit_turn( - &resumed.codex, - "after resume", - Some(MultiAgentMode::Proactive), - ) - .await?; + submit_turn(&resumed.codex, "after resume", /*mode*/ None).await?; + + assert_eq!( + resumed.codex.config_snapshot().await.multi_agent_mode, + MultiAgentMode::Proactive + ); let requests = responses.requests(); let resumed_input = requests[1].input(); @@ -236,108 +331,14 @@ async fn resume_compares_against_previous_effective_multi_agent_mode() -> Result count_containing(&texts, NO_SPAWN_TEXT), count_containing(&texts, PROACTIVE_TEXT), ), - (2, 1, 1) - ); - - let resumed_rollout_path = resumed - .session_configured - .rollout_path - .clone() - .expect("resumed rollout path"); - let resumed_home = resumed.home.clone(); - drop(resumed); - let mut same_mode_resume_builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - }); - let resumed_same_mode = same_mode_resume_builder - .resume(&server, resumed_home, resumed_rollout_path) - .await?; - submit_turn( - &resumed_same_mode.codex, - "after same-mode resume", - /*mode*/ None, - ) - .await?; - - assert_eq!( - resumed_same_mode - .codex - .config_snapshot() - .await - .multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); - let requests = responses.requests(); - let resumed_same_mode_input = requests[2].input(); - let texts = developer_texts(&resumed_same_mode_input); - assert_eq!( - ( - count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), - count_containing(&texts, PROACTIVE_TEXT), - ), - (2, 1, 1) - ); - - let resumed_same_mode_rollout_path = resumed_same_mode - .session_configured - .rollout_path - .clone() - .expect("same-mode resumed rollout path"); - let resumed_same_mode_home = resumed_same_mode.home.clone(); - drop(resumed_same_mode); - let mut disabled_mode_resume_builder = test_codex().with_config(|config| { - config - .features - .enable(Feature::MultiAgentV2) - .expect("test config should allow feature update"); - }); - let resumed_disabled_mode = disabled_mode_resume_builder - .resume( - &server, - resumed_same_mode_home, - resumed_same_mode_rollout_path, - ) - .await?; - submit_turn( - &resumed_disabled_mode.codex, - "after disabled-mode resume", - /*mode*/ None, - ) - .await?; - - assert_eq!( - resumed_disabled_mode - .codex - .config_snapshot() - .await - .multi_agent_mode, - Some(MultiAgentMode::Proactive) - ); - let requests = responses.requests(); - let resumed_disabled_mode_input = requests[3].input(); - let texts = developer_texts(&resumed_disabled_mode_input); - assert_eq!( - ( - count_containing(&texts, MULTI_AGENT_MODE_OPEN_TAG), - count_containing(&texts, NO_SPAWN_TEXT), - count_containing(&texts, PROACTIVE_TEXT), - ), - (3, 2, 1) + (1, 0, 1) ); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { +async fn multi_agent_mode_is_retained_without_multi_agent_v2() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -346,21 +347,13 @@ async fn explicit_multi_agent_mode_is_retained_without_multi_agent_v2() -> Resul sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), ) .await; - let test = test_codex() - .with_config(|config| { - config - .features - .enable(Feature::MultiAgentMode) - .expect("test config should allow feature update"); - }) - .build(&server) - .await?; + let test = test_codex().build(&server).await?; submit_turn(&test.codex, "hello", Some(MultiAgentMode::Proactive)).await?; assert_eq!( test.codex.config_snapshot().await.multi_agent_mode, - Some(MultiAgentMode::Proactive) + MultiAgentMode::Proactive ); let input = responses.single_request().input(); let texts = developer_texts(&input); diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index 15a37fe36a..a76aaaa3ce 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -46,6 +46,7 @@ pub struct MultiAgentV2ConfigToml { #[serde(skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0, max = 3600000))] pub default_wait_timeout_ms: Option, + /// Deprecated compatibility field. Its value is ignored. #[serde(skip_serializing_if = "Option::is_none")] pub usage_hint_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 3a04621fe0..75808e49d0 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -145,7 +145,7 @@ pub enum Feature { Collab, /// Enable task-path-based multi-agent routing. MultiAgentV2, - /// Enable per-turn multi-agent mode selection. + /// Removed compatibility flag retained as a no-op. MultiAgentMode, /// Enable CSV-backed agent job tools. SpawnCsv, @@ -1034,7 +1034,7 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::MultiAgentMode, key: "multi_agent_mode", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7ee46f4ba2..476c80aedc 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -604,45 +604,6 @@ non_code_mode_only = true ); } -#[test] -fn multi_agent_v2_feature_config_usage_hint_enabled_does_not_enable_feature() { - let features_toml: FeaturesToml = toml::from_str( - r#" -[multi_agent_v2] -usage_hint_enabled = false -"#, - ) - .expect("features table should deserialize"); - let features = Features::from_sources( - FeatureConfigSource { - features: Some(&features_toml), - ..Default::default() - }, - FeatureConfigSource::default(), - FeatureOverrides::default(), - ); - - assert_eq!(features.enabled(Feature::MultiAgentV2), false); - assert_eq!(features_toml.entries(), BTreeMap::new()); - assert_eq!( - features_toml.multi_agent_v2, - Some(crate::FeatureToml::Config(crate::MultiAgentV2ConfigToml { - enabled: None, - max_concurrent_threads_per_session: None, - min_wait_timeout_ms: None, - max_wait_timeout_ms: None, - default_wait_timeout_ms: None, - usage_hint_enabled: Some(false), - usage_hint_text: None, - root_agent_usage_hint_text: None, - subagent_usage_hint_text: None, - tool_namespace: None, - hide_spawn_agent_metadata: None, - non_code_mode_only: None, - })) - ); -} - #[test] fn materialize_resolved_enabled_writes_all_features_and_preserves_custom_config() { let mut features = Features::with_defaults(); diff --git a/codex-rs/protocol/src/config_types.rs b/codex-rs/protocol/src/config_types.rs index 5c250648a9..83bcbdc0ae 100644 --- a/codex-rs/protocol/src/config_types.rs +++ b/codex-rs/protocol/src/config_types.rs @@ -296,8 +296,10 @@ pub enum Personality { Pragmatic, } -/// Controls whether the model should only spawn sub-agents after an explicit -/// user request or may delegate proactively when doing so would help. +/// Controls whether the model receives multi-agent delegation instructions and, +/// when it does, whether it should only spawn sub-agents after an explicit user +/// request or may delegate proactively when doing so would help. `none` leaves +/// the multi-agent tools available without injecting delegation instructions. #[derive( Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS, Default, )] @@ -305,6 +307,7 @@ pub enum Personality { #[ts(rename_all = "camelCase")] #[strum(serialize_all = "camelCase")] pub enum MultiAgentMode { + None, #[default] ExplicitRequestOnly, Proactive, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5900ffe3cf..ea1df417a4 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -2005,7 +2005,7 @@ pub struct ThreadSettingsSnapshot { pub personality: Option, pub collaboration_mode: CollaborationMode, #[serde(default)] - pub multi_agent_mode: Option, + pub multi_agent_mode: MultiAgentMode, } #[derive(Debug, Clone, Deserialize, Serialize, Default, PartialEq, Eq, JsonSchema, TS)] From 9c3b10e5d4fcd1881c191b26467a6b0aa5b50682 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 11:45:37 +0100 Subject: [PATCH 24/86] Apply sandbox intent inside remote exec servers (#29113) ## Why PR #29108 lets the orchestrator send sandbox intent with `process/start` without wrapping the command for its own operating system. This PR completes that boundary by making the executor interpret and enforce the intent using its own filesystem paths and sandbox implementation. For example, a macOS TUI targeting a Linux devbox sends `/bin/bash -lc pwd`. The Linux executor turns that into its own `codex-linux-sandbox ... /bin/bash -lc pwd` launch. ## What changes - Keep `process/start` unchanged when no sandbox intent is present. - Convert sandbox `PathUri` values into native paths on the executor. - Bind symbolic `:workspace_roots` permissions to the executor's native sandbox cwd. - Select the sandbox implementation on the executor and wrap the original command immediately before spawning it. - Reject sandbox-required execution before spawning when the executor cannot enforce the intent. - Pass exec-server runtime paths into process creation so Linux can locate `codex-linux-sandbox`. The boundary is therefore: ```text orchestrator executor original argv + sandbox intent -> select and enforce local sandbox ``` This PR intentionally treats a denied remote command as an ordinary command failure. Draft follow-up #29424 carries a semantic `sandboxDenied` result back to unified exec for the existing approval and retry flow. ## Platform scope Linux and macOS use their existing direct-spawn sandbox transforms. Windows sandboxed remote process launch is intentionally unsupported in this PR. The current Windows direct-spawn wrapper does not correctly preserve arbitrary argv, TTY behavior, or pass the full child environment out of band. The executor rejects the request instead of running it incorrectly or unsandboxed. ## Known follow-ups - The transported permission profile can still contain orchestrator-materialized helper or explicit paths. A `TODO(jif)` marks where the executor boundary should receive pre-host-materialization permission intent. - The sandbox wrapper currently replaces a requested custom inner `arg0`. A `TODO(jif)` marks where this must be preserved or rejected explicitly. - Draft PR #29424 contains the deferred sandbox-denial classification and approval/retry behavior. ## Rollout assumption This executor-sandbox stack is unreleased and its client and executor are expected to move together. This PR does not add mixed-version negotiation with older exec servers. --- codex-rs/core/src/tools/sandboxing.rs | 6 +- codex-rs/core/src/tools/sandboxing_tests.rs | 2 +- codex-rs/exec-server/src/environment.rs | 48 ++++++- codex-rs/exec-server/src/lib.rs | 1 + codex-rs/exec-server/src/local_process.rs | 63 ++++++--- codex-rs/exec-server/src/process_sandbox.rs | 132 ++++++++++++++++++ .../exec-server/src/process_sandbox_tests.rs | 109 +++++++++++++++ codex-rs/exec-server/src/server/handler.rs | 10 +- .../exec-server/src/server/process_handler.rs | 8 +- .../src/server/session_registry.rs | 4 +- 10 files changed, 348 insertions(+), 35 deletions(-) create mode 100644 codex-rs/exec-server/src/process_sandbox.rs create mode 100644 codex-rs/exec-server/src/process_sandbox_tests.rs diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index f9d21dccfa..77f68d96b5 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -496,11 +496,7 @@ impl<'a> SandboxAttempt<'a> { exec_request.exec_server_sandbox = Some(FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(exec_request.windows_sandbox_policy_cwd.clone()), - workspace_roots: self - .workspace_roots - .iter() - .map(PathUri::from_abs_path) - .collect(), + workspace_roots: Vec::new(), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self.windows_sandbox_private_desktop, use_legacy_landlock: self.use_legacy_landlock, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 47b0b4e891..c647e1c401 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -258,7 +258,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { Some(codex_exec_server::FileSystemSandboxContext { permissions: exec_server_permissions.into(), cwd: Some(cwd_uri), - workspace_roots: vec![PathUri::from_abs_path(&cwd)], + workspace_roots: Vec::new(), windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, use_legacy_landlock: false, diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index d3e7e5e981..5990d8811d 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -488,7 +488,9 @@ impl Environment { exec_server_url: None, remote_client: None, startup_task: Arc::new(Mutex::new(None)), - exec_backend: Arc::new(LocalProcess::default()), + exec_backend: Arc::new(LocalProcess::with_local_runtime_paths( + local_runtime_paths.clone(), + )), filesystem: Arc::new(LocalFileSystem::with_runtime_paths( local_runtime_paths.clone(), )), @@ -1165,6 +1167,50 @@ mod tests { assert_eq!(response.process.process_id().as_str(), "default-env-proc"); } + #[tokio::test] + async fn local_environment_passes_runtime_paths_to_exec_backend() { + let environment = Environment::local(test_runtime_paths()); + #[cfg(unix)] + let uri = "file://server/share/checkout"; + #[cfg(windows)] + let uri = "file:///usr/local/checkout"; + let sandbox_cwd = PathUri::parse(uri).expect("non-native sandbox cwd URI"); + let source = sandbox_cwd + .to_abs_path() + .expect_err("sandbox cwd should not be native to this host"); + let sandbox = crate::FileSystemSandboxContext::from_permission_profile_with_cwd( + codex_protocol::models::PermissionProfile::workspace_write(), + sandbox_cwd.clone(), + ); + + let result = environment + .get_exec_backend() + .start(crate::ExecParams { + process_id: ProcessId::from("local-sandbox-proc"), + argv: vec!["true".to_string()], + cwd: PathUri::from_path(std::env::current_dir().expect("read current dir")) + .expect("cwd URI"), + env_policy: None, + env: Default::default(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }) + .await; + let Err(err) = result else { + panic!("sandbox cwd should be rejected after resolving runtime paths"); + }; + + assert_eq!( + err.to_string(), + format!( + "exec-server rejected request (-32602): sandbox cwd URI `{sandbox_cwd}` is not valid on this exec-server host: {source}" + ) + ); + } + #[tokio::test] async fn test_environment_rejects_sandboxed_filesystem_without_runtime_paths() { let environment = Environment::default_for_tests(); diff --git a/codex-rs/exec-server/src/lib.rs b/codex-rs/exec-server/src/lib.rs index c96c443447..d5c71674ac 100644 --- a/codex-rs/exec-server/src/lib.rs +++ b/codex-rs/exec-server/src/lib.rs @@ -16,6 +16,7 @@ mod noise_channel; mod noise_relay; mod process; mod process_id; +mod process_sandbox; mod protocol; mod regular_file; mod relay; diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 1a6f2cf00f..802c8e69cf 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -26,9 +26,11 @@ use crate::ExecProcessEvent; use crate::ExecProcessEventReceiver; use crate::ExecProcessFuture; use crate::ExecServerError; +use crate::ExecServerRuntimePaths; use crate::ProcessId; use crate::StartedExecProcess; use crate::process::ExecProcessEventLog; +use crate::process_sandbox::prepare_exec_request; use crate::protocol::EXEC_CLOSED_METHOD; use crate::protocol::ExecClosedNotification; use crate::protocol::ExecEnvPolicy; @@ -133,6 +135,7 @@ struct Inner { #[derive(Clone)] pub(crate) struct LocalProcess { inner: Arc, + runtime_paths: Option, } struct LocalExecProcess { @@ -144,20 +147,39 @@ struct LocalExecProcess { impl Default for LocalProcess { fn default() -> Self { - let (outgoing_tx, mut outgoing_rx) = - mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); - tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); - Self::new(RpcNotificationSender::new(outgoing_tx)) + Self::with_discarded_notifications(/*runtime_paths*/ None) } } impl LocalProcess { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn with_local_runtime_paths(runtime_paths: ExecServerRuntimePaths) -> Self { + Self::with_discarded_notifications(Some(runtime_paths)) + } + + fn with_discarded_notifications(runtime_paths: Option) -> Self { + let (outgoing_tx, mut outgoing_rx) = + mpsc::channel::(NOTIFICATION_CHANNEL_CAPACITY); + tokio::spawn(async move { while outgoing_rx.recv().await.is_some() {} }); + Self::with_runtime_paths(RpcNotificationSender::new(outgoing_tx), runtime_paths) + } + + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { + Self::with_runtime_paths(notifications, Some(runtime_paths)) + } + + fn with_runtime_paths( + notifications: RpcNotificationSender, + runtime_paths: Option, + ) -> Self { Self { inner: Arc::new(Inner { notifications: std::sync::RwLock::new(Some(notifications)), processes: Mutex::new(HashMap::new()), }), + runtime_paths, } } @@ -191,16 +213,12 @@ impl LocalProcess { params: ExecParams, ) -> Result<(ExecResponse, watch::Sender, ExecProcessEventLog), JSONRPCErrorError> { let process_id = params.process_id.clone(); - let (program, args) = params - .argv + let prepared = + prepare_exec_request(¶ms, child_env(¶ms), self.runtime_paths.as_ref())?; + let (program, args) = prepared + .command .split_first() .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; - let native_cwd = params.cwd.to_abs_path().map_err(|err| { - invalid_params(format!( - "cwd URI `{}` is not valid on this exec-server host: {err}", - params.cwd - )) - })?; let start = Arc::new(ProcessStart); { @@ -216,14 +234,13 @@ impl LocalProcess { ); } - let env = child_env(¶ms); let spawned_result = if params.tty { codex_utils_pty::spawn_pty_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, TerminalSize::default(), ) .await @@ -231,18 +248,18 @@ impl LocalProcess { codex_utils_pty::spawn_pipe_process( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await } else { codex_utils_pty::spawn_pipe_process_no_stdin( program, args, - native_cwd.as_path(), - &env, - ¶ms.arg0, + prepared.cwd.as_path(), + &prepared.env, + &prepared.arg0, ) .await }; diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs new file mode 100644 index 0000000000..be95b92d34 --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -0,0 +1,132 @@ +use std::collections::HashMap; + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_protocol::models::PermissionProfile; +use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxDirectSpawnTransformRequest; +use codex_sandboxing::SandboxManager; +use codex_sandboxing::SandboxTransformRequest; +use codex_sandboxing::SandboxType; +use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; + +use crate::ExecServerRuntimePaths; +use crate::protocol::ExecParams; +use crate::rpc::invalid_params; + +pub(crate) struct PreparedExecRequest { + pub(crate) command: Vec, + pub(crate) cwd: AbsolutePathBuf, + pub(crate) env: HashMap, + pub(crate) arg0: Option, +} + +pub(crate) fn prepare_exec_request( + params: &ExecParams, + env: HashMap, + runtime_paths: Option<&ExecServerRuntimePaths>, +) -> Result { + let Some(sandbox_context) = params.sandbox.as_ref() else { + return Ok(PreparedExecRequest { + command: params.argv.clone(), + cwd: native_path(¶ms.cwd, "cwd")?, + env, + arg0: params.arg0.clone(), + }); + }; + let runtime_paths = runtime_paths + .ok_or_else(|| invalid_params("sandbox runtime paths are not configured".to_string()))?; + // TODO(jif): Transport permissions before orchestrator-local paths are materialized, + // then resolve executor-local helper and workspace paths here. + let permissions: PermissionProfile = sandbox_context + .permissions + .clone() + .try_into() + .map_err(|err| invalid_params(format!("invalid sandbox permission path URI: {err}")))?; + let sandbox_policy_cwd = sandbox_context.cwd.as_ref().unwrap_or(¶ms.cwd); + let native_sandbox_policy_cwd = native_path(sandbox_policy_cwd, "sandbox cwd")?; + let native_workspace_roots = sandbox_context + .workspace_roots + .iter() + .map(|root| native_path(root, "sandbox workspace root")) + .collect::, _>>()?; + let workspace_roots = if native_workspace_roots.is_empty() { + std::slice::from_ref(&native_sandbox_policy_cwd) + } else { + native_workspace_roots.as_slice() + }; + let permissions = permissions.materialize_project_roots_with_workspace_roots(workspace_roots); + let (file_system_policy, network_policy) = permissions.to_runtime_permissions(); + let sandbox_manager = SandboxManager::new(); + let sandbox = sandbox_manager.select_initial( + &file_system_policy, + network_policy, + SandboxablePreference::Require, + sandbox_context.windows_sandbox_level, + params.enforce_managed_network, + ); + match sandbox { + SandboxType::None => { + return Err(invalid_params( + "sandbox intent cannot be enforced on this executor".to_string(), + )); + } + SandboxType::WindowsRestrictedToken => { + // TODO(jif): Launch generic remote commands through the Windows sandbox session API + // while preserving argv and TTY behavior and passing the child environment out of band. + return Err(invalid_params( + "sandboxed remote process launch is not supported on Windows".to_string(), + )); + } + SandboxType::MacosSeatbelt | SandboxType::LinuxSeccomp => {} + } + let (program, args) = params + .argv + .split_first() + .ok_or_else(|| invalid_params("argv must not be empty".to_string()))?; + let request = sandbox_manager + .transform_for_direct_spawn(SandboxDirectSpawnTransformRequest { + workspace_roots, + transform: SandboxTransformRequest { + // TODO(jif): Preserve params.arg0 for the inner command across the sandbox + // wrapper, or reject sandboxed requests with a custom arg0. + command: SandboxCommand { + program: program.into(), + args: args.to_vec(), + cwd: params.cwd.clone(), + env, + additional_permissions: None, + }, + permissions: &permissions, + sandbox, + enforce_managed_network: params.enforce_managed_network, + environment_id: None, + network: None, + sandbox_policy_cwd, + codex_linux_sandbox_exe: runtime_paths.codex_linux_sandbox_exe.as_deref(), + use_legacy_landlock: sandbox_context.use_legacy_landlock, + windows_sandbox_level: sandbox_context.windows_sandbox_level, + windows_sandbox_private_desktop: sandbox_context.windows_sandbox_private_desktop, + }, + }) + .map_err(|err| invalid_params(format!("failed to prepare process sandbox: {err}")))?; + Ok(PreparedExecRequest { + command: request.command, + cwd: native_path(&request.cwd, "cwd")?, + env: request.env, + arg0: request.arg0, + }) +} + +fn native_path(path: &PathUri, label: &str) -> Result { + path.to_abs_path().map_err(|err| { + invalid_params(format!( + "{label} URI `{path}` is not valid on this exec-server host: {err}" + )) + }) +} + +#[cfg(test)] +#[path = "process_sandbox_tests.rs"] +mod tests; diff --git a/codex-rs/exec-server/src/process_sandbox_tests.rs b/codex-rs/exec-server/src/process_sandbox_tests.rs new file mode 100644 index 0000000000..1b0408afd8 --- /dev/null +++ b/codex-rs/exec-server/src/process_sandbox_tests.rs @@ -0,0 +1,109 @@ +use std::collections::HashMap; + +#[cfg(unix)] +use codex_protocol::models::PermissionProfile; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; + +use super::prepare_exec_request; +use crate::ExecParams; +#[cfg(unix)] +use crate::ExecServerRuntimePaths; +#[cfg(unix)] +use crate::FileSystemSandboxContext; +use crate::ProcessId; + +#[cfg(unix)] +#[test] +fn sandbox_request_wraps_native_argv_on_executor() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let self_exe = std::env::current_exe().expect("current executable"); + let runtime_paths = + ExecServerRuntimePaths::new(self_exe.clone(), Some(self_exe)).expect("runtime paths"); + let sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( + PermissionProfile::workspace_write(), + cwd_uri.clone(), + ); + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec![ + "/bin/bash".to_string(), + "-lc".to_string(), + "pwd".to_string(), + ], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: None, + sandbox: Some(sandbox), + enforce_managed_network: false, + }; + + let prepared = prepare_exec_request(¶ms, HashMap::new(), Some(&runtime_paths)) + .expect("prepare sandboxed request"); + + assert_ne!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + #[cfg(target_os = "linux")] + { + assert_eq!( + prepared.command.first(), + Some(&runtime_paths.codex_self_exe.to_string_lossy().into_owned()) + ); + let permission_profile_json = prepared + .command + .iter() + .position(|arg| arg == "--permission-profile") + .and_then(|index| prepared.command.get(index + 1)) + .expect("sandbox wrapper permission profile"); + let permission_profile: PermissionProfile = + serde_json::from_str(permission_profile_json).expect("permission profile JSON"); + assert_eq!( + permission_profile, + PermissionProfile::workspace_write() + .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&cwd)) + ); + } + #[cfg(target_os = "macos")] + assert_eq!( + prepared.command.first().map(String::as_str), + Some("/usr/bin/sandbox-exec") + ); +} + +#[test] +fn native_request_preserves_native_launch_fields() { + let cwd: AbsolutePathBuf = std::env::current_dir() + .expect("current directory") + .try_into() + .expect("absolute cwd"); + let cwd_uri = PathUri::from_abs_path(&cwd); + let env = HashMap::from([("TEST_ENV".to_string(), "value".to_string())]); + let params = ExecParams { + process_id: ProcessId::from("process-1"), + argv: vec!["echo".to_string(), "hello".to_string()], + cwd: cwd_uri, + env_policy: None, + env: HashMap::new(), + tty: false, + pipe_stdin: false, + arg0: Some("custom-arg0".to_string()), + sandbox: None, + enforce_managed_network: false, + }; + + let prepared = prepare_exec_request(¶ms, env.clone(), /*runtime_paths*/ None) + .expect("prepare native request"); + + assert_eq!(prepared.command, params.argv); + assert_eq!(prepared.cwd, cwd); + assert_eq!(prepared.env, env); + assert_eq!(prepared.arg0, params.arg0); +} diff --git a/codex-rs/exec-server/src/server/handler.rs b/codex-rs/exec-server/src/server/handler.rs index e0ede94c77..07575a1c42 100644 --- a/codex-rs/exec-server/src/server/handler.rs +++ b/codex-rs/exec-server/src/server/handler.rs @@ -66,6 +66,7 @@ pub(crate) struct ExecServerHandler { background_task_shutdown: CancellationToken, background_tasks: TaskTracker, file_system: FileSystemHandler, + runtime_paths: ExecServerRuntimePaths, initialize_requested: AtomicBool, initialized: AtomicBool, } @@ -83,7 +84,8 @@ impl ExecServerHandler { active_body_stream_ids: Mutex::new(HashSet::new()), background_task_shutdown: CancellationToken::new(), background_tasks: TaskTracker::new(), - file_system: FileSystemHandler::new(runtime_paths), + file_system: FileSystemHandler::new(runtime_paths.clone()), + runtime_paths, initialize_requested: AtomicBool::new(false), initialized: AtomicBool::new(false), } @@ -116,7 +118,11 @@ impl ExecServerHandler { let session = match self .session_registry - .attach(params.resume_session_id.clone(), self.notifications.clone()) + .attach( + params.resume_session_id.clone(), + self.notifications.clone(), + self.runtime_paths.clone(), + ) .await { Ok(session) => session, diff --git a/codex-rs/exec-server/src/server/process_handler.rs b/codex-rs/exec-server/src/server/process_handler.rs index 9fced9c166..f628b242b0 100644 --- a/codex-rs/exec-server/src/server/process_handler.rs +++ b/codex-rs/exec-server/src/server/process_handler.rs @@ -1,5 +1,6 @@ use codex_app_server_protocol::JSONRPCErrorError; +use crate::ExecServerRuntimePaths; use crate::local_process::LocalProcess; use crate::protocol::ExecParams; use crate::protocol::ExecResponse; @@ -19,9 +20,12 @@ pub(crate) struct ProcessHandler { } impl ProcessHandler { - pub(crate) fn new(notifications: RpcNotificationSender) -> Self { + pub(crate) fn new( + notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, + ) -> Self { Self { - process: LocalProcess::new(notifications), + process: LocalProcess::new(notifications, runtime_paths), } } diff --git a/codex-rs/exec-server/src/server/session_registry.rs b/codex-rs/exec-server/src/server/session_registry.rs index 59da3b50a7..73b14ff7cd 100644 --- a/codex-rs/exec-server/src/server/session_registry.rs +++ b/codex-rs/exec-server/src/server/session_registry.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use tokio::sync::Mutex; use uuid::Uuid; +use crate::ExecServerRuntimePaths; use crate::rpc::RpcNotificationSender; use crate::rpc::invalid_request; use crate::rpc::session_already_attached; @@ -60,6 +61,7 @@ impl SessionRegistry { self: &Arc, resume_session_id: Option, notifications: RpcNotificationSender, + runtime_paths: ExecServerRuntimePaths, ) -> Result { enum AttachOutcome { Attached(Arc), @@ -95,7 +97,7 @@ impl SessionRegistry { let session_id = Uuid::new_v4().to_string(); let entry = Arc::new(SessionEntry::new( session_id.clone(), - ProcessHandler::new(notifications), + ProcessHandler::new(notifications, runtime_paths), connection_id, )); sessions.insert(session_id, Arc::clone(&entry)); From 21d36296f137c0954df24ea86abe9619318915e6 Mon Sep 17 00:00:00 2001 From: xli-oai Date: Mon, 22 Jun 2026 04:25:07 -0700 Subject: [PATCH 25/86] Add workspace messages app-server API (#29001) ## Summary - Add backend-client types and fetch support for active workspace messages. - Add the app-server v2 `account/workspaceMessages/read` method, generated schemas, and README documentation. - Delegate workspace-message eligibility to the Codex backend feature gate; map a backend 404 to `featureEnabled: false`. ## Testing - `just write-app-server-schema` - `just test -p codex-backend-client` - `just test -p codex-app-server-protocol` - `just test -p codex-app-server workspace_messages` - `just fix -p codex-backend-client -p codex-app-server-protocol -p codex-app-server` - `just fmt` ## Stack - Base PR for #28232, which adds the TUI status-line integration. --- .../schema/json/ClientRequest.json | 23 +++ .../codex_app_server_protocol.schemas.json | 88 ++++++++ .../codex_app_server_protocol.v2.schemas.json | 88 ++++++++ .../json/v2/GetWorkspaceMessagesResponse.json | 67 ++++++ .../schema/typescript/ClientRequest.ts | 2 +- .../v2/GetWorkspaceMessagesResponse.ts | 14 ++ .../schema/typescript/v2/WorkspaceMessage.ts | 14 ++ .../typescript/v2/WorkspaceMessageType.ts | 5 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/common.rs | 24 +++ .../src/protocol/v2/account.rs | 35 ++++ codex-rs/app-server/README.md | 14 +- codex-rs/app-server/src/message_processor.rs | 3 + codex-rs/app-server/src/request_processors.rs | 7 + .../request_processors/account_processor.rs | 160 ++++++++++++++ codex-rs/backend-client/src/client.rs | 38 ++++ codex-rs/backend-client/src/lib.rs | 3 + codex-rs/backend-client/src/types.rs | 83 ++++++++ codex-rs/tui/src/app/background_requests.rs | 38 ++++ codex-rs/tui/src/app/event_dispatch.rs | 11 + codex-rs/tui/src/app_event.rs | 10 + .../tui/src/bottom_pane/status_line_setup.rs | 7 + .../tui/src/bottom_pane/status_line_style.rs | 2 +- .../src/bottom_pane/status_surface_preview.rs | 3 + codex-rs/tui/src/chatwidget.rs | 11 + codex-rs/tui/src/chatwidget/constructor.rs | 5 + codex-rs/tui/src/chatwidget/settings.rs | 5 + ...s_line_setup_popup_workspace_headline.snap | 20 ++ .../tui/src/chatwidget/status_surfaces.rs | 95 +++++++++ .../src/chatwidget/tests/status_and_layout.rs | 195 ++++++++++++++++++ .../tests/status_surface_previews.rs | 12 ++ codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/workspace_messages.rs | 29 +++ codex-rs/tui/src/workspace_messages_tests.rs | 51 +++++ 34 files changed, 1163 insertions(+), 3 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap create mode 100644 codex-rs/tui/src/workspace_messages.rs create mode 100644 codex-rs/tui/src/workspace_messages_tests.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 18c147ba36..7ea246f0ea 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -6462,6 +6462,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ec0321d9b1..9c2abf525a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -1904,6 +1904,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -10354,6 +10377,28 @@ "title": "GetAccountTokenUsageResponse", "type": "object" }, + "GetWorkspaceMessagesResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/v2/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -20564,6 +20609,49 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/v2/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index cd0f46bd47..c8b172c7dc 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2913,6 +2913,29 @@ "title": "Account/usage/readRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "account/workspaceMessages/read" + ], + "title": "Account/workspaceMessages/readRequestMethod", + "type": "string" + }, + "params": { + "type": "null" + } + }, + "required": [ + "id", + "method" + ], + "title": "Account/workspaceMessages/readRequest", + "type": "object" + }, { "properties": { "id": { @@ -6705,6 +6728,28 @@ "title": "GetAccountTokenUsageResponse", "type": "object" }, + "GetWorkspaceMessagesResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" + }, "GitInfo": { "properties": { "branch": { @@ -18343,6 +18388,49 @@ "title": "WindowsWorldWritableWarningNotification", "type": "object" }, + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + }, "WriteStatus": { "enum": [ "ok", diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json new file mode 100644 index 0000000000..4d1246a1b2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/GetWorkspaceMessagesResponse.json @@ -0,0 +1,67 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "WorkspaceMessage": { + "properties": { + "archivedAt": { + "description": "Unix timestamp (in seconds) when the message was archived.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the message was created.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "messageBody": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "messageType": { + "$ref": "#/definitions/WorkspaceMessageType" + } + }, + "required": [ + "messageBody", + "messageId", + "messageType" + ], + "type": "object" + }, + "WorkspaceMessageType": { + "enum": [ + "headline", + "announcement", + "unknown" + ], + "type": "string" + } + }, + "properties": { + "featureEnabled": { + "description": "Whether the workspace-message backend route is available for this client.", + "type": "boolean" + }, + "messages": { + "description": "Active workspace messages returned by the backend.", + "items": { + "$ref": "#/definitions/WorkspaceMessage" + }, + "type": "array" + } + }, + "required": [ + "featureEnabled", + "messages" + ], + "title": "GetWorkspaceMessagesResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index e014cc98b8..6af04ef401 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -88,4 +88,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/delete", id: RequestId, params: ThreadDeleteParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/goal/set", id: RequestId, params: ThreadGoalSetParams, } | { "method": "thread/goal/get", id: RequestId, params: ThreadGoalGetParams, } | { "method": "thread/goal/clear", id: RequestId, params: ThreadGoalClearParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/approveGuardianDeniedAction", id: RequestId, params: ThreadApproveGuardianDeniedActionParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/extraRoots/set", id: RequestId, params: SkillsExtraRootsSetParams, } | { "method": "hooks/list", id: RequestId, params: HooksListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "marketplace/remove", id: RequestId, params: MarketplaceRemoveParams, } | { "method": "marketplace/upgrade", id: RequestId, params: MarketplaceUpgradeParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/installed", id: RequestId, params: PluginInstalledParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "plugin/skill/read", id: RequestId, params: PluginSkillReadParams, } | { "method": "plugin/share/save", id: RequestId, params: PluginShareSaveParams, } | { "method": "plugin/share/updateTargets", id: RequestId, params: PluginShareUpdateTargetsParams, } | { "method": "plugin/share/list", id: RequestId, params: PluginShareListParams, } | { "method": "plugin/share/checkout", id: RequestId, params: PluginShareCheckoutParams, } | { "method": "plugin/share/delete", id: RequestId, params: PluginShareDeleteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "modelProvider/capabilities/read", id: RequestId, params: ModelProviderCapabilitiesReadParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "permissionProfile/list", id: RequestId, params: PermissionProfileListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "windowsSandbox/readiness", id: RequestId, params: undefined, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "account/rateLimitResetCredit/consume", id: RequestId, params: ConsumeAccountRateLimitResetCreditParams, } | { "method": "account/usage/read", id: RequestId, params: undefined, } | { "method": "account/workspaceMessages/read", id: RequestId, params: undefined, } | { "method": "account/sendAddCreditsNudgeEmail", id: RequestId, params: SendAddCreditsNudgeEmailParams, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "externalAgentConfig/import/readHistories", id: RequestId, params: undefined, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts new file mode 100644 index 0000000000..949ad433a1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/GetWorkspaceMessagesResponse.ts @@ -0,0 +1,14 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorkspaceMessage } from "./WorkspaceMessage"; + +export type GetWorkspaceMessagesResponse = { +/** + * Whether the workspace-message backend route is available for this client. + */ +featureEnabled: boolean, +/** + * Active workspace messages returned by the backend. + */ +messages: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts new file mode 100644 index 0000000000..b024ce1163 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessage.ts @@ -0,0 +1,14 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { WorkspaceMessageType } from "./WorkspaceMessageType"; + +export type WorkspaceMessage = { messageId: string, messageType: WorkspaceMessageType, messageBody: string, +/** + * Unix timestamp (in seconds) when the message was created. + */ +createdAt: number | null, +/** + * Unix timestamp (in seconds) when the message was archived. + */ +archivedAt: number | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts new file mode 100644 index 0000000000..9d9438d964 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/WorkspaceMessageType.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WorkspaceMessageType = "headline" | "announcement" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 419cf8c3c6..3e769d3fab 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -157,6 +157,7 @@ export type { GetAccountParams } from "./GetAccountParams"; export type { GetAccountRateLimitsResponse } from "./GetAccountRateLimitsResponse"; export type { GetAccountResponse } from "./GetAccountResponse"; export type { GetAccountTokenUsageResponse } from "./GetAccountTokenUsageResponse"; +export type { GetWorkspaceMessagesResponse } from "./GetWorkspaceMessagesResponse"; export type { GitInfo } from "./GitInfo"; export type { GrantedPermissionProfile } from "./GrantedPermissionProfile"; export type { GuardianApprovalReview } from "./GuardianApprovalReview"; @@ -494,4 +495,6 @@ export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode"; export type { WindowsSandboxSetupStartParams } from "./WindowsSandboxSetupStartParams"; export type { WindowsSandboxSetupStartResponse } from "./WindowsSandboxSetupStartResponse"; export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification"; +export type { WorkspaceMessage } from "./WorkspaceMessage"; +export type { WorkspaceMessageType } from "./WorkspaceMessageType"; export type { WriteStatus } from "./WriteStatus"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 0b1e11133d..9bcc6a86d0 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1022,6 +1022,12 @@ client_request_definitions! { response: v2::GetAccountTokenUsageResponse, }, + GetWorkspaceMessages => "account/workspaceMessages/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: None, + response: v2::GetWorkspaceMessagesResponse, + }, + SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" { params: v2::SendAddCreditsNudgeEmailParams, serialization: global("account-auth"), @@ -2541,6 +2547,24 @@ mod tests { Ok(()) } + #[test] + fn serialize_get_workspace_messages() -> Result<()> { + let request = ClientRequest::GetWorkspaceMessages { + request_id: RequestId::Integer(1), + params: None, + }; + assert_eq!(request.id(), &RequestId::Integer(1)); + assert_eq!(request.method(), "account/workspaceMessages/read"); + assert_eq!( + json!({ + "method": "account/workspaceMessages/read", + "id": 1, + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_client_response() -> Result<()> { let cwd = absolute_path("/tmp"); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 2a71a092c9..348d6810fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -314,6 +314,41 @@ pub struct GetAccountTokenUsageResponse { pub daily_usage_buckets: Option>, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GetWorkspaceMessagesResponse { + /// Whether the workspace-message backend route is available for this client. + pub feature_enabled: bool, + /// Active workspace messages returned by the backend. + pub messages: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct WorkspaceMessage { + pub message_id: String, + pub message_type: WorkspaceMessageType, + pub message_body: String, + /// Unix timestamp (in seconds) when the message was created. + #[ts(type = "number | null")] + pub created_at: Option, + /// Unix timestamp (in seconds) when the message was archived. + #[ts(type = "number | null")] + pub archived_at: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum WorkspaceMessageType { + Headline, + Announcement, + #[serde(other)] + Unknown, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e823eb1007..e70db8bba1 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1920,6 +1920,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the number of earned rate-limit resets currently available. Rate-limit updates arrive via `account/rateLimits/updated` (notify); the reset count is snapshot-only. - `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key. - `account/usage/read` — fetch ChatGPT account token-activity summary and daily buckets. +- `account/workspaceMessages/read` — fetch active workspace messages, including workspace notification headlines when available. - `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change. This is a sparse rolling update; merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. - `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit. - `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`. @@ -2046,7 +2047,18 @@ Field notes: - `noCredit` means the account has no earned reset credits available. - Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated windows from this response. -### 9) Notify a workspace owner about a limit +### 9) Workspace messages (ChatGPT) + +```json +{ "method": "account/workspaceMessages/read", "id": 9 } +{ "id": 9, "result": { "featureEnabled": true, "messages": [ + { "messageId": "msg_123", "messageType": "headline", "messageBody": "Workspace maintenance starts at 5pm.", "createdAt": 1781395200, "archivedAt": null } +] } } +``` + +When the upstream workspace-message feature is disabled, `featureEnabled` is `false` and `messages` is empty. + +### 10) Notify a workspace owner about a limit ```json { "method": "account/sendAddCreditsNudgeEmail", "id": 9, "params": { "creditType": "credits" } } diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ad29e05010..33cf142fe8 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1427,6 +1427,9 @@ impl MessageProcessor { ClientRequest::GetAccountTokenUsage { .. } => { self.account_processor.get_account_token_usage().await } + ClientRequest::GetWorkspaceMessages { .. } => { + self.account_processor.get_workspace_messages().await + } ClientRequest::SendAddCreditsNudgeEmail { params, .. } => { self.account_processor .send_add_credits_nudge_email(params) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 8c202a806d..c8bd48c474 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -76,6 +76,7 @@ use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::GetConversationSummaryParams; use codex_app_server_protocol::GetConversationSummaryResponse; +use codex_app_server_protocol::GetWorkspaceMessagesResponse; use codex_app_server_protocol::GitDiffToRemoteParams; use codex_app_server_protocol::GitDiffToRemoteResponse; use codex_app_server_protocol::GitInfo as ApiGitInfo; @@ -282,10 +283,16 @@ use codex_app_server_protocol::WindowsSandboxSetupCompletedNotification; use codex_app_server_protocol::WindowsSandboxSetupMode; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_app_server_protocol::WindowsSandboxSetupStartResponse; +use codex_app_server_protocol::WorkspaceMessage; +use codex_app_server_protocol::WorkspaceMessageType; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType; use codex_backend_client::Client as BackendClient; +use codex_backend_client::CodexWorkspaceMessage as BackendWorkspaceMessage; +use codex_backend_client::CodexWorkspaceMessageType as BackendWorkspaceMessageType; +use codex_backend_client::CodexWorkspaceMessagesResponse as BackendWorkspaceMessagesResponse; use codex_backend_client::ConsumeRateLimitResetCreditCode as BackendConsumeRateLimitResetCreditCode; +use codex_backend_client::RequestError as BackendRequestError; use codex_backend_client::TokenUsageProfile; use codex_chatgpt::connectors; use codex_chatgpt::workspace_settings; diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 5d234f7002..4c8538e2b1 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -1,10 +1,13 @@ use super::*; +use chrono::DateTime; mod rate_limit_resets; // Duration before a browser ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); const ACCOUNT_TOKEN_USAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); +const ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT: Duration = + Duration::from_millis(/*millis*/ 1000); // The override is intentionally available only in debug builds, matching the login path below. #[cfg(debug_assertions)] const LOGIN_ISSUER_OVERRIDE_ENV_VAR: &str = "CODEX_APP_SERVER_LOGIN_ISSUER"; @@ -142,6 +145,14 @@ impl AccountRequestProcessor { .map(|response| Some(response.into())) } + pub(crate) async fn get_workspace_messages( + &self, + ) -> Result, JSONRPCErrorError> { + self.get_workspace_messages_response() + .await + .map(|response| Some(response.into())) + } + pub(crate) async fn send_add_credits_nudge_email( &self, params: SendAddCreditsNudgeEmailParams, @@ -943,6 +954,48 @@ impl AccountRequestProcessor { Ok(Self::account_token_usage_response(profile)) } + async fn get_workspace_messages_response( + &self, + ) -> Result { + let Some(auth) = self.auth_manager.auth().await else { + return Err(invalid_request( + "codex account authentication required to read workspace messages", + )); + }; + + if !auth.uses_codex_backend() { + return Err(invalid_request( + "chatgpt authentication required to read workspace messages", + )); + } + + let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) + .map_err(|err| internal_error(format!("failed to construct backend client: {err}")))?; + let messages = tokio::time::timeout( + ACCOUNT_WORKSPACE_MESSAGES_FETCH_TIMEOUT, + client.list_workspace_messages(), + ) + .await + .map_err(|_| internal_error("workspace messages fetch timed out"))?; + + match messages { + Ok(messages) => { + Self::workspace_messages_response(messages, /*feature_enabled*/ true) + } + Err(err) if workspace_messages_feature_disabled(&err) => { + Self::workspace_messages_response( + BackendWorkspaceMessagesResponse { + messages: Vec::new(), + }, + /*feature_enabled*/ false, + ) + } + Err(err) => Err(internal_error(format!( + "failed to fetch workspace messages: {err}" + ))), + } + } + fn account_token_usage_response(profile: TokenUsageProfile) -> GetAccountTokenUsageResponse { let stats = profile.stats; GetAccountTokenUsageResponse { @@ -965,6 +1018,20 @@ impl AccountRequestProcessor { } } + fn workspace_messages_response( + messages: BackendWorkspaceMessagesResponse, + feature_enabled: bool, + ) -> Result { + Ok(GetWorkspaceMessagesResponse { + feature_enabled, + messages: messages + .messages + .into_iter() + .map(workspace_message_from_backend) + .collect::, _>>()?, + }) + } + async fn send_add_credits_nudge_email_response( &self, params: SendAddCreditsNudgeEmailParams, @@ -1015,6 +1082,48 @@ impl AccountRequestProcessor { } } +fn workspace_message_from_backend( + message: BackendWorkspaceMessage, +) -> Result { + Ok(WorkspaceMessage { + message_id: message.message_id, + message_type: workspace_message_type_from_backend(message.message_type), + message_body: message.message_body, + created_at: workspace_message_timestamp_from_backend(message.created_at)?, + archived_at: workspace_message_timestamp_from_backend(message.archived_at)?, + }) +} + +fn workspace_message_timestamp_from_backend( + timestamp: Option, +) -> Result, JSONRPCErrorError> { + timestamp + .map(|timestamp| { + DateTime::parse_from_rfc3339(×tamp) + .map(|timestamp| timestamp.timestamp()) + .map_err(|err| { + internal_error(format!( + "failed to parse workspace message timestamp `{timestamp}`: {err}" + )) + }) + }) + .transpose() +} + +fn workspace_message_type_from_backend( + message_type: BackendWorkspaceMessageType, +) -> WorkspaceMessageType { + match message_type { + BackendWorkspaceMessageType::Headline => WorkspaceMessageType::Headline, + BackendWorkspaceMessageType::Announcement => WorkspaceMessageType::Announcement, + BackendWorkspaceMessageType::Unknown => WorkspaceMessageType::Unknown, + } +} + +fn workspace_messages_feature_disabled(err: &BackendRequestError) -> bool { + err.status().is_some_and(|status| status.as_u16() == 404) +} + #[cfg(test)] mod tests { use super::*; @@ -1055,4 +1164,55 @@ mod tests { } ); } + + #[test] + fn workspace_messages_response_maps_backend_messages() { + let response = AccountRequestProcessor::workspace_messages_response( + BackendWorkspaceMessagesResponse { + messages: vec![BackendWorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: BackendWorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some("2026-06-14T00:00:00Z".to_string()), + archived_at: Some("2026-06-15T00:00:00Z".to_string()), + }], + }, + /*feature_enabled*/ true, + ) + .expect("workspace message timestamps should parse"); + + assert_eq!( + response, + GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some(1_781_395_200), + archived_at: Some(1_781_481_600), + }], + } + ); + } + + #[test] + fn workspace_messages_feature_disabled_only_for_not_found() { + let cases = [ + (reqwest::StatusCode::NOT_FOUND, true), + (reqwest::StatusCode::UNAUTHORIZED, false), + (reqwest::StatusCode::FORBIDDEN, false), + ]; + + for (status, expected) in cases { + let err = BackendRequestError::UnexpectedStatus { + method: "GET".to_string(), + url: "https://example.test/api/codex/workspace-messages".to_string(), + status, + content_type: "application/json".to_string(), + body: "{}".to_string(), + }; + assert_eq!(workspace_messages_feature_disabled(&err), expected); + } + } } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index e8e4f5fb52..be444b6247 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,5 +1,6 @@ use crate::types::AccountsCheckResponse; use crate::types::CodeTaskDetailsResponse; +use crate::types::CodexWorkspaceMessagesResponse; use crate::types::ConfigBundleResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitReachedKind as BackendRateLimitReachedKind; @@ -19,6 +20,7 @@ use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::SpendControlLimitSnapshot; use reqwest::StatusCode; +use reqwest::header::CACHE_CONTROL; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use reqwest::header::HeaderName; @@ -430,6 +432,20 @@ impl Client { .map_err(RequestError::from) } + pub async fn list_workspace_messages( + &self, + ) -> std::result::Result { + let url = self.workspace_messages_url(); + let req = self + .http + .get(&url) + .headers(self.headers()) + .header(CACHE_CONTROL, HeaderValue::from_static("no-store")); + let (body, ct) = self.exec_request_detailed(req, "GET", &url).await?; + self.decode_json::(&url, &ct, &body) + .map_err(RequestError::from) + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { @@ -570,6 +586,13 @@ impl Client { } } + fn workspace_messages_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/workspace-messages", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/workspace-messages", self.base_url), + } + } + fn map_rate_limit_window( window: Option>>, ) -> Option { @@ -936,6 +959,21 @@ mod tests { ); } + #[test] + fn workspace_messages_uses_expected_paths() { + let codex_client = test_client("https://example.test", PathStyle::CodexApi); + assert_eq!( + codex_client.workspace_messages_url(), + "https://example.test/api/codex/workspace-messages" + ); + + let chatgpt_client = test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi); + assert_eq!( + chatgpt_client.workspace_messages_url(), + "https://chatgpt.com/backend-api/wham/workspace-messages" + ); + } + fn test_client(base_url: &str, path_style: PathStyle) -> Client { Client { base_url: base_url.to_string(), diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 9731bc82b3..6cbeaf30b2 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -8,6 +8,9 @@ pub use types::AccountEntry; pub use types::AccountsCheckResponse; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; +pub use types::CodexWorkspaceMessage; +pub use types::CodexWorkspaceMessageType; +pub use types::CodexWorkspaceMessagesResponse; pub use types::ConfigBundleResponse; pub use types::ConsumeRateLimitResetCreditCode; pub use types::ConsumeRateLimitResetCreditResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index d469713891..18ab323986 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -36,6 +36,23 @@ pub(crate) struct RateLimitStatusWithResetCredits { pub rate_limit_reset_credits: Option, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessagesResponse { + #[serde(default)] + pub messages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct CodexWorkspaceMessage { + pub message_id: String, + pub message_type: CodexWorkspaceMessageType, + pub message_body: String, + #[serde(default)] + pub created_at: Option, + #[serde(default)] + pub archived_at: Option, +} + #[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ConsumeRateLimitResetCreditCode { @@ -52,6 +69,15 @@ pub struct ConsumeRateLimitResetCreditResponse { pub windows_reset: i64, } +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum CodexWorkspaceMessageType { + Headline, + Announcement, + #[serde(other)] + Unknown, +} + #[derive(Clone, Debug)] pub struct AccountsCheckResponse { pub accounts: Vec, @@ -520,4 +546,61 @@ Second line" .expect("error should be present"); assert_eq!(msg, "APPLY_FAILED: Patch could not be applied"); } + + #[test] + fn workspace_messages_response_deserializes_messages() { + let response: CodexWorkspaceMessagesResponse = serde_json::from_value(serde_json::json!({ + "messages": [ + { + "message_id": "headline-id", + "message_type": "headline", + "message_body": "Headline body", + "created_at": "2026-06-14T00:00:00Z", + "archived_at": null + }, + { + "message_id": "announcement-id", + "message_type": "announcement", + "message_body": "Announcement body", + "created_at": "2026-06-14T01:00:00Z", + "archived_at": null + }, + { + "message_id": "unknown-id", + "message_type": "unknown", + "message_body": "Unknown body" + } + ] + })) + .expect("workspace messages response should deserialize"); + + assert_eq!( + response, + CodexWorkspaceMessagesResponse { + messages: vec![ + CodexWorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: CodexWorkspaceMessageType::Headline, + message_body: "Headline body".to_string(), + created_at: Some("2026-06-14T00:00:00Z".to_string()), + archived_at: None, + }, + CodexWorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: CodexWorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: Some("2026-06-14T01:00:00Z".to_string()), + archived_at: None, + }, + CodexWorkspaceMessage { + message_id: "unknown-id".to_string(), + message_type: CodexWorkspaceMessageType::Unknown, + message_body: "Unknown body".to_string(), + created_at: None, + archived_at: None, + }, + ], + } + ); + } } diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index bb05e1a3f4..08e7f9b071 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -31,6 +31,8 @@ const TOKEN_ACTIVITY_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); const RATE_LIMIT_RESET_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(/*secs*/ 15); +const WORKSPACE_HEADLINE_FETCH_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(/*millis*/ 2000); impl App { pub(super) fn fetch_mcp_inventory( @@ -158,6 +160,29 @@ impl App { }); } + pub(super) fn refresh_status_line_workspace_headline( + &mut self, + app_server: &AppServerSession, + request_id: u64, + ) { + let request_handle = app_server.request_handle(); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + WORKSPACE_HEADLINE_FETCH_TIMEOUT, + fetch_workspace_messages(request_handle), + ) + .await + .map_err(|_| "account/workspaceMessages/read timed out in TUI".to_string()) + .and_then(|result| { + result + .map(crate::workspace_messages::workspace_headline_from_response) + .map_err(|err| err.to_string()) + }); + app_event_tx.send(AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result }); + }); + } + pub(super) fn send_add_credits_nudge_email( &mut self, app_server: &AppServerSession, @@ -796,6 +821,19 @@ pub(super) async fn consume_rate_limit_reset_credit_request( .wrap_err("account/rateLimitResetCredit/consume failed in TUI") } +pub(super) async fn fetch_workspace_messages( + request_handle: AppServerRequestHandle, +) -> Result { + let request_id = RequestId::String(format!("workspace-messages-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::GetWorkspaceMessages { + request_id, + params: None, + }) + .await + .wrap_err("account/workspaceMessages/read failed in TUI") +} + pub(super) async fn send_add_credits_nudge_email( request_handle: AppServerRequestHandle, credit_type: AddCreditsNudgeCreditType, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0ab569b0eb..181e60d963 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -716,6 +716,9 @@ impl App { AppEvent::RefreshTokenActivity { request_id } => { self.refresh_token_activity(app_server, request_id); } + AppEvent::RefreshStatusLineWorkspaceHeadline { request_id } => { + self.refresh_status_line_workspace_headline(app_server, request_id); + } AppEvent::OpenThreadGoalMenu { thread_id } => { self.open_thread_goal_menu(app_server, thread_id).await; } @@ -2020,6 +2023,14 @@ impl App { self.chat_widget.set_status_line_git_summary(cwd, summary); self.refresh_status_line(); } + AppEvent::StatusLineWorkspaceHeadlineUpdated { request_id, result } => { + if self + .chat_widget + .set_status_line_workspace_headline(request_id, result) + { + tui.frame_requester().schedule_frame(); + } + } AppEvent::StatusLineSetupCancelled => { self.chat_widget.cancel_status_line_setup(); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fc018aad87..643cd9e2d8 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -341,6 +341,11 @@ pub(crate) enum AppEvent { result: Result, }, + /// Fetch workspace messages for the status-line headline item. + RefreshStatusLineWorkspaceHeadline { + request_id: u64, + }, + /// Commit settled asynchronous usage output after active-output barriers clear. CommitPendingUsageOutput, @@ -982,6 +987,11 @@ pub(crate) enum AppEvent { cwd: PathBuf, summary: crate::chatwidget::StatusLineGitSummary, }, + /// Async update of the workspace notification headline for status line rendering. + StatusLineWorkspaceHeadlineUpdated { + request_id: u64, + result: Result, + }, /// Apply a user-confirmed status-line item ordering/selection. StatusLineSetup { items: Vec, diff --git a/codex-rs/tui/src/bottom_pane/status_line_setup.rs b/codex-rs/tui/src/bottom_pane/status_line_setup.rs index 4dd62a7d20..63ce4237db 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_setup.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_setup.rs @@ -137,6 +137,9 @@ pub(crate) enum StatusLineItem { /// Current thread title (if set by user). ThreadTitle, + /// Current workspace notification headline. + WorkspaceHeadline, + /// Latest checklist task progress from `update_plan` (if available). TaskProgress, } @@ -185,6 +188,9 @@ impl StatusLineItem { StatusLineItem::ThreadTitle => { "Current thread title, or thread identifier when unnamed" } + StatusLineItem::WorkspaceHeadline => { + "Workspace notification headline (Enterprise workspaces only; omitted when unavailable)" + } StatusLineItem::TaskProgress => { "Latest task progress from update_plan (omitted until available)" } @@ -217,6 +223,7 @@ impl StatusLineItem { StatusLineItem::FastMode => StatusSurfacePreviewItem::FastMode, StatusLineItem::RawOutput => StatusSurfacePreviewItem::RawOutput, StatusLineItem::ThreadTitle => StatusSurfacePreviewItem::ThreadTitle, + StatusLineItem::WorkspaceHeadline => StatusSurfacePreviewItem::WorkspaceHeadline, StatusLineItem::TaskProgress => StatusSurfacePreviewItem::TaskProgress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_line_style.rs b/codex-rs/tui/src/bottom_pane/status_line_style.rs index 170c4641d2..4198943a4f 100644 --- a/codex-rs/tui/src/bottom_pane/status_line_style.rs +++ b/codex-rs/tui/src/bottom_pane/status_line_style.rs @@ -49,7 +49,7 @@ impl StatusLineAccent { StatusLineItem::FastMode | StatusLineItem::RawOutput => Self::Mode, StatusLineItem::Permissions => Self::Mode, StatusLineItem::ApprovalMode => Self::Mode, - StatusLineItem::ThreadTitle => Self::Thread, + StatusLineItem::ThreadTitle | StatusLineItem::WorkspaceHeadline => Self::Thread, StatusLineItem::TaskProgress => Self::Progress, } } diff --git a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs index bd0a94a4d4..b8b7a6fbd4 100644 --- a/codex-rs/tui/src/bottom_pane/status_surface_preview.rs +++ b/codex-rs/tui/src/bottom_pane/status_surface_preview.rs @@ -30,6 +30,7 @@ pub(crate) enum StatusSurfacePreviewItem { SessionId, FastMode, RawOutput, + WorkspaceHeadline, Model, ModelWithReasoning, Reasoning, @@ -62,6 +63,7 @@ impl StatusSurfacePreviewItem { StatusSurfacePreviewItem::SessionId => "550e8400-e29b-41d4", StatusSurfacePreviewItem::FastMode => "Fast on", StatusSurfacePreviewItem::RawOutput => "raw output", + StatusSurfacePreviewItem::WorkspaceHeadline => "Workspace headline", StatusSurfacePreviewItem::Model => "gpt-5.2-codex", StatusSurfacePreviewItem::ModelWithReasoning => "gpt-5.2-codex medium", StatusSurfacePreviewItem::Reasoning => "medium", @@ -94,6 +96,7 @@ impl StatusSurfacePreviewItem { Self::SessionId, Self::FastMode, Self::RawOutput, + Self::WorkspaceHeadline, Self::Model, Self::ModelWithReasoning, Self::Reasoning, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fd3c140e3f..20bef58a1a 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -727,6 +727,16 @@ pub(crate) struct ChatWidget { status_line_git_summary_pending: bool, // True once we've attempted a Git summary lookup for the current CWD. status_line_git_summary_lookup_complete: bool, + // Cached workspace notification headline for the status line. + status_line_workspace_headline: Option, + // Request ID for the async workspace headline fetch currently in flight. + status_line_workspace_headline_pending_request_id: Option, + // Request ID to assign to the next workspace headline fetch. + next_status_line_workspace_headline_request_id: u64, + // Last time a workspace headline fetch was requested. + status_line_workspace_headline_last_requested_at: Option, + // Set after the backend reports the workspace-message feature gate is disabled. + status_line_workspace_messages_disabled: bool, // Current thread-goal status shown in the status line when plan mode is inactive. current_goal_status_indicator: Option, current_goal_status: Option, @@ -1188,6 +1198,7 @@ impl ChatWidget { { self.refresh_terminal_title(); } + self.refresh_status_line_if_workspace_headline_due(); } fn flush_active_cell(&mut self) { diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 77fdd74e52..17f2c8c3b0 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -229,6 +229,11 @@ impl ChatWidget { status_line_git_summary_cwd: None, status_line_git_summary_pending: false, status_line_git_summary_lookup_complete: false, + status_line_workspace_headline: None, + status_line_workspace_headline_pending_request_id: None, + next_status_line_workspace_headline_request_id: 0, + status_line_workspace_headline_last_requested_at: None, + status_line_workspace_messages_disabled: false, current_goal_status_indicator: None, current_goal_status: None, external_editor_state: ExternalEditorState::Closed, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75f8d1ad4b..75c4cc66e4 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -224,6 +224,10 @@ impl ChatWidget { self.clear_pending_token_activity_refreshes(); self.clear_pending_rate_limit_reset_requests(); } + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; self.status_account_display = status_account_display; self.plan_type = plan_type; self.has_chatgpt_account = has_chatgpt_account; @@ -232,6 +236,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); + self.refresh_status_line(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap new file mode 100644 index 0000000000..32ad18ec65 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_setup_popup_workspace_headline.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/status_surface_previews.rs +expression: status_line_popup_snapshot(&mut chat) +--- + Configure Status Line + Select which items to display in the status line. + + Type to search + > +› [x] Use theme colors Apply colors from the active /theme + ─────────────────────── + [x] workspace-headline Workspace notification headline (Enterprise workspaces only; omitted … + [ ] model Current model name + [ ] model-with-reasoning Current model name with reasoning level + [ ] reasoning Current reasoning level + [ ] current-dir Current working directory + [ ] project-name Project name (omitted when unavailable) + + Workspace maintenance starts at 5pm + Press space to toggle; ←/→ to move; enter to confirm and close; esc to close diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 592dd04381..5f02dd8d69 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -65,6 +65,11 @@ impl StatusSurfaceSelections { .status_line_items .contains(&StatusLineItem::BranchChanges) } + + fn uses_workspace_headline(&self) -> bool { + self.status_line_items + .contains(&StatusLineItem::WorkspaceHeadline) + } } /// Cached project-root display name keyed by the cwd used for the last lookup. @@ -157,6 +162,15 @@ impl ChatWidget { self.request_status_line_git_summary(cwd); } } + + if !selections.uses_workspace_headline() { + self.status_line_workspace_headline = None; + self.status_line_workspace_headline_pending_request_id = None; + self.status_line_workspace_headline_last_requested_at = None; + self.status_line_workspace_messages_disabled = false; + } else { + self.request_status_line_workspace_headline_if_due(Instant::now()); + } } fn refresh_status_line_from_selections(&mut self, selections: &StatusSurfaceSelections) { @@ -553,6 +567,85 @@ impl ChatWidget { }); } + fn request_status_line_workspace_headline_if_due(&mut self, now: Instant) { + if !self.status_line_workspace_headline_should_fetch(now) { + return; + } + let request_id = self.next_status_line_workspace_headline_request_id; + self.next_status_line_workspace_headline_request_id = self + .next_status_line_workspace_headline_request_id + .wrapping_add(/*rhs*/ 1); + self.status_line_workspace_headline_pending_request_id = Some(request_id); + self.status_line_workspace_headline_last_requested_at = Some(now); + self.app_event_tx + .send(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }); + } + + fn status_line_workspace_headline_should_fetch(&self, now: Instant) -> bool { + if self + .status_line_workspace_headline_pending_request_id + .is_some() + || self.status_line_workspace_messages_disabled + || !self.has_codex_backend_auth + { + return false; + } + + self.status_line_workspace_headline_last_requested_at + .is_none_or(|last_requested_at| { + now.saturating_duration_since(last_requested_at) + >= crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL + }) + } + + pub(super) fn refresh_status_line_if_workspace_headline_due(&mut self) { + let now = Instant::now(); + if self.status_line_workspace_headline_should_fetch(now) + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.refresh_status_line(); + } + } + + pub(crate) fn set_status_line_workspace_headline( + &mut self, + request_id: u64, + result: Result, + ) -> bool { + if self.status_line_workspace_headline_pending_request_id != Some(request_id) { + return false; + } + self.status_line_workspace_headline_pending_request_id = None; + match result { + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(headline)) => { + self.status_line_workspace_messages_disabled = false; + self.status_line_workspace_headline = headline; + } + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled) => { + self.status_line_workspace_messages_disabled = true; + self.status_line_workspace_headline = None; + } + Err(err) => { + tracing::debug!(error = %err, "failed to fetch workspace headline"); + } + } + + if !self.status_line_workspace_messages_disabled + && self + .status_line_items_with_invalids() + .0 + .contains(&StatusLineItem::WorkspaceHeadline) + { + self.frame_requester + .schedule_frame_in(crate::workspace_messages::WORKSPACE_HEADLINE_REFRESH_INTERVAL); + } + self.refresh_status_line(); + true + } + /// Resolves a display string for one configured status-line item. /// /// Returning `None` means "omit this item for now", not "configuration error". Callers rely on @@ -653,6 +746,7 @@ impl ChatWidget { } }, ), + StatusLineItem::WorkspaceHeadline => self.status_line_workspace_headline.clone(), StatusLineItem::TaskProgress => self.terminal_title_task_progress(), } } @@ -693,6 +787,7 @@ impl ChatWidget { StatusSurfacePreviewItem::SessionId => StatusLineItem::SessionId, StatusSurfacePreviewItem::FastMode => StatusLineItem::FastMode, StatusSurfacePreviewItem::RawOutput => StatusLineItem::RawOutput, + StatusSurfacePreviewItem::WorkspaceHeadline => StatusLineItem::WorkspaceHeadline, StatusSurfacePreviewItem::Model => StatusLineItem::ModelName, StatusSurfacePreviewItem::ModelWithReasoning => StatusLineItem::ModelWithReasoning, StatusSurfacePreviewItem::Reasoning => StatusLineItem::Reasoning, diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 583377bace..9e528fb6c2 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -14,6 +14,15 @@ fn enable_test_ambient_pet(chat: &mut ChatWidget) { chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); } +fn take_workspace_headline_request_id( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> u64 { + match rx.try_recv() { + Ok(AppEvent::RefreshStatusLineWorkspaceHeadline { request_id }) => request_id, + event => panic!("expected workspace headline refresh, got {event:?}"), + } +} + /// Receiving a token usage update without usage clears the context indicator. #[tokio::test] async fn token_count_none_resets_context_indicator() { @@ -2141,6 +2150,192 @@ async fn status_line_legacy_context_usage_renders_context_used_percent() { ); } +#[tokio::test] +async fn status_line_workspace_headline_renders_cached_value() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + + chat.refresh_status_line(); + + assert_eq!( + status_line_text(&chat), + Some("Workspace maintenance starts at 5pm".to_string()) + ); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be a valid status line item" + ); +} + +#[tokio::test] +async fn status_line_workspace_headline_omits_when_unavailable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.thread_id = Some(ThreadId::new()); + chat.config.tui_status_line = Some(vec![ + "workspace-headline".to_string(), + "run-state".to_string(), + ]); + + chat.refresh_status_line(); + + assert_eq!(status_line_text(&chat), Some("Ready".to_string())); + assert!( + drain_insert_history(&mut rx).is_empty(), + "workspace-headline should be omitted without warning when no headline is cached" + ); +} + +#[tokio::test] +async fn workspace_headline_update_applies_feature_disabled_result() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old headline".to_string()); + let request_id = 3; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + + assert_eq!(status_line_text(&chat), None); + assert!(chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn workspace_headline_update_applies_available_headline() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + let request_id = 4; + chat.status_line_workspace_headline_pending_request_id = Some(request_id); + + assert!(chat.set_status_line_workspace_headline( + request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Fresh workspace headline".to_string(), + )) + ), + )); + + assert_eq!( + status_line_text(&chat), + Some("Fresh workspace headline".to_string()) + ); + assert!(!chat.status_line_workspace_messages_disabled); +} + +#[tokio::test] +async fn account_update_clears_workspace_headline_state() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + chat.status_line_workspace_headline = Some("Old workspace headline".to_string()); + chat.status_line_workspace_headline_pending_request_id = Some(5); + chat.status_line_workspace_headline_last_requested_at = Some(Instant::now()); + chat.status_line_workspace_messages_disabled = true; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, + ); + + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_headline_last_requested_at, + chat.status_line_workspace_messages_disabled, + ), + (None, None, None, false) + ); +} + +#[tokio::test] +async fn workspace_headline_fetch_allows_backend_auth_without_chatgpt_account() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ true, + ); + + let request_id = take_workspace_headline_request_id(&mut rx); + assert_eq!( + chat.status_line_workspace_headline_pending_request_id, + Some(request_id) + ); +} + +#[tokio::test] +async fn account_update_discards_stale_workspace_headline_results() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("first@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let stale_request_id = take_workspace_headline_request_id(&mut rx); + + chat.update_account_state( + Some(StatusAccountDisplay::ChatGpt { + email: Some("second@example.com".to_string()), + plan: None, + }), + /*plan_type*/ None, + /*has_chatgpt_account*/ true, + /*has_codex_backend_auth*/ true, + ); + let current_request_id = take_workspace_headline_request_id(&mut rx); + + assert_ne!(stale_request_id, current_request_id); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "First account headline".to_string(), + )) + ), + )); + assert_eq!( + ( + chat.status_line_workspace_headline.clone(), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (None, Some(current_request_id), false) + ); + + assert!(chat.set_status_line_workspace_headline( + current_request_id, + Ok( + crate::workspace_messages::WorkspaceHeadlineFetchResult::Available(Some( + "Second account headline".to_string(), + )) + ), + )); + assert!(!chat.set_status_line_workspace_headline( + stale_request_id, + Ok(crate::workspace_messages::WorkspaceHeadlineFetchResult::FeatureDisabled), + )); + assert_eq!( + ( + status_line_text(&chat), + chat.status_line_workspace_headline_pending_request_id, + chat.status_line_workspace_messages_disabled, + ), + (Some("Second account headline".to_string()), None, false,) + ); +} + #[tokio::test] async fn status_line_branch_state_resets_when_git_branch_disabled() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs index 8654b37d24..63005cb957 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_surface_previews.rs @@ -180,6 +180,18 @@ async fn status_line_setup_popup_hardcoded_only_snapshot() { ); } +#[tokio::test] +async fn status_line_setup_popup_workspace_headline_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.status_line_workspace_headline = Some("Workspace maintenance starts at 5pm".to_string()); + chat.config.tui_status_line = Some(vec!["workspace-headline".to_string()]); + + assert_chatwidget_snapshot!( + "status_line_setup_popup_workspace_headline", + status_line_popup_snapshot(&mut chat) + ); +} + #[tokio::test] async fn status_surface_preview_lines_mixed_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 82d9a72bd8..9a4a88c86d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -200,6 +200,7 @@ mod width; #[cfg(any(target_os = "windows", test))] mod windows_sandbox; mod workspace_command; +mod workspace_messages; mod wrapping; diff --git a/codex-rs/tui/src/workspace_messages.rs b/codex-rs/tui/src/workspace_messages.rs new file mode 100644 index 0000000000..4f5695f80c --- /dev/null +++ b/codex-rs/tui/src/workspace_messages.rs @@ -0,0 +1,29 @@ +use codex_app_server_protocol::GetWorkspaceMessagesResponse; +use codex_app_server_protocol::WorkspaceMessageType; +use std::time::Duration; + +pub(crate) const WORKSPACE_HEADLINE_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum WorkspaceHeadlineFetchResult { + Available(Option), + FeatureDisabled, +} + +pub(crate) fn workspace_headline_from_response( + response: GetWorkspaceMessagesResponse, +) -> WorkspaceHeadlineFetchResult { + if !response.feature_enabled { + return WorkspaceHeadlineFetchResult::FeatureDisabled; + } + + WorkspaceHeadlineFetchResult::Available(response.messages.into_iter().find_map(|message| { + (message.message_type == WorkspaceMessageType::Headline) + .then(|| message.message_body.trim().to_string()) + .filter(|headline| !headline.is_empty()) + })) +} + +#[cfg(test)] +#[path = "workspace_messages_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/workspace_messages_tests.rs b/codex-rs/tui/src/workspace_messages_tests.rs new file mode 100644 index 0000000000..962d2ceba1 --- /dev/null +++ b/codex-rs/tui/src/workspace_messages_tests.rs @@ -0,0 +1,51 @@ +use super::*; +use codex_app_server_protocol::WorkspaceMessage; +use pretty_assertions::assert_eq; + +#[test] +fn workspace_headline_from_response_uses_first_non_empty_headline() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: true, + messages: vec![ + WorkspaceMessage { + message_id: "announcement-id".to_string(), + message_type: WorkspaceMessageType::Announcement, + message_body: "Announcement body".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "empty-headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " ".to_string(), + created_at: None, + archived_at: None, + }, + WorkspaceMessage { + message_id: "headline-id".to_string(), + message_type: WorkspaceMessageType::Headline, + message_body: " Workspace headline ".to_string(), + created_at: None, + archived_at: None, + }, + ], + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::Available(Some("Workspace headline".to_string())) + ); +} + +#[test] +fn workspace_headline_from_response_reports_feature_disabled() { + let response = GetWorkspaceMessagesResponse { + feature_enabled: false, + messages: Vec::new(), + }; + + assert_eq!( + workspace_headline_from_response(response), + WorkspaceHeadlineFetchResult::FeatureDisabled + ); +} From 91e6da943b08677cf1c9346fb438488471fc03a5 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 16:43:08 +0100 Subject: [PATCH 26/86] Stop logging every Responses WebSocket event (#29432) ## Why Every successful Responses WebSocket event currently produces three local log records: the full payload at TRACE, an OpenTelemetry log event, and an OpenTelemetry trace event. On busy threads these records fill the 1,000-row log partition in seconds and cause continuous SQLite insert-and-prune churn. Related to https://openai.slack.com/archives/C095U48JNL9/p1782128972644209 ## What changed - Stop logging each successful Responses WebSocket payload at TRACE. - Stop emitting `codex.websocket_event` as OpenTelemetry log and trace events. - Keep WebSocket event counters, duration metrics, response timing metrics, parsing, and error handling. --- .../src/endpoint/responses_websocket.rs | 1 - codex-rs/otel/src/events/session_telemetry.rs | 46 ++----------------- 2 files changed, 5 insertions(+), 42 deletions(-) diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index fa01530102..d1f06ee2f8 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -668,7 +668,6 @@ async fn run_websocket_response_stream( match message { Message::Text(text) => { - trace!("websocket event: {text}"); if let Some(wrapped_error) = parse_wrapped_websocket_error_event(&text) && let Some(error) = map_wrapped_websocket_error_event(wrapped_error, text.to_string()) diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index c8e35b9f7d..efff5c24e3 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -707,7 +707,6 @@ impl SessionTelemetry { duration: Duration, ) { let mut kind = None; - let mut error_message = None; let mut success = true; match result { @@ -724,49 +723,26 @@ impl SessionTelemetry { } if kind.as_deref() == Some("response.failed") { success = false; - error_message = value - .get("response") - .and_then(|value| value.get("error")) - .map(serde_json::Value::to_string) - .or_else(|| Some("response.failed event received".to_string())); } } - Err(err) => { + Err(_) => { kind = Some("parse_error".to_string()); - error_message = Some(err.to_string()); success = false; } } } - tokio_tungstenite::tungstenite::Message::Binary(_) => { - success = false; - error_message = Some("unexpected binary websocket event".to_string()); - } tokio_tungstenite::tungstenite::Message::Ping(_) | tokio_tungstenite::tungstenite::Message::Pong(_) => { return; } - tokio_tungstenite::tungstenite::Message::Close(_) => { + tokio_tungstenite::tungstenite::Message::Binary(_) + | tokio_tungstenite::tungstenite::Message::Close(_) + | tokio_tungstenite::tungstenite::Message::Frame(_) => { success = false; - error_message = - Some("websocket closed by server before response.completed".to_string()); - } - tokio_tungstenite::tungstenite::Message::Frame(_) => { - success = false; - error_message = Some("unexpected websocket frame".to_string()); } }, - Ok(Some(Err(err))) => { + Ok(Some(Err(_))) | Ok(None) | Err(_) => { success = false; - error_message = Some(err.to_string()); - } - Ok(None) => { - success = false; - error_message = Some("stream closed before response.completed".to_string()); - } - Err(err) => { - success = false; - error_message = Some(err.to_string()); } } @@ -775,18 +751,6 @@ impl SessionTelemetry { let tags = [("kind", kind_str), ("success", success_str)]; self.counter(WEBSOCKET_EVENT_COUNT_METRIC, /*inc*/ 1, &tags); self.record_duration(WEBSOCKET_EVENT_DURATION_METRIC, duration, &tags); - log_and_trace_event!( - self, - common: { - event.name = "codex.websocket_event", - event.kind = %kind_str, - duration_ms = %duration.as_millis(), - success = success_str, - error.message = error_message.as_deref(), - }, - log: {}, - trace: {}, - ); } pub fn log_sse_event( From 22886f21dee225bc54dcb3fd81771be810c61175 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 22 Jun 2026 08:47:43 -0700 Subject: [PATCH 27/86] core: refresh environment context before sampling (#29073) ## Why Nonblocking environment snapshots allow a turn to reach the model while a remote environment is still starting. The initial context can describe that environment as still loading, but nothing currently refreshes the model-visible environment context when startup finishes during the same turn. This adds the first request-scoped reconciliation slice on top of #28683. It is gated by `DeferredExecutor` and intentionally updates only model-visible environment context; tools and other environment-derived state will migrate separately. ## What - Add a minimal `StepContext` containing the environment snapshot captured before each sampling request. - Render attached environments with their resolved shell and starting environments with `still loading`. - Track the latest environment state recorded in model history and append a bounded update only when it changes. - Seed that baseline from full initial context so ready-at-start environments are not duplicated. - Clear the in-memory baseline when history is rewritten so replacement history can be refreshed safely. ## Testing - `just test -p codex-core deferred_executor` - `just test -p codex-core environment_context_baseline_deduplicates_until_history_is_replaced` The integration coverage verifies that a pending environment reaches the first request, the ready state reaches the next request, later requests do not duplicate it, and ready-at-start environments remain single-injected.
Live verification - Connected to a real remote executor with startup deliberately delayed and forced three sampling requests in one turn. - Inspected the raw model inputs: request 1 showed the remote environment as `still loading`, request 2 appended its ready shell and cwd, and request 3 contained no duplicate ready update. - With the feature disabled, startup waited for the delayed executor and the first request contained only the ready environment. - With a synchronously ready environment and the feature enabled, the first request contained one environment context with no duplicate. - Executed `pwd` and read a marker file through the remote process runner; the command exited successfully and returned the remote cwd and marker contents.
--- .../core/src/context/environment_context.rs | 122 +++++++--- .../src/context/environment_context_tests.rs | 61 +++-- codex-rs/core/src/context_manager/history.rs | 17 ++ .../core/src/context_manager/history_tests.rs | 15 ++ codex-rs/core/src/session/mod.rs | 51 ++++ codex-rs/core/src/session/step_context.rs | 7 + codex-rs/core/src/session/turn.rs | 16 ++ codex-rs/core/tests/suite/remote_env.rs | 226 +++++++++++++++++- 8 files changed, 460 insertions(+), 55 deletions(-) create mode 100644 codex-rs/core/src/session/step_context.rs diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index e93ffb88ff..5f36124b81 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,3 +1,4 @@ +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::session::turn_context::TurnEnvironment; use crate::shell::Shell; @@ -30,34 +31,50 @@ pub(crate) struct EnvironmentContext { pub(crate) struct EnvironmentContextEnvironment { pub(crate) id: String, pub(crate) cwd: PathUri, - pub(crate) shell: String, + status: EnvironmentContextEnvironmentStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum EnvironmentContextEnvironmentStatus { + Starting, + Ready { shell: String }, } impl EnvironmentContextEnvironment { - fn legacy(cwd: PathUri, shell: String) -> Self { - Self { - id: String::new(), - cwd, - shell, - } - } - fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { environments .iter() .map(|environment| Self { id: environment.environment_id.clone(), cwd: environment.cwd().clone(), - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), + status: EnvironmentContextEnvironmentStatus::Ready { + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()) + .unwrap_or_else(|| shell.name().to_string()), + }, }) .collect() } } +impl EnvironmentContextEnvironmentStatus { + fn equals_except_shell(&self, other: &Self) -> bool { + matches!( + (self, other), + (Self::Starting, Self::Starting) | (Self::Ready { .. }, Self::Ready { .. }) + ) + } + + fn render(&self, indent: &str) -> String { + match self { + Self::Starting => format!("{indent}starting"), + Self::Ready { shell } => format!("{indent}{shell}"), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum EnvironmentContextEnvironments { None, @@ -81,13 +98,16 @@ impl EnvironmentContextEnvironments { fn equals_except_shell(&self, other: &Self) -> bool { match (self, other) { (Self::None, Self::None) => true, - (Self::Single(left), Self::Single(right)) => left.cwd == right.cwd, + (Self::Single(left), Self::Single(right)) => { + left.cwd == right.cwd && left.status.equals_except_shell(&right.status) + } (Self::Multiple(left), Self::Multiple(right)) => { left.len() == right.len() - && left - .iter() - .zip(right.iter()) - .all(|(left, right)| left.id == right.id && left.cwd == right.cwd) + && left.iter().zip(right.iter()).all(|(left, right)| { + left.id == right.id + && left.cwd == right.cwd + && left.status.equals_except_shell(&right.status) + }) } _ => false, } @@ -365,6 +385,48 @@ impl EnvironmentContext { } } + pub(crate) fn from_step_context(step_context: &StepContext, shell: &Shell) -> Option { + let mut environments = EnvironmentContextEnvironment::from_turn_environments( + &step_context.environments.turn_environments, + shell, + ); + environments.extend( + step_context + .environments + .starting + .iter() + .map(|environment| EnvironmentContextEnvironment { + id: environment.selection.environment_id.clone(), + cwd: environment.selection.cwd.clone(), + status: EnvironmentContextEnvironmentStatus::Starting, + }), + ); + + Self::environment_only(environments) + } + + pub(crate) fn from_attached_environments( + environments: &[TurnEnvironment], + shell: &Shell, + ) -> Option { + Self::environment_only(EnvironmentContextEnvironment::from_turn_environments( + environments, + shell, + )) + } + + fn environment_only(environments: Vec) -> Option { + (!environments.is_empty()).then(|| { + Self::new( + environments, + /*current_date*/ None, + /*timezone*/ None, + /*network*/ None, + /*subagents*/ None, + ) + }) + } + /// Compares two environment contexts, ignoring the shell. Useful when /// comparing turn to turn, since the initial environment_context will /// include the shell, and then it is not configurable from turn to turn. @@ -386,10 +448,11 @@ impl EnvironmentContext { let environments = match &after.environments { EnvironmentContextEnvironments::Single(environment) => { if PathUri::from_abs_path(&before.cwd) != environment.cwd { - EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment::legacy( - environment.cwd.clone(), - environment.shell.clone(), - )) + EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment { + id: String::new(), + cwd: environment.cwd.clone(), + status: environment.status.clone(), + }) } else { EnvironmentContextEnvironments::None } @@ -442,10 +505,11 @@ impl EnvironmentContext { shell: String, ) -> Self { Self::new_with_environments( - EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment::legacy( - PathUri::from_abs_path(&turn_context_item.cwd), - shell, - )]), + EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment { + id: String::new(), + cwd: PathUri::from_abs_path(&turn_context_item.cwd), + status: EnvironmentContextEnvironmentStatus::Ready { shell }, + }]), turn_context_item.current_date.clone(), turn_context_item.timezone.clone(), Self::network_from_turn_context_item(turn_context_item), @@ -538,7 +602,7 @@ impl ContextualUserFragment for EnvironmentContext { EnvironmentContextEnvironments::Single(environment) => { let cwd = environment.cwd.inferred_native_path_string(); lines.push(format!(" {cwd}")); - lines.push(format!(" {}", environment.shell)); + lines.push(environment.status.render(" ")); } EnvironmentContextEnvironments::Multiple(environments) => { lines.push(" ".to_string()); @@ -546,7 +610,7 @@ impl ContextualUserFragment for EnvironmentContext { lines.push(format!(" ", environment.id)); let cwd = environment.cwd.inferred_native_path_string(); lines.push(format!(" {cwd}")); - lines.push(format!(" {}", environment.shell)); + lines.push(environment.status.render(" ")); lines.push(" ".to_string()); } lines.push(" ".to_string()); diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/environment_context_tests.rs index d40ce4e671..4d9f1a7291 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/environment_context_tests.rs @@ -1,5 +1,6 @@ use crate::shell::ShellType; +use super::EnvironmentContextEnvironmentStatus::Ready; use super::*; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -37,7 +38,9 @@ fn serialize_workspace_write_environment_context() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&cwd.abs()), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -64,7 +67,9 @@ fn serialize_environment_context_with_foreign_windows_cwd() { vec![EnvironmentContextEnvironment { id: "remote".to_string(), cwd: PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), - shell: "powershell".to_string(), + status: Ready { + shell: "powershell".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -91,7 +96,9 @@ fn serialize_environment_context_with_network() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -153,7 +160,9 @@ fn serialize_environment_context_with_full_filesystem_profile() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -259,7 +268,9 @@ fn equals_except_shell_compares_cwd() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -270,7 +281,9 @@ fn equals_except_shell_compares_cwd() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -286,7 +299,9 @@ fn equals_except_shell_compares_cwd_differences() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo1")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -297,7 +312,9 @@ fn equals_except_shell_compares_cwd_differences() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo2")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -314,7 +331,9 @@ fn equals_except_shell_ignores_shell() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: "bash".to_string(), + status: Ready { + shell: "bash".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -325,7 +344,9 @@ fn equals_except_shell_ignores_shell() { vec![EnvironmentContextEnvironment { id: "other".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: "zsh".to_string(), + status: Ready { + shell: "zsh".to_string(), + }, }], /*current_date*/ None, /*timezone*/ None, @@ -342,7 +363,9 @@ fn serialize_environment_context_with_subagents() { vec![EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - shell: fake_shell_name(), + status: Ready { + shell: fake_shell_name(), + }, }], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -376,12 +399,16 @@ fn serialize_environment_context_with_multiple_selected_environments() { EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&local_cwd.abs()), - shell: "bash".to_string(), + status: Ready { + shell: "bash".to_string(), + }, }, EnvironmentContextEnvironment { id: "remote".to_string(), cwd: PathUri::from_abs_path(&remote_cwd.abs()), - shell: "bash".to_string(), + status: Ready { + shell: "bash".to_string(), + }, }, ], Some("2026-02-26".to_string()), @@ -421,12 +448,16 @@ fn serialize_environment_context_prefers_environment_shell_when_present() { EnvironmentContextEnvironment { id: "local".to_string(), cwd: PathUri::from_abs_path(&local_cwd.abs()), - shell: "powershell".to_string(), + status: Ready { + shell: "powershell".to_string(), + }, }, EnvironmentContextEnvironment { id: "remote".to_string(), cwd: PathUri::from_abs_path(&remote_cwd.abs()), - shell: "cmd".to_string(), + status: Ready { + shell: "cmd".to_string(), + }, }, ], /*current_date*/ None, diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6ce1c6148f..eb6f094248 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,3 +1,4 @@ +use crate::context::EnvironmentContext; use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; @@ -48,6 +49,8 @@ pub(crate) struct ContextManager { /// also clear this when it trims a mixed initial-context developer bundle /// whose non-diff fragments no longer exist in the surviving history. reference_context_item: Option, + /// Environment state most recently appended to model-visible history. + environment_context_baseline: Option, } impl ContextManager { @@ -59,6 +62,7 @@ impl ContextManager { &None, &None, /*model_context_window*/ None, ), reference_context_item: None, + environment_context_baseline: None, } } @@ -78,6 +82,17 @@ impl ContextManager { self.reference_context_item.clone() } + pub(crate) fn update_environment_context_baseline( + &mut self, + context: &EnvironmentContext, + ) -> bool { + if self.environment_context_baseline.as_ref() == Some(context) { + return false; + } + self.environment_context_baseline = Some(context.clone()); + true + } + pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { match &mut self.token_info { Some(info) => info.fill_to_context_window(context_window), @@ -163,12 +178,14 @@ impl ContextManager { // its corresponding counterpart to keep the invariants intact without // running a full normalization pass. normalize::remove_corresponding_for(&mut self.items, &removed); + self.environment_context_baseline = None; } } pub(crate) fn replace(&mut self, items: Vec) { self.items = items; self.history_version = self.history_version.saturating_add(1); + self.environment_context_baseline = None; } /// Replace image content in the last turn if it originated from a tool output. diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 35a9656fe1..bbf8d8059a 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::context::EnvironmentContext; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; @@ -73,6 +74,20 @@ fn create_history_with_items(items: Vec) -> ContextManager { h } +#[test] +fn environment_context_baseline_deduplicates_until_history_is_replaced() { + let context = + EnvironmentContext::from_turn_context_item(&reference_context_item(), "bash".to_string()); + let mut history = ContextManager::new(); + + assert!(history.update_environment_context_baseline(&context)); + assert!(!history.update_environment_context_baseline(&context)); + + history.replace(Vec::new()); + + assert!(history.update_environment_context_baseline(&context)); +} + fn user_msg(text: &str) -> ResponseItem { ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index f3c7a950dd..42eb0c29e3 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -218,6 +218,7 @@ mod rollout_budget; mod rollout_reconstruction; #[allow(clippy::module_inception)] pub(crate) mod session; +pub(crate) mod step_context; pub(crate) mod time_reminder; mod token_budget; pub(crate) mod turn; @@ -2772,6 +2773,35 @@ impl Session { self.send_raw_response_items(turn_context, items).await; } + pub(crate) async fn record_step_environment_context_if_changed( + &self, + turn_context: &TurnContext, + step_context: &step_context::StepContext, + ) { + if !turn_context.config.include_environment_context { + return; + } + + let shell = self.user_shell(); + let Some(environment_context) = + crate::context::EnvironmentContext::from_step_context(step_context, shell.as_ref()) + else { + return; + }; + let changed = { + let mut state = self.state.lock().await; + state + .history + .update_environment_context_baseline(&environment_context) + }; + if !changed { + return; + } + + let item = ContextualUserFragment::into(environment_context); + self.record_conversation_items(turn_context, &[item]).await; + } + pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, @@ -3438,6 +3468,22 @@ impl Session { .await, ); } + let initial_environment_context = if should_inject_full_context + && !context_items.is_empty() + && turn_context.config.include_environment_context + && turn_context + .config + .features + .enabled(Feature::DeferredExecutor) + { + let shell = self.user_shell(); + crate::context::EnvironmentContext::from_attached_environments( + &turn_context.environments.turn_environments, + shell.as_ref(), + ) + } else { + None + }; if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; @@ -3451,6 +3497,11 @@ impl Session { // context items. This keeps later runtime diffing aligned with the current turn state. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); + if let Some(environment_context) = initial_environment_context { + state + .history + .update_environment_context_baseline(&environment_context); + } } pub(crate) async fn update_token_usage_info( diff --git a/codex-rs/core/src/session/step_context.rs b/codex-rs/core/src/session/step_context.rs new file mode 100644 index 0000000000..17243ced67 --- /dev/null +++ b/codex-rs/core/src/session/step_context.rs @@ -0,0 +1,7 @@ +use crate::environment_selection::TurnEnvironmentSnapshot; + +/// Request-scoped state that may change between model sampling requests. +#[derive(Debug)] +pub(crate) struct StepContext { + pub(crate) environments: TurnEnvironmentSnapshot, +} diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 041da729ea..9b9c7cc66e 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -42,6 +42,7 @@ use crate::responses_retry::handle_retryable_response_stream_error; use crate::session::PreviousTurnSettings; use crate::session::TurnInput; use crate::session::session::Session; +use crate::session::step_context::StepContext; use crate::session::turn_context::TurnContext; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::TurnItemContributorPolicy; @@ -241,6 +242,21 @@ pub(crate) async fn run_turn( ) .await?; + if turn_context + .config + .features + .enabled(Feature::DeferredExecutor) + { + let step_context = StepContext { + environments: sess.services.turn_environments.snapshot().await, + }; + sess.record_step_environment_context_if_changed( + turn_context.as_ref(), + &step_context, + ) + .await; + } + // Construct the input that we will send to the model. let sampling_request_input: Vec = async { sess.clone_history() diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 67e5668f45..337ca8d405 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -26,6 +26,8 @@ use codex_protocol::protocol::TurnEnvironmentSelection; use codex_protocol::request_permissions::PermissionGrantScope; use codex_protocol::request_permissions::RequestPermissionProfile; use codex_protocol::request_permissions::RequestPermissionsResponse; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; @@ -49,9 +51,13 @@ use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::test_env; use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; +use futures::SinkExt; +use futures::StreamExt; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::process::Command; @@ -59,6 +65,12 @@ use std::time::Duration; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tempfile::TempDir; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tokio_tungstenite::WebSocketStream; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; async fn unified_exec_test(server: &wiremock::MockServer) -> Result { let mut builder = test_codex().with_config(|config| { config.use_experimental_unified_exec_tool = true; @@ -301,8 +313,7 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn deferred_executor_reaches_model_before_remote_environment_is_ready() -> Result<()> { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; +async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> { let server = start_mock_server().await; let response_mock = mount_sse_once( &server, @@ -313,23 +324,216 @@ async fn deferred_executor_reaches_model_before_remote_environment_is_ready() -> ]), ) .await; + let mut builder = test_codex().with_config(|config| { + assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + }); + let test = builder.build(&server).await?; + + test.submit_turn("report the environment").await?; + + let user_context = response_mock.single_request().message_input_texts("user"); + assert_eq!( + user_context + .iter() + .filter(|text| text.contains("")) + .count(), + 1 + ); + + Ok(()) +} + +async fn read_exec_server_json(websocket: &mut WebSocketStream) -> Value { + loop { + match timeout(Duration::from_secs(5), websocket.next()) + .await + .expect("websocket read should not time out") + .expect("websocket should stay open") + .expect("websocket frame should read") + { + Message::Text(text) => { + return serde_json::from_str(text.as_ref()).expect("valid JSON-RPC message"); + } + Message::Binary(bytes) => { + return serde_json::from_slice(bytes.as_ref()).expect("valid JSON-RPC message"); + } + Message::Ping(_) | Message::Pong(_) => {} + other => panic!("expected JSON-RPC message, got {other:?}"), + } + } +} + +async fn serve_environment_info(listener: TcpListener) { + let (stream, _) = listener.accept().await.expect("connection"); + let mut websocket = accept_async(stream).await.expect("websocket handshake"); + + let initialize = read_exec_server_json(&mut websocket).await; + assert_eq!(initialize["method"], "initialize"); + websocket + .send(Message::Text( + json!({ + "id": initialize["id"], + "result": { "sessionId": "test-session" } + }) + .to_string() + .into(), + )) + .await + .expect("initialize response"); + let initialized = read_exec_server_json(&mut websocket).await; + assert_eq!(initialized["method"], "initialized"); + + let info = read_exec_server_json(&mut websocket).await; + assert_eq!(info["method"], "environment/info"); + websocket + .send(Message::Text( + json!({ + "id": info["id"], + "result": { "shell": { "name": "zsh", "path": "/bin/zsh" } } + }) + .to_string() + .into(), + )) + .await + .expect("environment info response"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn deferred_executor_updates_model_context_after_startup() -> Result<()> { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let server = start_mock_server().await; + let user_input_call_id = "wait-for-startup"; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + user_input_call_id, + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after startup?", + "options": [{ + "label": "Yes (Recommended)", + "description": "Continue the test." + }, { + "label": "No", + "description": "Stop the test." + }] + }] + }) + .to_string(), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_function_call( + "update-plan", + "update_plan", + &json!({ + "explanation": "Continue after startup.", + "plan": [{"step": "Finish", "status": "completed"}] + }) + .to_string(), + ), + ev_completed("resp-2"), + ]), + sse(vec![ + ev_response_created("resp-3"), + ev_assistant_message("msg-3", "done"), + ev_completed("resp-3"), + ]), + ], + ) + .await; let mut builder = test_codex() .with_exec_server_url(format!("ws://{}", listener.local_addr()?)) .with_config(|config| { assert!(config.features.enable(Feature::DeferredExecutor).is_ok()); + assert!( + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .is_ok() + ); }); - - let test = tokio::time::timeout(Duration::from_secs(5), builder.build(&server)) + let test = timeout(Duration::from_secs(5), builder.build(&server)) .await .context("thread startup should not wait for the remote environment")??; - tokio::time::timeout( - Duration::from_secs(5), - test.submit_turn("respond before the environment is ready"), - ) - .await - .context("turn should reach the model before the remote environment is ready")??; - response_mock.single_request(); + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "wait for the environment".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + let request = wait_for_event_match(&test.codex, |event| match event { + EventMsg::RequestUserInput(request) => Some(request.clone()), + _ => None, + }) + .await; + + serve_environment_info(listener).await; + test.codex + .submit(Op::UserInputAnswer { + id: request.turn_id, + response: RequestUserInputResponse { + answers: HashMap::from([( + "continue".to_string(), + RequestUserInputAnswer { + answers: vec!["Yes (Recommended)".to_string()], + }, + )]), + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 3); + assert!( + requests[0] + .message_input_texts("user") + .iter() + .any(|text| text.contains("starting")) + ); + let ready_user_context = requests[1].message_input_texts("user"); + assert_eq!( + ready_user_context + .iter() + .filter(|text| text.contains("zsh")) + .count(), + 1 + ); + let final_user_context = requests[2].message_input_texts("user"); + assert_eq!( + final_user_context + .iter() + .filter(|text| text.contains("starting")) + .count(), + 1 + ); + assert_eq!( + final_user_context + .iter() + .filter(|text| text.contains("zsh")) + .count(), + 1 + ); + Ok(()) } From 8f8de7844f9a95ba0f07b9ae584b5c08b3173dd6 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 22 Jun 2026 09:05:27 -0700 Subject: [PATCH 28/86] fix(core): restore thread_source in x-codex-turn-metadata (#29455) ## Description Restore `thread_source` in `x-codex-turn-metadata`. Inadvertently removed `thread_source` from `x-codex-turn-metadata` in https://github.com/openai/codex/pull/27122 - didn't realize it was a top-level thread app-server API field, not passed in `responsesapi_client_metadata`. This also reserves the key so `responsesapi_client_metadata` cannot override it. --- .../tests/suite/v2/client_metadata.rs | 14 ++++++++++---- codex-rs/core/src/responses_metadata.rs | 8 ++++++++ codex-rs/core/src/session/review.rs | 8 ++++++-- codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/src/turn_metadata.rs | 5 +++++ codex-rs/core/src/turn_metadata_tests.rs | 19 ++++++++++++++++++- 6 files changed, 48 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/client_metadata.rs b/codex-rs/app-server/tests/suite/v2/client_metadata.rs index e55cd0c24d..ffe7a53627 100644 --- a/codex-rs/app-server/tests/suite/v2/client_metadata.rs +++ b/codex-rs/app-server/tests/suite/v2/client_metadata.rs @@ -63,7 +63,10 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::Feature("automation".to_string())), + ..Default::default() + }) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -75,7 +78,6 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result let client_metadata = HashMap::from([ ("fiber_run_id".to_string(), "fiber-start-123".to_string()), ("origin".to_string(), "gaas".to_string()), - ("thread_source".to_string(), "client-supplied".to_string()), ]); let turn_req = mcp .send_turn_start_request(TurnStartParams { @@ -110,7 +112,7 @@ async fn turn_start_forwards_client_metadata_to_responses_request_v2() -> Result .expect("x-codex-turn-metadata header should be present"); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); - assert_eq!(metadata["thread_source"].as_str(), Some("client-supplied")); + assert_eq!(metadata["thread_source"].as_str(), Some("automation")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("installation_id").is_some()); assert!(metadata.get("session_id").is_some()); @@ -561,7 +563,10 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp - .send_thread_start_request(ThreadStartParams::default()) + .send_thread_start_request(ThreadStartParams { + thread_source: Some(ThreadSource::Feature("automation".to_string())), + ..Default::default() + }) .await?; let thread_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, @@ -619,6 +624,7 @@ async fn turn_start_forwards_client_metadata_to_responses_websocket_request_body .expect("websocket x-codex-turn-metadata client metadata should be present"); assert_eq!(metadata["fiber_run_id"].as_str(), Some("fiber-start-123")); assert_eq!(metadata["origin"].as_str(), Some("gaas")); + assert_eq!(metadata["thread_source"].as_str(), Some("automation")); assert_eq!(metadata["turn_id"].as_str(), Some(turn.id.as_str())); assert!(metadata.get("session_id").is_some()); assert_eq!( diff --git a/codex-rs/core/src/responses_metadata.rs b/codex-rs/core/src/responses_metadata.rs index 6e86ed8eb3..1852c4bea6 100644 --- a/codex-rs/core/src/responses_metadata.rs +++ b/codex-rs/core/src/responses_metadata.rs @@ -10,6 +10,7 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_string::to_ascii_json_string; use http::HeaderMap as ApiHeaderMap; use http::HeaderValue; @@ -34,6 +35,7 @@ pub(crate) const TURN_STARTED_AT_UNIX_MS_KEY: &str = "turn_started_at_unix_ms"; pub(crate) const FORKED_FROM_THREAD_ID_KEY: &str = "forked_from_thread_id"; pub(crate) const PARENT_THREAD_ID_KEY: &str = "parent_thread_id"; pub(crate) const SUBAGENT_KIND_KEY: &str = "subagent_kind"; +pub(crate) const THREAD_SOURCE_KEY: &str = "thread_source"; pub(crate) const SANDBOX_KEY: &str = "sandbox"; pub(crate) const WORKSPACES_KEY: &str = "workspaces"; @@ -56,6 +58,7 @@ const RESERVED_METADATA_KEYS: &[&str] = &[ FORKED_FROM_THREAD_ID_KEY, PARENT_THREAD_ID_KEY, SUBAGENT_KIND_KEY, + THREAD_SOURCE_KEY, SANDBOX_KEY, WORKSPACES_KEY, ]; @@ -143,6 +146,7 @@ pub struct CodexResponsesMetadata { pub(crate) parent_thread_id: Option, pub(crate) subagent_header: Option, pub(crate) subagent_kind: Option, + pub(crate) thread_source: Option, pub(crate) sandbox: Option, pub(crate) workspaces: BTreeMap, pub(crate) turn_started_at_unix_ms: Option, @@ -167,6 +171,7 @@ impl CodexResponsesMetadata { parent_thread_id: None, subagent_header: None, subagent_kind: None, + thread_source: None, sandbox: None, workspaces: BTreeMap::new(), turn_started_at_unix_ms: None, @@ -268,6 +273,7 @@ impl CodexResponsesMetadata { forked_from_thread_id: self.forked_from_thread_id, parent_thread_id: self.parent_thread_id, subagent_kind: self.subagent_kind.as_deref(), + thread_source: self.thread_source.as_ref(), sandbox: self.sandbox.as_deref(), workspaces: non_empty_workspaces(&self.workspaces), turn_started_at_unix_ms: self.turn_started_at_unix_ms, @@ -354,6 +360,8 @@ struct CodexTurnMetadataPayload<'a> { #[serde(default, skip_serializing_if = "Option::is_none")] subagent_kind: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] + thread_source: Option<&'a ThreadSource>, + #[serde(default, skip_serializing_if = "Option::is_none")] sandbox: Option<&'a str>, #[serde(default, skip_serializing_if = "Option::is_none")] workspaces: Option<&'a BTreeMap>, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 7fccc4d19f..8986ffd052 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -75,9 +75,12 @@ pub(super) async fn spawn_review_thread( .model_reasoning_summary .unwrap_or(model_info.default_reasoning_summary); let session_source = parent_turn_context.session_source.clone(); - let forked_from_thread_id = { + let (forked_from_thread_id, thread_source) = { let state = sess.state.lock().await; - state.session_configuration.forked_from_thread_id + ( + state.session_configuration.forked_from_thread_id, + state.session_configuration.thread_source.clone(), + ) }; let per_turn_config = Arc::new(per_turn_config); @@ -88,6 +91,7 @@ pub(super) async fn spawn_review_thread( forked_from_thread_id, parent_turn_context.parent_thread_id, &session_source, + thread_source, review_turn_id.clone(), #[allow(deprecated)] parent_turn_context.cwd.clone(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 2fb02e7fc1..b546355f7e 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -536,6 +536,7 @@ impl Session { session_configuration.forked_from_thread_id, session_configuration.parent_thread_id, &session_configuration.session_source, + session_configuration.thread_source.clone(), sub_id.clone(), cwd.clone(), &session_configuration.permission_profile(), diff --git a/codex-rs/core/src/turn_metadata.rs b/codex-rs/core/src/turn_metadata.rs index d13c081817..0a1f781b38 100644 --- a/codex-rs/core/src/turn_metadata.rs +++ b/codex-rs/core/src/turn_metadata.rs @@ -25,6 +25,7 @@ use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; const MODEL_KEY: &str = "model"; @@ -91,6 +92,7 @@ pub(crate) struct TurnMetadataState { parent_thread_id: Option, subagent_header: Option, subagent_kind: Option, + thread_source: Option, turn_id: String, sandbox: Option, enriched_workspaces: Arc>>>, @@ -108,6 +110,7 @@ impl TurnMetadataState { forked_from_thread_id: Option, parent_thread_id: Option, session_source: &SessionSource, + thread_source: Option, turn_id: String, cwd: AbsolutePathBuf, permission_profile: &PermissionProfile, @@ -132,6 +135,7 @@ impl TurnMetadataState { parent_thread_id, subagent_header: subagent_header_value(session_source), subagent_kind: subagent_metadata_kind(session_source), + thread_source, turn_id, sandbox, enriched_workspaces: Arc::new(RwLock::new(None)), @@ -225,6 +229,7 @@ impl TurnMetadataState { parent_thread_id: self.parent_thread_id, subagent_header: self.subagent_header.clone(), subagent_kind: self.subagent_kind.clone(), + thread_source: self.thread_source.clone(), sandbox: self.sandbox.clone(), workspaces: self.current_workspaces(), turn_started_at_unix_ms: self.current_turn_started_at_unix_ms(), diff --git a/codex-rs/core/src/turn_metadata_tests.rs b/codex-rs/core/src/turn_metadata_tests.rs index 5e6d1f32bb..83649b9187 100644 --- a/codex-rs/core/src/turn_metadata_tests.rs +++ b/codex-rs/core/src/turn_metadata_tests.rs @@ -13,6 +13,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use codex_protocol::protocol::ThreadSource; use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -187,6 +188,7 @@ fn turn_metadata_state_uses_platform_sandbox_tag() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -229,6 +231,7 @@ fn turn_metadata_state_includes_root_fork_lineage() { Some(source_thread_id), /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -267,6 +270,7 @@ fn turn_metadata_state_includes_thread_spawn_subagent_parent_without_fork() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -305,6 +309,7 @@ fn turn_metadata_state_includes_forked_thread_spawn_subagent_lineage() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -349,6 +354,7 @@ fn turn_metadata_state_includes_known_parent_for_non_thread_spawn_subagents_with /*forked_from_thread_id*/ None, Some(parent_thread_id), &SessionSource::SubAgent(subagent_source), + /*thread_source*/ None, "turn-a".to_string(), cwd.clone(), &permission_profile, @@ -380,6 +386,7 @@ fn turn_metadata_state_includes_turn_started_at_unix_ms_after_start() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -409,6 +416,7 @@ fn turn_metadata_state_includes_model_and_reasoning_effort_only_in_request_meta( /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -457,6 +465,7 @@ fn turn_metadata_state_marks_user_input_requested_during_turn_only_for_mcp_reque /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -509,6 +518,7 @@ fn turn_metadata_state_ignores_client_reserved_metadata_before_start() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -562,6 +572,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( agent_nickname: None, agent_role: None, }), + Some(ThreadSource::Feature("automation".to_string())), "turn-a".to_string(), cwd, &permission_profile, @@ -637,7 +648,7 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( Some("55555555-5555-4555-8555-555555555555") ); assert_eq!(json["subagent_kind"].as_str(), Some("thread_spawn")); - assert_eq!(json["thread_source"].as_str(), Some("client-supplied")); + assert_eq!(json["thread_source"].as_str(), Some("automation")); assert_eq!(json["turn_id"].as_str(), Some("turn-a")); assert!(json.get("request_kind").is_none()); assert!(json.get(WINDOW_ID_KEY).is_none()); @@ -650,6 +661,10 @@ fn turn_metadata_state_merges_client_metadata_without_replacing_reserved_fields( let model_request_json: Value = serde_json::from_str(&model_request_header).expect("model request json"); assert_eq!(model_request_json["request_kind"].as_str(), Some("turn")); + assert_eq!( + model_request_json["thread_source"].as_str(), + Some("automation") + ); assert_eq!( model_request_json[INSTALLATION_ID_KEY].as_str(), Some("installation-a") @@ -679,6 +694,7 @@ fn turn_metadata_state_overlays_compaction_only_on_compaction_requests() { /*forked_from_thread_id*/ None, /*parent_thread_id*/ None, &SessionSource::Exec, + /*thread_source*/ None, "turn-a".to_string(), cwd, &permission_profile, @@ -741,6 +757,7 @@ async fn turn_metadata_state_preserves_lineage_after_git_enrichment() { agent_nickname: None, agent_role: None, }), + /*thread_source*/ None, "turn-a".to_string(), repo_path, &permission_profile, From e98d43ac372ddf7f513c0e30c56dd8dc35ea5404 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 17:17:04 +0100 Subject: [PATCH 29/86] Filter noisy targets from persistent logs (#29457) ## Why The local SQLite log sink currently enables TRACE for every target. This persists high-volume dependency logs bridged through `target=log` and duplicates OpenTelemetry mirror events in `codex_otel.log_only` and `codex_otel.trace_safe`. These records rapidly consume the per-partition log budget and cause unnecessary SQLite insert-and-prune churn. ## What changed - Keep TRACE persistence for other targets. - Exclude bridged `target=log` events from the SQLite sink. - Exclude the two `codex_otel` mirror targets from the SQLite sink. - Share the same filter between app-server and TUI. Remote OpenTelemetry export and metrics are unchanged. --- codex-rs/app-server/src/lib.rs | 4 +--- codex-rs/state/src/log_db.rs | 10 ++++++++++ codex-rs/tui/src/lib.rs | 4 +--- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index f4cd83d732..264c28e098 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -68,13 +68,11 @@ use tokio::sync::mpsc; use tokio::sync::oneshot; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; -use tracing::Level; use tracing::error; use tracing::info; use tracing::warn; use tracing_subscriber::EnvFilter; use tracing_subscriber::Layer; -use tracing_subscriber::filter::Targets; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::Registry; use tracing_subscriber::util::SubscriberInitExt; @@ -666,7 +664,7 @@ pub async fn run_main_with_transport_options( let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() - .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + .map(|layer| layer.with_filter(log_db::default_filter())); let otel_logger_layer = otel.as_ref().and_then(|o| o.logger_layer()); let otel_tracing_layer = otel.as_ref().and_then(|o| o.tracing_layer()); let _ = tracing_subscriber::registry() diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 71d2afbc01..55de2a3077 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -30,11 +30,13 @@ use tokio::sync::oneshot; use tracing::Event; use tracing::field::Field; use tracing::field::Visit; +use tracing::level_filters::LevelFilter; use tracing::span::Attributes; use tracing::span::Id; use tracing::span::Record; use tracing_subscriber::Layer; use tracing_subscriber::field::RecordFields; +use tracing_subscriber::filter::Targets; use tracing_subscriber::fmt::FormatFields; use tracing_subscriber::fmt::FormattedFields; use tracing_subscriber::fmt::format::DefaultFields; @@ -48,6 +50,14 @@ const LOG_QUEUE_CAPACITY: usize = 512; const LOG_BATCH_SIZE: usize = 128; const LOG_FLUSH_INTERVAL: Duration = Duration::from_secs(2); +pub fn default_filter() -> Targets { + Targets::new() + .with_default(LevelFilter::TRACE) + .with_target("log", LevelFilter::OFF) + .with_target("codex_otel.log_only", LevelFilter::OFF) + .with_target("codex_otel.trace_safe", LevelFilter::OFF) +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub struct LogSinkQueueConfig { pub queue_capacity: usize, diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 9a4a88c86d..c5cf1e1853 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -74,12 +74,10 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Instant; pub use token_usage::TokenUsage; -use tracing::Level; use tracing::error; use tracing::warn; use tracing_appender::non_blocking; use tracing_subscriber::EnvFilter; -use tracing_subscriber::filter::Targets; use tracing_subscriber::prelude::*; use url::Url; use uuid::Uuid; @@ -1232,7 +1230,7 @@ pub async fn run_main( let log_db = state_db.clone().map(log_db::start); let log_db_layer = log_db .clone() - .map(|layer| layer.with_filter(Targets::new().with_default(Level::TRACE))); + .map(|layer| layer.with_filter(log_db::default_filter())); let _ = tracing_subscriber::registry() .with(tui_file_layer) From e79d72d75d479c1aceaf6a5853833e7ddad33c29 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 10:05:11 -0700 Subject: [PATCH 30/86] remove flag for image preparation (#29429) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What - make Fjord's centralized response-item image preparation unconditional for new and resumed history - have local user images and `view_image` outputs always defer decoding and resizing to that path - retain `resize_all_images` as an ignored, removed compatibility key for released clients - delete the flag-off producer paths and obsolete policy-specific tests ## Why Centralized preparation is now the intended image path. Keeping the runtime feature checks also kept two image-processing implementations alive and allowed client config to select the legacy behavior. This is a clean replacement for #28975, rebuilt from the latest `main`. ## How `prepare_response_items` now runs whenever items enter history and whenever persisted history is reconstructed. Producers emit deferred image data, so malformed images become the existing model-visible placeholder instead of failing the session at the producer. ## Test plan - `just fmt` - `just fix -p codex-core -p codex-features` - `just test -p codex-features` — 52 passed - focused affected `codex-core` set — 20 passed - `just test -p codex-core handle_accepts_explicit_high_detail` — 1 passed - full `just test -p codex-core` attempt — 2,723 passed; 88 unrelated environment failures from read-only `~/.codex` SQLite state and unavailable integration helper binaries --- .../tests/suite/v2/dynamic_tools.rs | 8 +- .../tests/suite/v2/imagegen_extension.rs | 10 +- codex-rs/core/src/prompt_debug.rs | 2 +- codex-rs/core/src/session/mod.rs | 43 ++----- codex-rs/core/src/session/tests.rs | 14 +-- .../core/src/tools/handlers/view_image.rs | 28 +---- codex-rs/core/tests/suite/client.rs | 4 +- codex-rs/core/tests/suite/code_mode.rs | 20 +-- codex-rs/core/tests/suite/compact.rs | 2 +- .../core/tests/suite/extension_sandbox.rs | 10 +- codex-rs/core/tests/suite/image_rollout.rs | 2 +- codex-rs/core/tests/suite/model_switching.rs | 2 +- codex-rs/core/tests/suite/responses_lite.rs | 2 +- codex-rs/core/tests/suite/rmcp_client.rs | 5 - codex-rs/core/tests/suite/truncation.rs | 2 +- codex-rs/core/tests/suite/view_image.rs | 114 +----------------- codex-rs/features/src/lib.rs | 8 +- codex-rs/features/src/tests.rs | 17 +++ 18 files changed, 72 insertions(+), 221 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index ef1cf86753..2adc2be0b2 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -38,6 +38,8 @@ use tempfile::TempDir; use tokio::time::timeout; use wiremock::MockServer; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + // macOS and Windows Bazel CI can spend tens of seconds starting app-server // subprocesses or processing test RPCs under load. #[cfg(any(target_os = "macos", windows))] @@ -650,7 +652,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]; let content_items = response_content_items @@ -695,7 +697,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( text: "dynamic-ok".to_string(), }, DynamicToolCallOutputContentItem::InputImage { - image_url: "data:image/png;base64,AAA".to_string(), + image_url: TINY_PNG_DATA_URL.to_string(), }, ]) ); @@ -721,7 +723,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( }, { "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": TINY_PNG_DATA_URL, "detail": "high" } ]) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 5c18aa8a4e..7a29bb3542 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -28,13 +28,13 @@ use wiremock::ResponseTemplate; use wiremock::matchers::method; use wiremock::matchers::path; -const RESULT: &str = "cG5n"; +const RESULT: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, - 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, - 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; -const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; #[derive(Clone, Copy)] enum ImagegenTestMode { @@ -116,7 +116,7 @@ async fn standalone_image_generation_returns_saved_path_hint_to_model() -> Resul assert_eq!(status, "completed"); assert_eq!(revised_prompt.as_deref(), Some("paint a blue whale")); assert_eq!(result, RESULT); - assert_eq!(std::fs::read(&saved_path)?, b"png"); + assert_eq!(std::fs::read(&saved_path)?, TINY_PNG_BYTES); let requests = response_mock.requests(); assert_eq!(requests.len(), 2); diff --git a/codex-rs/core/src/prompt_debug.rs b/codex-rs/core/src/prompt_debug.rs index bd9289b770..307115c505 100644 --- a/codex-rs/core/src/prompt_debug.rs +++ b/codex-rs/core/src/prompt_debug.rs @@ -81,7 +81,7 @@ pub(crate) async fn build_prompt_input_from_session( .await; if !input.is_empty() { - let response_item = sess.response_item_from_user_input(turn_context.as_ref(), input); + let response_item = sess.response_item_from_user_input(input); sess.record_conversation_items(turn_context.as_ref(), std::slice::from_ref(&response_item)) .await; } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 42eb0c29e3..32fcfd09e2 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1372,17 +1372,11 @@ impl Session { } = self .reconstruct_history_from_rollout(turn_context, rollout_items) .await; - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - // Keep the recorded rollout unchanged. Prepare its reconstructed history before - // installing it, so legacy images are processed once for this resume or fork and - // will be processed again if the rollout is reconstructed in a future session. - // This meets image resizing requirements without modifying persisted rollouts. - prepare_response_items(&mut history); - } + // Keep the recorded rollout unchanged. Prepare its reconstructed history before + // installing it, so legacy images are processed once for this resume or fork and + // will be processed again if the rollout is reconstructed in a future session. + // This meets image resizing requirements without modifying persisted rollouts. + prepare_response_items(&mut history); { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); @@ -2689,13 +2683,7 @@ impl Session { items: &'a [ResponseItem], ) -> Cow<'a, [ResponseItem]> { let mut items = Cow::Borrowed(items); - if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - prepare_response_items(items.to_mut()); - } + prepare_response_items(items.to_mut()); if turn_context.config.features.enabled(Feature::ItemIds) { Self::assign_missing_response_item_ids(items) } else { @@ -2734,23 +2722,10 @@ impl Session { items } - pub(crate) fn response_item_from_user_input( - &self, - turn_context: &TurnContext, - input: Vec, - ) -> ResponseItem { - let local_image_preparation = if turn_context - .config - .features - .enabled(Feature::ResizeAllImages) - { - LocalImagePreparation::Defer - } else { - LocalImagePreparation::Process - }; + pub(crate) fn response_item_from_user_input(&self, input: Vec) -> ResponseItem { ResponseItem::from(ResponseInputItem::from_user_input( input, - local_image_preparation, + LocalImagePreparation::Defer, )) } @@ -3666,7 +3641,7 @@ impl Session { // Persist the user message to history, but emit the turn item from `UserInput` so // UI-only `text_elements` are preserved. `ResponseItem::Message` does not carry // those spans, and `record_response_item_and_emit_turn_item` would drop them. - let response_item = self.response_item_from_user_input(turn_context, input.to_vec()); + let response_item = self.response_item_from_user_input(input.to_vec()); self.record_conversation_items(turn_context, std::slice::from_ref(&response_item)) .await; let mut user_message_item = UserMessageItem::new(input); diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index edd5ace9ed..5c56f03d02 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1694,12 +1694,11 @@ async fn record_initial_history_reconstructs_resumed_transcript() { } #[tokio::test] -async fn resize_all_images_prepares_failures_before_history_insertion() { +async fn prepares_image_failures_before_history_insertion() { let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( CodexAuth::from_api_key("Test API Key"), Vec::new(), |config| { - let _ = config.features.enable(Feature::ResizeAllImages); let _ = config.features.enable(Feature::ItemIds); }, ) @@ -1764,15 +1763,8 @@ async fn resize_all_images_prepares_failures_before_history_insertion() { } #[tokio::test] -async fn resize_all_images_prepares_resumed_history_before_installing_it() { - let (session, _turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( - CodexAuth::from_api_key("Test API Key"), - Vec::new(), - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, - ) - .await; +async fn prepares_resumed_history_before_installing_it() { + let (session, _turn_context) = make_session_and_context().await; let resumed_item = ResponseItem::Message { id: None, role: "user".to_string(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 84911fb815..09a6844f19 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -1,4 +1,3 @@ -use codex_features::Feature; use codex_protocol::items::ImageViewItem; use codex_protocol::items::TurnItem; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; @@ -8,9 +7,7 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; -use codex_utils_image::PromptImageMode; use codex_utils_image::data_url_from_bytes; -use codex_utils_image::load_for_prompt_bytes; use serde::Deserialize; use crate::function_tool::FunctionCallError; @@ -195,24 +192,8 @@ impl ViewImageHandler { DEFAULT_IMAGE_DETAIL }; - let image_url = if turn.config.features.enabled(Feature::ResizeAllImages) { - // The history insertion path owns image decoding and resizing when this is enabled. - data_url_from_bytes("application/octet-stream", &file_bytes) - } else { - let image_mode = if use_original_detail { - PromptImageMode::Original - } else { - PromptImageMode::ResizeToFit - }; - load_for_prompt_bytes(abs_path.as_path(), file_bytes, image_mode) - .map_err(|error| { - FunctionCallError::RespondToModel(format!( - "unable to process image at `{}`: {error}", - abs_path.display() - )) - })? - .into_data_url() - }; + // The history insertion path owns image decoding and resizing. + let image_url = data_url_from_bytes("application/octet-stream", &file_bytes); let item = TurnItem::ImageView(ImageViewItem { id: call_id, @@ -420,9 +401,6 @@ mod tests { }) .await; - let Err(FunctionCallError::RespondToModel(message)) = result else { - panic!("expected image processing error"); - }; - assert!(message.contains("unable to process image"), "{message}"); + result.expect("explicit high detail should be accepted"); } } diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index f9b2fe42ef..3c905351cc 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -860,7 +860,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { async fn resume_replays_image_tool_outputs_with_detail() { skip_if_no_network!(); - let image_url = "data:image/webp;base64,UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEAAUAmJaACdLoB+AADsAD+8ut//NgVzXPv9//S4P0uD9Lg/9KQAAA="; + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let function_call_id = "view-image-call"; let custom_call_id = "js-repl-call"; let thread_id = ThreadId::default(); @@ -888,7 +888,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { id: None, name: "view_image".to_string(), namespace: None, - arguments: "{\"path\":\"/tmp/example.webp\"}".to_string(), + arguments: "{\"path\":\"/tmp/example.png\"}".to_string(), call_id: function_call_id.to_string(), metadata: None, }), diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 0a765a0e26..14bee0fb8d 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -2681,7 +2681,7 @@ async fn code_mode_can_output_images_via_global_helper() -> Result<()> { &server, "use exec to return images", r#" -image("data:image/png;base64,AAA"); +image("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="); "#, ) .await?; @@ -2706,7 +2706,7 @@ image("data:image/png;base64,AAA"); items[1], serde_json::json!({ "type": "input_image", - "image_url": "data:image/png;base64,AAA", + "image_url": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==", "detail": "high" }), ); @@ -2715,17 +2715,14 @@ image("data:image/png;base64,AAA"); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { +async fn code_mode_replaces_malformed_image() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; - let (_test, second_mock) = run_code_mode_turn_with_config( + let (_test, second_mock) = run_code_mode_turn( &server, "use exec to return an image", r#"image("data:image/png;base64,AAA");"#, - |config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }, ) .await?; @@ -2746,7 +2743,7 @@ async fn resize_all_images_replaces_malformed_code_mode_image() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result<()> { +async fn code_mode_resizes_explicit_original_image() -> Result<()> { skip_if_no_network!(Ok(())); let original_dimensions = (6401, 100); @@ -2772,12 +2769,7 @@ async fn resize_all_images_resizes_explicit_original_code_mode_image() -> Result "use exec to return a large original-detail image", &code, "gpt-5.3-codex", - |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); - }, + |_| {}, ) .await?; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index e40a13dcd4..fb20603850 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -4403,7 +4403,7 @@ async fn snapshot_request_shape_pre_turn_compaction_including_incoming_user_mess ) .await .expect("override thread settings"); - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); codex .submit(Op::UserInput { diff --git a/codex-rs/core/tests/suite/extension_sandbox.rs b/codex-rs/core/tests/suite/extension_sandbox.rs index e4c01557de..aa835fa9ac 100644 --- a/codex-rs/core/tests/suite/extension_sandbox.rs +++ b/codex-rs/core/tests/suite/extension_sandbox.rs @@ -42,9 +42,11 @@ use wiremock::matchers::path; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, - 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, - 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, + 0, 0, 31, 21, 196, 137, 0, 0, 0, 13, 73, 68, 65, 84, 120, 156, 99, 248, 207, 192, 240, 31, 0, + 5, 0, 1, 255, 137, 153, 61, 29, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; +const TINY_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; fn image_generation_extensions(auth: &CodexAuth) -> Arc> { let auth_manager = codex_core::test_support::auth_manager_from_auth(auth.clone()); @@ -148,7 +150,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { .and(path("/v1/images/edits")) .respond_with(ResponseTemplate::new(200).set_body_json(json!({ "created": 1, - "data": [{"b64_json": "cG5n"}], + "data": [{"b64_json": TINY_PNG_BASE64}], }))) .expect(1) .mount(&server) @@ -299,7 +301,7 @@ async fn extension_tool_uses_granted_turn_permissions() -> Result<()> { let output = request.function_call_output(image_call_id); let image = &output["output"][0]; assert_eq!(image["type"], "input_image"); - assert_eq!(image["image_url"], "data:image/png;base64,cG5n"); + assert_eq!(image["image_url"], TINY_PNG_DATA_URL); Ok(()) } diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index 2b18559075..c97e8cc896 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -200,7 +200,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> .. } = test_codex().build(&server).await?; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=".to_string(); + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==".to_string(); let response = sse(vec![ ev_response_created("resp-1"), diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index ef7a305bd7..4688434a4d 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -506,7 +506,7 @@ async fn model_change_from_image_to_text_strips_prior_image_content() -> Result< let _ = models_manager .list_models(RefreshStrategy::OnlineIfUncached) .await; - let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGNgYAAAAAMAASsJTYQAAAAASUVORK5CYII=" + let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==" .to_string(); test.codex diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index b137d5bc73..db4faa675e 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -55,7 +55,7 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_lite_strips_data_image_detail_without_resize_all_images() -> Result<()> { +async fn responses_lite_strips_data_image_detail() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index dde226a194..2b4e09bd1c 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -24,7 +24,6 @@ use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::Environment; use codex_exec_server::HttpRequestParams; -use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_models_manager::manager::RefreshStrategy; @@ -1455,10 +1454,6 @@ async fn stdio_image_responses_resize_large_image() -> anyhow::Result<()> { let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { - config - .features - .enable(Feature::ResizeAllImages) - .expect("resize_all_images should be enabled"); insert_mcp_server( config, server_name, diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index cc942cbc17..c7e296ec0c 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -474,7 +474,7 @@ async fn mcp_image_output_preserves_image_and_no_text_summary() -> Result<()> { let rmcp_test_server_bin = stdio_server_bin()?; // 1x1 PNG data URL - let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMB/ee9bQAAAABJRU5ErkJggg=="; + let openai_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let mut builder = test_codex().with_config(move |config| { let mut servers = config.mcp_servers.get().clone(); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 1857eedefd..c2c7b562d8 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -7,7 +7,6 @@ use codex_exec_server::CreateDirectoryOptions; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::RemoveOptions; -use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; @@ -181,15 +180,10 @@ async fn write_workspace_png( async fn assert_user_turn_local_image_resizes_to( original_dimensions: (u32, u32), expected_dimensions: (u32, u32), - resize_policy: TestImageResizePolicy, ) -> anyhow::Result<()> { let server = start_mock_server().await; - let mut builder = test_codex().with_config(move |config| { - if resize_policy == TestImageResizePolicy::AllImages { - let _ = config.features.enable(Feature::ResizeAllImages); - } - }); + let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { codex, @@ -263,42 +257,25 @@ async fn assert_user_turn_local_image_resizes_to( Ok(()) } -#[derive(Clone, Copy, Eq, PartialEq)] -enum TestImageResizePolicy { - Legacy, - AllImages, -} - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768), TestImageResizePolicy::Legacy) - .await + assert_user_turn_local_image_resizes_to((2304, 864), (2048, 768)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn user_turn_with_vertical_local_image_resizes_to_square_bounds() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (1024, 4096), - (512, 2048), - TestImageResizePolicy::Legacy, - ) - .await + assert_user_turn_local_image_resizes_to((1024, 4096), (512, 2048)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_applies_patch_budget_to_local_user_image() -> anyhow::Result<()> { +async fn user_turn_local_image_applies_patch_budget() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - assert_user_turn_local_image_resizes_to( - (2048, 2048), - (1600, 1600), - TestImageResizePolicy::AllImages, - ) - .await + assert_user_turn_local_image_resizes_to((2048, 2048), (1600, 1600)).await } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -1209,11 +1186,10 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { +async fn view_image_tool_turns_invalid_image_into_placeholder() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mut builder = test_codex(); let test = builder.build_with_remote_env(&server).await?; let TestCodex { @@ -1222,84 +1198,6 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { .. } = &test; - let rel_path = "assets/example.json"; - let abs_path = - write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; - - let call_id = "view-image-non-image"; - let arguments = serde_json::json!({ "path": rel_path }).to_string(); - - let first_response = sse(vec![ - ev_response_created("resp-1"), - ev_function_call(call_id, "view_image", &arguments), - ev_completed("resp-1"), - ]); - responses::mount_sse_once(&server, first_response).await; - - let second_response = sse(vec![ - ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), - ]); - let mock = responses::mount_sse_once(&server, second_response).await; - - let session_model = session_configured.model.clone(); - - codex - .submit(disabled_user_turn( - &test, - vec![UserInput::Text { - text: "please use the view_image tool to read the json file".into(), - text_elements: Vec::new(), - }], - session_model, - )) - .await?; - - wait_for_event_with_timeout( - codex, - |event| matches!(event, EventMsg::TurnComplete(_)), - VIEW_IMAGE_TURN_COMPLETE_TIMEOUT, - ) - .await; - - let request = mock.single_request(); - assert!( - request.inputs_of_type("input_image").is_empty(), - "non-image file should not produce an input_image message" - ); - let (error_text, success) = request - .function_call_output_content_and_success(call_id) - .expect("function_call_output should be present"); - assert_eq!(success, None); - let error_text = error_text.expect("error text present"); - - let expected_error = format!( - "unable to process image at `{}`: unsupported image `application/json`", - abs_path.display() - ); - assert!( - error_text.contains(&expected_error), - "error should describe unsupported file type: {error_text}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn resize_all_images_turns_invalid_view_image_into_placeholder() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let mut builder = test_codex().with_config(|config| { - let _ = config.features.enable(Feature::ResizeAllImages); - }); - let test = builder.build_with_remote_env(&server).await?; - let TestCodex { - codex, - session_configured, - .. - } = &test; - let rel_path = "assets/invalid-image.json"; write_workspace_file(&test, rel_path, br#"{ "message": "hello" }"#.to_vec()).await?; let call_id = "view-image-invalid-placeholder"; diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 75808e49d0..933ec73c60 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -193,7 +193,7 @@ pub enum Feature { ImageGeneration, /// Replace hosted image generation with the standalone image-generation extension. ImageGenExt, - /// Resize all inline data-URL images before recording them in history. + /// Removed compatibility flag for always-on centralized image preparation. ResizeAllImages, /// Generate Responses API item IDs for client-created history items. ItemIds, @@ -472,7 +472,7 @@ impl Features { "tool_search" | "apps_mcp_path_override" => { continue; } - "image_detail_original" => { + "image_detail_original" | "resize_all_images" => { continue; } "plugin_hooks" => { @@ -1160,8 +1160,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ResizeAllImages, key: "resize_all_images", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Removed, + default_enabled: true, }, FeatureSpec { id: Feature::ItemIds, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 476c80aedc..b9902320bb 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -476,6 +476,23 @@ fn from_sources_ignores_removed_image_detail_original_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_resize_all_images_feature_key() { + let features_toml = + FeaturesToml::from(BTreeMap::from([("resize_all_images".to_string(), false)])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn from_sources_ignores_removed_undo_feature_key() { let features_toml = FeaturesToml::from(BTreeMap::from([("undo".to_string(), true)])); From c1f8b280b5cfc280c75682cab1b5be4a4dc58709 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 22 Jun 2026 10:26:30 -0700 Subject: [PATCH 31/86] ci: restore custom Windows runner with hermetic LLVM 0.7.9 (#29143) The custom Windows argument-comment-lint job was temporarily moved to `windows-2022` in #28940 after hermetic LLVM source extraction failed on the newer runner. This takes the upstream extraction fix so the job can return to the intended custom runner. This upgrades `llvm` to `0.7.9` and `rules_cc` to `0.2.18`, refreshes the module lock, rebases the remaining Windows and custom libc++ patches, drops the obsolete symlink-extraction workaround, and restores the `windows-x64` runner configuration. Validation: - Verified all LLVM patches apply cleanly against the `0.7.9` source. - Built `@llvm-project//compiler-rt:clang_rt.builtins.static`. --- .github/workflows/rust-ci.yml | 5 ++- MODULE.bazel | 11 +++--- MODULE.bazel.lock | 12 ++++-- patches/BUILD.bazel | 2 +- patches/llvm_windows_arm64_powl.patch | 13 ++++--- ....patch => llvm_windows_mingw_compat.patch} | 38 ++++++------------- 6 files changed, 37 insertions(+), 44 deletions(-) rename patches/{llvm_windows_symlink_extract.patch => llvm_windows_mingw_compat.patch} (51%) diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index 59a7820151..4cf6d6e37a 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -173,8 +173,11 @@ jobs: runner: macos-15-xlarge timeout_minutes: 30 - name: Windows - runner: windows-2022 + runner: windows-x64 timeout_minutes: 30 + runs_on: + group: ${{ github.event.repository.name }}-runners + labels: ${{ github.event.repository.name }}-windows-x64 steps: - name: Check whether argument comment lint should run id: argument_comment_lint_gate diff --git a/MODULE.bazel b/MODULE.bazel index e9ec662d09..a24f93afd8 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -2,18 +2,17 @@ module(name = "codex") bazel_dep(name = "bazel_skylib", version = "1.9.0") bazel_dep(name = "platforms", version = "1.0.0") -bazel_dep(name = "llvm", version = "0.7.1") +bazel_dep(name = "llvm", version = "0.7.9") -# The upstream LLVM archive contains a few unix-only symlink entries and is -# missing a couple of MinGW compatibility archives that windows-gnullvm needs -# during extraction and linking, so patch it until upstream grows native support. +# Patch hermetic LLVM for Codex's custom libc++ and Windows gnullvm runtime +# needs that have not landed upstream. single_version_override( module_name = "llvm", patch_strip = 1, patches = [ "//patches:llvm_rusty_v8_custom_libcxx.patch", "//patches:llvm_windows_arm64_powl.patch", - "//patches:llvm_windows_symlink_extract.patch", + "//patches:llvm_windows_mingw_compat.patch", ], ) @@ -80,7 +79,7 @@ use_repo(osx, "macos_sdk") # Needed to disable xcode... bazel_dep(name = "apple_support", version = "2.1.0") -bazel_dep(name = "rules_cc", version = "0.2.16") +bazel_dep(name = "rules_cc", version = "0.2.18") single_version_override( module_name = "rules_cc", patch_strip = 1, diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index be8d988f5d..1826001540 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -45,6 +45,7 @@ "https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e", "https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7", "https://bcr.bazel.build/modules/bazel_features/1.42.0/MODULE.bazel": "e8ca15cb2639c5f12183db6dcb678735555d0cdd739b32a0418b6532b5e565f8", + "https://bcr.bazel.build/modules/bazel_features/1.43.0/MODULE.bazel": "defa2226f06ba20550d6548c3a2ea2a7929634437a52973869c20c225450eb91", "https://bcr.bazel.build/modules/bazel_features/1.45.0/MODULE.bazel": "7daec6d87ab0703417486d4cb948af0b06f55d4d7c08cbb5978c80e79b538edf", "https://bcr.bazel.build/modules/bazel_features/1.45.0/source.json": "635e4536e09ff125b8972e0fa239c135fde5f18701f7d5115680560651dfb41d", "https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b", @@ -63,6 +64,7 @@ "https://bcr.bazel.build/modules/bazel_skylib/1.6.1/MODULE.bazel": "8fdee2dbaace6c252131c00e1de4b165dc65af02ea278476187765e1a617b917", "https://bcr.bazel.build/modules/bazel_skylib/1.7.0/MODULE.bazel": "0db596f4563de7938de764cc8deeabec291f55e8ec15299718b93c4423e9796d", "https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b", + "https://bcr.bazel.build/modules/bazel_skylib/1.8.0/MODULE.bazel": "2fb3fb53675f6adfc1ca5bfbd5cfb655ae350fba4706d924a8ec7e3ba945671c", "https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6", "https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67", "https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7", @@ -87,8 +89,8 @@ "https://bcr.bazel.build/modules/libcap/2.27.bcr.1/source.json": "3b116cbdbd25a68ffb587b672205f6d353a4c19a35452e480d58fc89531e0a10", "https://bcr.bazel.build/modules/libpfm/4.11.0/MODULE.bazel": "45061ff025b301940f1e30d2c16bea596c25b176c8b6b3087e92615adbd52902", "https://bcr.bazel.build/modules/llvm/0.7.0/MODULE.bazel": "3c07a4e5734b0ad41fe24dedaacbf3a35ce4377b7e1a21f24488a0c9ac4f1e6b", - "https://bcr.bazel.build/modules/llvm/0.7.1/MODULE.bazel": "74ac75efc6385b8a95d83bfa36ad399500f747c3d0f50287f9b6f9e854ec4814", - "https://bcr.bazel.build/modules/llvm/0.7.1/source.json": "0cac59d04dafa0ca1f70ea21cf1569e034ef8736c2c7510e834a9e8141d7e631", + "https://bcr.bazel.build/modules/llvm/0.7.9/MODULE.bazel": "452b272a6b5ecc8747db8e5b7fd9d6799ef0db17b8d437aa8d4dda4f56a3e9ea", + "https://bcr.bazel.build/modules/llvm/0.7.9/source.json": "0c098ab546a8c4b0a720dcb010714e13f9ba6ceab0d23a87b7f1083788928c31", "https://bcr.bazel.build/modules/nlohmann_json/3.6.1/MODULE.bazel": "6f7b417dcc794d9add9e556673ad25cb3ba835224290f4f848f8e2db1e1fca74", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/MODULE.bazel": "0f6b8f20b192b9ff0781406256150bcd46f19e66d807dcb0c540548439d6fc35", "https://bcr.bazel.build/modules/openssl/3.5.4.bcr.0/source.json": "543ed7627cc18e6460b9c1ae4a1b6b1debc5a5e0aca878b00f7531c7186b73da", @@ -143,7 +145,8 @@ "https://bcr.bazel.build/modules/rules_cc/0.2.13/MODULE.bazel": "eecdd666eda6be16a8d9dc15e44b5c75133405e820f620a234acc4b1fdc5aa37", "https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8", "https://bcr.bazel.build/modules/rules_cc/0.2.16/MODULE.bazel": "9242fa89f950c6ef7702801ab53922e99c69b02310c39fb6e62b2bd30df2a1d4", - "https://bcr.bazel.build/modules/rules_cc/0.2.16/source.json": "d03d5cde49376d87e14ec14b666c56075e5e3926930327fd5d0484a1ff2ac1cc", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/MODULE.bazel": "4460ec36adc8f722a6a2a4ac9374cb91f2acebadaa93fc37966129afb3dece87", + "https://bcr.bazel.build/modules/rules_cc/0.2.18/source.json": "abad668ff2fd63ada1ac49bf386d37e27048b89a3465a6fd968bb832b00a09d3", "https://bcr.bazel.build/modules/rules_cc/0.2.4/MODULE.bazel": "1ff1223dfd24f3ecf8f028446d4a27608aa43c3f41e346d22838a4223980b8cc", "https://bcr.bazel.build/modules/rules_cc/0.2.8/MODULE.bazel": "f1df20f0bf22c28192a794f29b501ee2018fa37a3862a1a2132ae2940a23a642", "https://bcr.bazel.build/modules/rules_foreign_cc/0.9.0/MODULE.bazel": "c9e8c682bf75b0e7c704166d79b599f93b72cfca5ad7477df596947891feeef6", @@ -221,8 +224,9 @@ "https://bcr.bazel.build/modules/stardoc/0.7.2/source.json": "58b029e5e901d6802967754adf0a9056747e8176f017cfe3607c0851f4d42216", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91", "https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.2/MODULE.bazel": "75aab2373a4bbe2a1260b9bf2a1ebbdbf872d3bd36f80bff058dccd82e89422f", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/MODULE.bazel": "e8f9ff79199e8d9eaad7f1b0a77ad74b30bb82d794b87d8ca942bead5de83ae9", + "https://bcr.bazel.build/modules/tar.bzl/0.10.4/source.json": "20143442376c03426f6135292ba02d825cb75308aa47e6bf42dd4cc5a435c2ff", "https://bcr.bazel.build/modules/tar.bzl/0.9.0/MODULE.bazel": "452a22d7f02b1c9d7a22ab25edf20f46f3e1101f0f67dc4bfbf9a474ddf02445", - "https://bcr.bazel.build/modules/tar.bzl/0.9.0/source.json": "c732760a374831a2cf5b08839e4be75017196b4d796a5aa55235272ee17cd839", "https://bcr.bazel.build/modules/upb/0.0.0-20220923-a547704/MODULE.bazel": "7298990c00040a0e2f121f6c32544bab27d4452f80d9ce51349b1a28f3005c43", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/MODULE.bazel": "b573395fe63aef4299ba095173e2f62ccfee5ad9bbf7acaa95dba73af9fc2b38", "https://bcr.bazel.build/modules/with_cfg.bzl/0.12.0/source.json": "3f3fbaeafecaf629877ad152a2c9def21f8d330d91aa94c5dc75bbb98c10b8b8", diff --git a/patches/BUILD.bazel b/patches/BUILD.bazel index a431c3f3a6..ed14137170 100644 --- a/patches/BUILD.bazel +++ b/patches/BUILD.bazel @@ -6,7 +6,7 @@ exports_files([ "bzip2_windows_stack_args.patch", "llvm_rusty_v8_custom_libcxx.patch", "llvm_windows_arm64_powl.patch", - "llvm_windows_symlink_extract.patch", + "llvm_windows_mingw_compat.patch", "rules_rust_windows_bootstrap_process_wrapper_linker.patch", "rules_rust_windows_build_script_runner_paths.patch", "rules_rust_windows_exec_bin_target.patch", diff --git a/patches/llvm_windows_arm64_powl.patch b/patches/llvm_windows_arm64_powl.patch index 5e12bd7d3b..0fd33c9766 100644 --- a/patches/llvm_windows_arm64_powl.patch +++ b/patches/llvm_windows_arm64_powl.patch @@ -15,10 +15,11 @@ diff --git a/runtimes/mingw/crt_sources.bzl b/runtimes/mingw/crt_sources.bzl diff --git a/toolchain/args/windows/BUILD.bazel b/toolchain/args/windows/BUILD.bazel --- a/toolchain/args/windows/BUILD.bazel +++ b/toolchain/args/windows/BUILD.bazel -@@ -49,5 +49,6 @@ cc_args( +@@ -45,5 +45,6 @@ cc_args( + args = [ + "-L{mingw_import_library_search_path}", + "-L{mingw_crt_library_search_path}", +- + "-lmingwex", - # For now, we force on ucrt. - # TODO(cerisier): make this either a constraint or a build setting - "-lucrt", - ], - data = [ ++ + # Clang will respect the user's chosen CRT variant. diff --git a/patches/llvm_windows_symlink_extract.patch b/patches/llvm_windows_mingw_compat.patch similarity index 51% rename from patches/llvm_windows_symlink_extract.patch rename to patches/llvm_windows_mingw_compat.patch index 9f548636b2..66cf4e7b34 100644 --- a/patches/llvm_windows_symlink_extract.patch +++ b/patches/llvm_windows_mingw_compat.patch @@ -1,26 +1,11 @@ -diff --git a/extensions/llvm_source.bzl b/extensions/llvm_source.bzl -index 89dcf81..cf27c92 100644 ---- a/extensions/llvm_source.bzl -+++ b/extensions/llvm_source.bzl -@@ -83,6 +83,13 @@ def _llvm_source_archive_excludes(): - "offload", - "libc/docs", - "libc/utils/gn", -+ # These entries are unix symlinks in the upstream source tarball. -+ # Windows tar.exe cannot materialize them during repository extraction, -+ # but nothing in our Bazel usage needs the symlink entrypoints. -+ "llvm/utils/mlgo-utils/extract_ir.py", -+ "llvm/utils/mlgo-utils/make_corpus.py", -+ "llvm/utils/mlgo-utils/combine_training_corpus.py", -+ "llvm/docs/_themes/llvm-theme", - ] - - test_docs_subprojects = [ +# What: provide MinGW compatibility archives required by Windows gnullvm links. +# Why: clang and prebuilt MSVC archives can request these names even though the +# hermetic MinGW runtime does not publish them by default. + diff --git a/runtimes/mingw/BUILD.bazel b/runtimes/mingw/BUILD.bazel -index ebd99db..9eb5d5b 100644 --- a/runtimes/mingw/BUILD.bazel +++ b/runtimes/mingw/BUILD.bazel -@@ -334,6 +334,30 @@ stub_library( +@@ -386,14 +386,42 @@ stub_library( name = "stdc++", ) @@ -51,14 +36,15 @@ index ebd99db..9eb5d5b 100644 copy_to_directory( name = "mingw_crt_library_search_directory", srcs = [ -@@ -344,6 +364,10 @@ copy_to_directory( ++ ":libcmt", + ":m", + ":mingw32", + ":mingwex", ":moldname", + ":oldnames", ":pthread", - ":stdc++", + ":ssp", + ":ssp_nonshared", -+ ":libcmt", - ":ucrt", - ":ucrtbase", - ":ucrtbased", + ":stdc++", + ":uuid", + ":winpthread", From 44dbae90eb5a2e3e047d9636bf2463f3beb00fa8 Mon Sep 17 00:00:00 2001 From: jameswt-oai Date: Mon, 22 Jun 2026 10:27:23 -0700 Subject: [PATCH 32/86] [codex] Centralize Plugin Analytics Metadata (#27102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR moves construction of `PluginTelemetryMetadata` from loader and model helpers into `PluginsManager`, which already owns installed plugin state and will eventually perform remote identity enrichment. The metadata type remains in `codex-plugin`, and serialized analytics events remain unchanged. ## Before ```mermaid flowchart LR subgraph Events["Analytics event paths"] direction TB Lifecycle["Local install / uninstall"] Config["Enable / disable"] Remote["Remote install"] Used["Plugin used"] end subgraph Construction["Metadata construction"] direction TB Loader["Loader telemetry helpers"] Summary["PluginCapabilitySummary::telemetry_metadata"] Override["Caller adds remote_plugin_id"] end Metadata["PluginTelemetryMetadata"] Lifecycle --> Loader Config --> Loader Remote --> Loader Loader -->|"local events"| Metadata Loader -->|"remote install"| Override Override --> Metadata Used --> Summary Summary --> Metadata ``` Telemetry metadata was constructed through loader helpers, a capability-summary method, and a remote-install call-site override. ## After ```mermaid flowchart LR subgraph Events["Analytics event paths"] direction TB Lifecycle["Local install / uninstall"] Config["Enable / disable"] Remote["Remote install"] Used["Plugin used"] end Manager["PluginsManager — single construction owner"] Metadata["PluginTelemetryMetadata"] Lifecycle --> Manager Config --> Manager Remote -->|"authoritative remote ID"| Manager Used -->|"capability summary"| Manager Manager --> Metadata ``` Every analytics path delegates metadata construction to `PluginsManager`. Remote install still supplies its authoritative backend ID explicitly. ## What Changes - Make loader code return a focused plugin capability summary instead of constructing analytics metadata. - Centralize immutable plugin telemetry metadata construction in `PluginsManager`. - Route local install/uninstall, remote install, enable/disable, and plugin-used emitters through the manager. - Preserve the current serialized analytics contract exactly. Normal metadata still has no remote override. Remote install continues to provide its authoritative backend ID explicitly, so the existing serializer continues reporting that ID through `plugin_id`. Snapshot-based enrichment is intentionally deferred to the final PR. ## Testing - `just test -p codex-core-plugins` (238 tests passed) - `just test -p codex-plugin` (3 tests passed) - Scoped Clippy/compile checks passed for `codex-plugin`, `codex-core-plugins`, `codex-app-server`, and `codex-core`. ## Split Overview ```text main ├── #27093 Debug analytics capture (merged) ├── #27099 Non-mutating plugin smoke (merged) ├── #27100 Remote install/uninstall smoke (merged) └── #27102 Plugin telemetry metadata refactor ← you are here └── #27669 Persist remote plugin identity After #27102 and #27669 merge: └── Final PR: add explicit local and remote IDs to plugin analytics ``` Review order and dependencies: 1. [#27093 Add debug-only analytics event capture](https://github.com/openai/codex/pull/27093) (merged) 2. [#27099 Add a plugin analytics smoke workflow](https://github.com/openai/codex/pull/27099) (merged) 3. [#27100 Add a remote plugin analytics mutation smoke workflow](https://github.com/openai/codex/pull/27100) (merged) 4. This metadata refactor, independent and based on `main` 5. [#27669 Persist remote plugin identity](https://github.com/openai/codex/pull/27669), stacked on this PR 6. Final remote-ID behavior PR, created after the prerequisites merge The original [#26281](https://github.com/openai/codex/pull/26281) remains open as the aggregate reference until the final replacement PR is published. --- .../analytics/src/analytics_client_tests.rs | 9 +- codex-rs/app-server/src/request_processors.rs | 1 - .../request_processors/config_processor.rs | 9 +- .../src/request_processors/plugins.rs | 18 ++-- codex-rs/core-plugins/src/loader.rs | 47 +++------- codex-rs/core-plugins/src/manager.rs | 72 ++++++++++++++-- codex-rs/core-plugins/src/manager_tests.rs | 85 +++++++++++++++++-- codex-rs/core/src/session/turn.rs | 17 ++-- codex-rs/plugin/src/lib.rs | 22 ----- 9 files changed, 189 insertions(+), 91 deletions(-) diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index a9a1d90e74..091a85e11f 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -3504,10 +3504,11 @@ async fn reducer_ingests_plugin_install_failed_fact() { async fn reducer_ingests_plugin_install_failed_fact_without_detail() { let mut reducer = AnalyticsReducer::default(); let mut events = Vec::new(); - let mut plugin = PluginTelemetryMetadata::from_plugin_id( - &PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), - ); - plugin.remote_plugin_id = Some("plugins~Plugin_00000000000000000000000000000000".to_string()); + let plugin = PluginTelemetryMetadata { + plugin_id: PluginId::parse("unknown@openai-curated-remote").expect("valid plugin id"), + remote_plugin_id: Some("plugins~Plugin_00000000000000000000000000000000".to_string()), + capability_summary: None, + }; reducer .ingest( diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index c8bd48c474..2972d05f09 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -337,7 +337,6 @@ use codex_core_plugins::PluginUninstallError as CorePluginUninstallError; use codex_core_plugins::PluginsManager; use codex_core_plugins::loader::load_plugin_apps; use codex_core_plugins::loader::load_plugin_mcp_servers; -use codex_core_plugins::loader::plugin_telemetry_metadata_from_root; use codex_core_plugins::manifest::PluginManifestInterface; use codex_core_plugins::marketplace::MarketplaceError; use codex_core_plugins::marketplace::MarketplacePluginSource; diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs index 15c3b7c864..50bba54009 100644 --- a/codex-rs/app-server/src/request_processors/config_processor.rs +++ b/codex-rs/app-server/src/request_processors/config_processor.rs @@ -295,15 +295,14 @@ impl ConfigRequestProcessor { &self, pending_changes: std::collections::BTreeMap, ) { + let plugins_manager = self.thread_manager.plugins_manager(); for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { continue; }; - let metadata = codex_core_plugins::loader::installed_plugin_telemetry_metadata( - self.config_manager.codex_home(), - &plugin_id, - ) - .await; + let metadata = plugins_manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; if enabled { self.analytics_events_client.track_plugin_enabled(metadata); } else { diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index e9bf044766..1e4716a88d 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -23,7 +23,6 @@ use codex_mcp::McpOAuthLoginSupport; use codex_mcp::oauth_login_support; use codex_mcp::should_retry_without_scopes; use codex_plugin::PluginId; -use codex_plugin::PluginTelemetryMetadata; use codex_rmcp_client::perform_oauth_login_silent; #[derive(Clone)] @@ -1620,9 +1619,14 @@ impl PluginRequestProcessor { Some(self.effective_plugins_changed_callback()), ); - let mut plugin_metadata = - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path).await; - plugin_metadata.remote_plugin_id = Some(remote_plugin_id.clone()); + let plugin_metadata = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_installed_plugin_with_remote_id( + &result.plugin_id, + &remote_plugin_id, + ) + .await; self.analytics_events_client .track_plugin_installed(plugin_metadata); @@ -1713,8 +1717,10 @@ impl PluginRequestProcessor { else { return; }; - let mut plugin = PluginTelemetryMetadata::from_plugin_id(&plugin_id); - plugin.remote_plugin_id = Some(remote_plugin_id.to_string()); + let plugin = self + .thread_manager + .plugins_manager() + .telemetry_metadata_for_plugin_id_with_remote_id(&plugin_id, remote_plugin_id); self.analytics_events_client .track_plugin_install_failed(plugin, error_type.to_string()); } diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 54eb371686..2a8d44fc97 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -38,7 +38,6 @@ use codex_plugin::PluginCapabilitySummary; use codex_plugin::PluginHookSource; use codex_plugin::PluginId; use codex_plugin::PluginIdError; -use codex_plugin::PluginTelemetryMetadata; use codex_plugin::app_connector_ids_from_declarations; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; @@ -1164,13 +1163,11 @@ fn cleaned_app_category(category: Option) -> Option { .filter(|category| !category.is_empty()) } -pub async fn plugin_telemetry_metadata_from_root( +pub async fn plugin_capability_summary_from_root( plugin_id: &PluginId, plugin_root: &AbsolutePathBuf, -) -> PluginTelemetryMetadata { - let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; +) -> Option { + let manifest = load_plugin_manifest(plugin_root.as_path())?; let manifest_paths = &manifest.paths; let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty(); @@ -1192,18 +1189,14 @@ pub async fn plugin_telemetry_metadata_from_root( .await; let app_connector_ids = app_connector_ids_from_declarations(&app_declarations); - PluginTelemetryMetadata { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: Some(PluginCapabilitySummary { - config_name: plugin_id.as_key(), - display_name: plugin_id.plugin_name.clone(), - description: None, - has_skills, - mcp_server_names, - app_connector_ids, - }), - } + Some(PluginCapabilitySummary { + config_name: plugin_id.as_key(), + display_name: plugin_id.plugin_name.clone(), + description: None, + has_skills, + mcp_server_names, + app_connector_ids, + }) } pub async fn load_plugin_mcp_servers( @@ -1279,24 +1272,6 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest( mcp_servers } -pub async fn installed_plugin_telemetry_metadata( - codex_home: &Path, - plugin_id: &PluginId, -) -> PluginTelemetryMetadata { - let store = match PluginStore::try_new(codex_home.to_path_buf()) { - Ok(store) => store, - Err(err) => { - warn!("failed to resolve plugin cache root: {err}"); - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - } - }; - let Some(plugin_root) = store.active_plugin_root(plugin_id) else { - return PluginTelemetryMetadata::from_plugin_id(plugin_id); - }; - - plugin_telemetry_metadata_from_root(plugin_id, &plugin_root).await -} - async fn load_mcp_servers_from_file( plugin_root: &Path, mcp_config_path: &AbsolutePathBuf, diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index b502106ea9..19b48b2c01 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -6,7 +6,6 @@ use crate::is_openai_curated_marketplace_name; use crate::loader::PluginHookLoadOutcome; use crate::loader::configured_curated_plugin_ids_from_codex_home; use crate::loader::curated_plugin_cache_version; -use crate::loader::installed_plugin_telemetry_metadata; use crate::loader::load_plugin_apps_from_manifest; use crate::loader::load_plugin_hooks; use crate::loader::load_plugin_hooks_from_layer_stack; @@ -15,7 +14,7 @@ use crate::loader::load_plugin_skills; use crate::loader::load_plugins_from_layer_stack; use crate::loader::log_plugin_load_errors; use crate::loader::materialize_marketplace_plugin_source; -use crate::loader::plugin_telemetry_metadata_from_root; +use crate::loader::plugin_capability_summary_from_root; use crate::loader::refresh_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache; use crate::loader::refresh_non_curated_plugin_cache_force_reinstall; @@ -709,6 +708,66 @@ impl PluginsManager { remote_installed_plugins_to_config(plugins, &self.store) } + pub async fn telemetry_metadata_for_installed_plugin( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + let mut metadata = self.telemetry_metadata_for_plugin_id(plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub async fn telemetry_metadata_for_installed_plugin_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + let mut metadata = + self.telemetry_metadata_for_plugin_id_with_remote_id(plugin_id, remote_plugin_id); + metadata.capability_summary = match self.store.active_plugin_root(plugin_id) { + Some(plugin_root) => plugin_capability_summary_from_root(plugin_id, &plugin_root).await, + None => None, + }; + metadata + } + + pub fn telemetry_metadata_for_plugin_id( + &self, + plugin_id: &PluginId, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + plugin_id: plugin_id.clone(), + remote_plugin_id: None, + capability_summary: None, + } + } + + pub fn telemetry_metadata_for_plugin_id_with_remote_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> PluginTelemetryMetadata { + PluginTelemetryMetadata { + remote_plugin_id: Some(remote_plugin_id.to_string()), + ..self.telemetry_metadata_for_plugin_id(plugin_id) + } + } + + pub fn telemetry_metadata_for_capability_summary( + &self, + summary: &PluginCapabilitySummary, + ) -> Option { + let plugin_id = PluginId::parse(&summary.config_name).ok()?; + Some(PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(summary.clone()), + }) + } + pub fn build_remote_installed_plugin_marketplaces_from_cache( &self, visible_marketplaces: &[&str], @@ -1281,7 +1340,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_install_failed( - PluginTelemetryMetadata::from_plugin_id(plugin_id), + self.telemetry_metadata_for_plugin_id(plugin_id), error_type.to_string(), ); } @@ -1351,7 +1410,7 @@ impl PluginsManager { }; if let Some(analytics_events_client) = analytics_events_client { analytics_events_client.track_plugin_installed( - plugin_telemetry_metadata_from_root(&result.plugin_id, &result.installed_path) + self.telemetry_metadata_for_installed_plugin(&result.plugin_id) .await, ); } @@ -1392,7 +1451,10 @@ impl PluginsManager { async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> { let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() { - Some(installed_plugin_telemetry_metadata(self.codex_home.as_path(), &plugin_id).await) + Some( + self.telemetry_metadata_for_installed_plugin(&plugin_id) + .await, + ) } else { None }; diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 4f448ff160..50a5fe3e73 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -845,6 +845,81 @@ remote_plugin = true assert_eq!(outcome, PluginLoadOutcome::default()); } +#[tokio::test] +async fn installed_plugin_telemetry_metadata_collects_capabilities() { + let codex_home = TempDir::new().unwrap(); + write_cached_plugin(codex_home.path(), "test", "sample"); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin(&plugin_id) + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: None, + capability_summary: Some(PluginCapabilitySummary { + config_name: "sample@test".to_string(), + display_name: "sample".to_string(), + description: None, + has_skills: true, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }), + } + ); +} + +#[tokio::test] +async fn installed_plugin_telemetry_metadata_accepts_authoritative_remote_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let plugin_id = + PluginId::parse("linear@openai-curated-remote").expect("plugin id should parse"); + + let metadata = manager + .telemetry_metadata_for_installed_plugin_with_remote_id(&plugin_id, "plugins~Plugin_linear") + .await; + + assert_eq!( + metadata, + PluginTelemetryMetadata { + plugin_id, + remote_plugin_id: Some("plugins~Plugin_linear".to_string()), + capability_summary: None, + } + ); +} + +#[test] +fn capability_summary_telemetry_metadata_uses_local_identity() { + let codex_home = TempDir::new().unwrap(); + let manager = PluginsManager::new(codex_home.path().to_path_buf()); + let summary = PluginCapabilitySummary { + config_name: "linear@openai-curated-remote".to_string(), + display_name: "Linear".to_string(), + description: Some("Track work".to_string()), + has_skills: true, + mcp_server_names: vec!["linear".to_string()], + app_connector_ids: vec![AppConnectorId("linear-app".to_string())], + }; + + let metadata = manager.telemetry_metadata_for_capability_summary(&summary); + + assert_eq!( + metadata, + Some(PluginTelemetryMetadata { + plugin_id: PluginId::parse("linear@openai-curated-remote") + .expect("plugin id should parse"), + remote_plugin_id: None, + capability_summary: Some(summary), + }) + ); +} + #[tokio::test] async fn remote_installed_cache_prefers_local_curated_conflicts_when_remote_plugin_disabled() { let codex_home = TempDir::new().unwrap(); @@ -1154,14 +1229,14 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "sample@test".to_string(), display_name: "sample".to_string(), @@ -1174,7 +1249,7 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() { } #[tokio::test] -async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { +async fn plugin_capability_summary_uses_manifest_mcp_server_objects() { let codex_home = TempDir::new().unwrap(); let plugin_root = codex_home .path() @@ -1195,14 +1270,14 @@ async fn plugin_telemetry_metadata_uses_manifest_mcp_server_objects() { }"#, ); - let metadata = plugin_telemetry_metadata_from_root( + let summary = plugin_capability_summary_from_root( &PluginId::parse("counter-sample@test").expect("plugin id should parse"), &plugin_root.abs(), ) .await; assert_eq!( - metadata.capability_summary, + summary, Some(PluginCapabilitySummary { config_name: "counter-sample@test".to_string(), display_name: "counter-sample".to_string(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 9b9c7cc66e..81f9f52356 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -656,13 +656,16 @@ async fn build_skills_and_plugins( sess.services .analytics_events_client .track_app_mentioned(tracking.clone(), mentioned_app_invocations); - for plugin in mentioned_plugins - .iter() - .filter_map(crate::plugins::PluginCapabilitySummary::telemetry_metadata) - { - sess.services - .analytics_events_client - .track_plugin_used(tracking.clone(), plugin); + for summary in &mentioned_plugins { + if let Some(plugin) = sess + .services + .plugins_manager + .telemetry_metadata_for_capability_summary(summary) + { + sess.services + .analytics_events_client + .track_plugin_used(tracking.clone(), plugin); + } } let mut injection_items: Vec = match injected_host_skill_prompts { diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 7daa78bc06..1a4eb23af1 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -76,25 +76,3 @@ pub struct PluginTelemetryMetadata { pub remote_plugin_id: Option, pub capability_summary: Option, } - -impl PluginTelemetryMetadata { - pub fn from_plugin_id(plugin_id: &PluginId) -> Self { - Self { - plugin_id: plugin_id.clone(), - remote_plugin_id: None, - capability_summary: None, - } - } -} - -impl PluginCapabilitySummary { - pub fn telemetry_metadata(&self) -> Option { - PluginId::parse(&self.config_name) - .ok() - .map(|plugin_id| PluginTelemetryMetadata { - plugin_id, - remote_plugin_id: None, - capability_summary: Some(self.clone()), - }) - } -} From f6fa2593126d726fed67920e24eadc5c5c12bf0d Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 10:30:16 -0700 Subject: [PATCH 33/86] TUI Plugin Sharing 3 - render remote plugin catalog sections (#26703) ## Summary [#26701](https://github.com/openai/codex/pull/26701) added remote plugin identity support, [#26702](https://github.com/openai/codex/pull/26702) added remote-section fetching and state, and [#28768](https://github.com/openai/codex/pull/28768) extracted the catalog rendering module. This PR builds the product-facing `/plugins` catalog on that foundation so remote records appear as OpenAI Curated, Workspace, and Shared with me sections rather than backend marketplace implementation details. Plugin details remain read-only for sharing metadata. This PR does not add share-authoring actions or change the app-server protocol. ## Changes - Renders OpenAI Curated, Workspace, and Shared with me sections with loading, empty, and error states. - Preserves section selection and stable tab ordering as remote sections transition between fallback and populated states. - Shows OpenAI Curated loading only when the explicit vertical fallback request was issued. - Centralizes remote marketplace identity matching around the existing marketplace constants. - Uses product labels for remote marketplaces and identifies the personal marketplace as Local by its path. - Shows read-only source, authentication, version, and sharing metadata in plugin detail views. - Applies narrow display deduplication for local and remote records sharing a remote plugin ID: - installed records take precedence; - local mapped sources are preferred for details only when their installed state matches the selected record. - Returns from detail and confirmation views through the current plugin cache so newly loaded remote sections are not overwritten by an older captured response. - Keeps admin-disabled plugins view-only and labels default-installed plugins as Available by default. ## Tests New tests: - `plugins_popup_admin_disabled_available_plugin_has_view_only_hint` - `plugins_popup_remote_section_fallback_states_snapshot` - `plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled` Updated existing plugin catalog tests and snapshots for product labels, detail metadata, personal-marketplace labeling, and stable tab ordering. Verification: - `cargo clippy -p codex-tui --all-targets -- -D warnings` ## Follow-ups - Local/remote duplicate normalization should eventually move into app-server. This PR intentionally keeps the compatibility behavior narrow and display-only. - PR5 will sanitize sensitive components before displaying Git source URLs. --- codex-rs/tui/src/app/event_dispatch.rs | 3 + codex-rs/tui/src/app_event.rs | 6 + codex-rs/tui/src/chatwidget/plugin_catalog.rs | 807 +++++++++++++++--- codex-rs/tui/src/chatwidget/plugins.rs | 35 + ...ests__plugin_detail_popup_installable.snap | 4 +- ..._tests__plugin_detail_popup_installed.snap | 2 + ...ts__plugins_popup_curated_marketplace.snap | 8 +- ...ins_popup_newly_installed_marketplace.snap | 4 +- .../chatwidget/tests/popups_and_settings.rs | 284 +++++- 9 files changed, 1003 insertions(+), 150 deletions(-) diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 181e60d963..0830c69b0f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -507,6 +507,9 @@ impl App { AppEvent::PluginsLoaded { cwd, result } => { self.chat_widget.on_plugins_loaded(cwd, result); } + AppEvent::OpenPluginsList { cwd, response } => { + self.chat_widget.open_plugins_list(cwd, response); + } AppEvent::PluginRemoteSectionsLoaded { cwd, marketplaces, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 643cd9e2d8..26686c3b53 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -450,6 +450,12 @@ pub(crate) enum AppEvent { result: Result, }, + /// Open the plugin list from an already cached response. + OpenPluginsList { + cwd: PathBuf, + response: PluginListResponse, + }, + /// Result of explicitly fetching remote-backed plugin sections. PluginRemoteSectionsLoaded { cwd: PathBuf, diff --git a/codex-rs/tui/src/chatwidget/plugin_catalog.rs b/codex-rs/tui/src/chatwidget/plugin_catalog.rs index c51e2e8ea9..a19ede3ab5 100644 --- a/codex-rs/tui/src/chatwidget/plugin_catalog.rs +++ b/codex-rs/tui/src/chatwidget/plugin_catalog.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::path::Path; use std::time::Duration; use std::time::Instant; @@ -9,6 +10,7 @@ use super::plugins::PLUGINS_SELECTION_VIEW_ID; use super::plugins::PluginsCacheState; use crate::app_event::AppEvent; use crate::app_event::PluginLocation; +use crate::app_event::PluginRemoteSectionError; use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; @@ -24,18 +26,24 @@ use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::tui::FrameRequester; +use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSharePrincipal; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_core_plugins::is_openai_curated_marketplace_name; +use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; +use codex_utils_absolute_path::AbsolutePathBuf; use crossterm::event::KeyCode; use ratatui::buffer::Buffer; use ratatui::layout::Rect; @@ -55,6 +63,179 @@ const PLUGIN_ROW_PREFIX_WIDTH: usize = 6; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; +const PERSONAL_MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json"; +const REMOTE_LOADING_TAB_ID_PREFIX: &str = "remote-loading:"; +const REMOTE_EMPTY_TAB_ID_PREFIX: &str = "remote-empty:"; +const REMOTE_ERROR_TAB_ID_PREFIX: &str = "remote-error:"; +const WORKSPACE_SECTION_TAB_ORDER: u8 = 0; +const SHARED_WITH_ME_SECTION_TAB_ORDER: u8 = 1; +const SHARED_WITH_ME_LINK_SECTION_TAB_ORDER: u8 = 2; +const LOCAL_MARKETPLACE_TAB_ORDER: u8 = 3; +const OTHER_MARKETPLACE_TAB_ORDER: u8 = 4; + +#[derive(Debug, Clone)] +struct PreferredLocalPluginSource { + marketplace_path: AbsolutePathBuf, + plugin_name: String, + installed: bool, +} + +#[derive(Debug, Clone, Copy)] +enum MarketplaceProduct { + OpenAiCurated, + Workspace, + SharedWithMe, + SharedWithMeLink, + Local, + Other, +} + +impl MarketplaceProduct { + fn from_marketplace(marketplace: &PluginMarketplaceEntry) -> Self { + Self::from_marketplace_parts(&marketplace.name, marketplace.path.as_ref()) + } + + fn from_marketplace_parts( + marketplace_name: &str, + marketplace_path: Option<&AbsolutePathBuf>, + ) -> Self { + if marketplace_path.is_some_and(is_personal_marketplace_path) { + return Self::Local; + } + + Self::from_marketplace_name(marketplace_name) + } + + fn from_marketplace_name(marketplace_name: &str) -> Self { + if is_openai_curated_marketplace_name(marketplace_name) + || marketplace_name == REMOTE_GLOBAL_MARKETPLACE_NAME + { + return Self::OpenAiCurated; + } + + match marketplace_name { + REMOTE_WORKSPACE_MARKETPLACE_NAME => Self::Workspace, + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME => Self::SharedWithMe, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Self::SharedWithMeLink, + _ => Self::Other, + } + } + + fn label(self) -> Option<&'static str> { + match self { + Self::OpenAiCurated => Some("OpenAI Curated"), + Self::Workspace => Some("Workspace"), + Self::SharedWithMe => Some("Shared with me"), + Self::SharedWithMeLink => Some("Shared with me (link)"), + Self::Local => Some("Local"), + Self::Other => None, + } + } + + fn tab_order(self) -> u8 { + match self { + Self::Workspace => WORKSPACE_SECTION_TAB_ORDER, + Self::SharedWithMe => SHARED_WITH_ME_SECTION_TAB_ORDER, + Self::SharedWithMeLink => SHARED_WITH_ME_LINK_SECTION_TAB_ORDER, + Self::Local => LOCAL_MARKETPLACE_TAB_ORDER, + Self::OpenAiCurated | Self::Other => OTHER_MARKETPLACE_TAB_ORDER, + } + } + + fn is_by_openai(self) -> bool { + matches!(self, Self::OpenAiCurated) + } +} + +#[derive(Debug, Clone, Copy)] +struct RemoteMarketplaceSection { + id: &'static str, + label: &'static str, + loading_tab_id: &'static str, + marketplace_names: &'static [&'static str], + empty_item_name: &'static str, + empty_item_description: &'static str, + tab_order: u8, +} + +const REMOTE_MARKETPLACE_SECTIONS: [RemoteMarketplaceSection; 2] = [ + RemoteMarketplaceSection { + id: "workspace", + label: "Workspace", + loading_tab_id: "workspace-loading", + marketplace_names: &[REMOTE_WORKSPACE_MARKETPLACE_NAME], + empty_item_name: "No workspace plugins available", + empty_item_description: "No workspace directory plugins are available.", + tab_order: WORKSPACE_SECTION_TAB_ORDER, + }, + RemoteMarketplaceSection { + id: "shared-with-me", + label: "Shared with me", + loading_tab_id: "shared-with-me-loading", + marketplace_names: &[ + REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + ], + empty_item_name: "No shared plugins available", + empty_item_description: "No plugins have been shared with you.", + tab_order: SHARED_WITH_ME_SECTION_TAB_ORDER, + }, +]; + +impl RemoteMarketplaceSection { + fn fallback_tab( + self, + marketplaces: &[PluginMarketplaceEntry], + remote_sections_loading: bool, + remote_sections_loaded: bool, + section_errors: &[PluginRemoteSectionError], + ) -> Option<(u8, SelectionTab)> { + if marketplaces + .iter() + .any(|marketplace| self.contains_marketplace(&marketplace.name)) + { + return None; + } + + let tab = if remote_sections_loading { + remote_section_loading_tab(self.loading_tab_id, self.label) + } else if remote_sections_loaded { + if let Some(section_error) = plugin_remote_section_error(section_errors, self.id) { + remote_section_error_tab(section_error) + } else { + remote_section_empty_tab( + self.id, + self.label, + self.empty_item_name, + self.empty_item_description, + ) + } + } else { + return None; + }; + + Some((self.tab_order, tab)) + } + + fn contains_marketplace(self, marketplace_name: &str) -> bool { + self.marketplace_names.contains(&marketplace_name) + } + + fn is_fallback_tab_id(self, tab_id: &str) -> bool { + tab_id.strip_prefix(REMOTE_LOADING_TAB_ID_PREFIX) == Some(self.loading_tab_id) + || tab_id.strip_prefix(REMOTE_EMPTY_TAB_ID_PREFIX) == Some(self.id) + || tab_id.strip_prefix(REMOTE_ERROR_TAB_ID_PREFIX) == Some(self.id) + } + + fn contains_tab_id(self, tab_id: &str) -> bool { + self.is_fallback_tab_id(tab_id) + || tab_id + .strip_prefix(MARKETPLACE_TAB_ID_PREFIX) + .is_some_and(|marketplace_name| self.contains_marketplace(marketplace_name)) + } +} struct DelayedLoadingHeader { started_at: Instant, @@ -237,18 +418,18 @@ impl ChatWidget { description: Some("Keep this marketplace installed.".to_string()), selected_description: Some("Keep this marketplace installed.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_cancel.clone(), - result: Ok(plugins_response_for_cancel.clone()), + response: plugins_response_for_cancel.clone(), }); })], ..Default::default() }, ], on_cancel: Some(Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd_for_on_cancel.clone(), - result: Ok(plugins_response_for_on_cancel.clone()), + response: plugins_response_for_on_cancel.clone(), }); })), ..Default::default() @@ -421,9 +602,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -478,9 +659,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -518,9 +699,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -542,19 +723,15 @@ impl ChatWidget { active_tab_id: Option, initial_selected_idx: Option, ) -> SelectionViewParams { - let marketplaces: Vec<&PluginMarketplaceEntry> = response.marketplaces.iter().collect(); + let marketplaces = &response.marketplaces; + let preferred_local_sources = preferred_local_plugin_sources(marketplaces); - let total: usize = marketplaces + let all_entries = plugin_entries_for_marketplaces(marketplaces); + let total = all_entries.len(); + let installed = all_entries .iter() - .map(|marketplace| marketplace.plugins.len()) - .sum(); - let installed = marketplaces - .iter() - .flat_map(|marketplace| marketplace.plugins.iter()) - .filter(|plugin| plugin.installed) + .filter(|(_, plugin, _)| plugin.installed) .count(); - - let all_entries = plugin_entries_for_marketplaces(marketplaces.iter().copied()); let name_column_width = all_entries .iter() .map(|(_, _, display_name)| { @@ -570,6 +747,14 @@ impl ChatWidget { let mut tabs = Vec::new(); let mut tab_footer_hints = Vec::new(); + let all_items = self.plugin_selection_items( + all_entries, + &preferred_local_sources, + /*include_marketplace_names*/ true, + "No marketplace plugins available", + "No plugins are available in the discovered marketplaces.", + ); + tabs.push(SelectionTab { id: ALL_PLUGINS_TAB_ID.to_string(), label: "All Plugins".to_string(), @@ -577,12 +762,7 @@ impl ChatWidget { "Browse plugins from available marketplaces.".to_string(), format!("Installed {installed} of {total} available plugins."), ), - items: self.plugin_selection_items( - all_entries, - /*include_marketplace_names*/ true, - "No marketplace plugins available", - "No plugins are available in the discovered marketplaces.", - ), + items: all_items, }); tabs.push(SelectionTab { @@ -594,24 +774,61 @@ impl ChatWidget { ), items: self.plugin_selection_items( installed_entries, + &preferred_local_sources, /*include_marketplace_names*/ true, "No installed plugins", "No installed plugins.", ), }); - let curated_marketplace = marketplaces - .iter() - .find(|marketplace| is_openai_curated_marketplace_name(&marketplace.name)) - .copied(); - let curated_entries = curated_marketplace - .map(|marketplace| plugin_entries_for_marketplaces([marketplace])) - .unwrap_or_default(); + let curated_entries = + plugin_entries_for_marketplaces(marketplaces.iter().filter(|marketplace| { + MarketplaceProduct::from_marketplace(marketplace).is_by_openai() + })); let curated_total = curated_entries.len(); let curated_installed = curated_entries .iter() .filter(|(_, plugin, _)| plugin.installed) .count(); + let curated_has_entries = !curated_entries.is_empty(); + let curated_loading = self.plugin_remote_sections_loading + && self.plugins_fetch_state.vertical_section_requested; + let by_openai_section_error = + plugin_remote_section_error(&self.plugin_remote_section_errors, "vertical"); + let (curated_empty_name, curated_empty_description) = + if curated_loading && !curated_has_entries { + ( + "Loading OpenAI Curated plugins...", + "This section updates when app-server returns it.", + ) + } else if let Some(section_error) = by_openai_section_error + && !curated_has_entries + { + ("OpenAI Curated unavailable", section_error.message.as_str()) + } else { + ( + "No OpenAI Curated plugins available", + "No OpenAI Curated plugins available.", + ) + }; + let mut curated_items = self.plugin_selection_items( + curated_entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + curated_empty_name, + curated_empty_description, + ); + if curated_loading && curated_has_entries { + curated_items.push(remote_section_loading_item("OpenAI Curated")); + } + if let Some(section_error) = by_openai_section_error + && curated_has_entries + { + curated_items.push(remote_section_error_item( + §ion_error.label, + §ion_error.message, + )); + } tabs.push(SelectionTab { id: OPENAI_CURATED_TAB_ID.to_string(), label: "OpenAI Curated".to_string(), @@ -619,27 +836,35 @@ impl ChatWidget { "OpenAI Curated marketplace.".to_string(), format!("Installed {curated_installed} of {curated_total} OpenAI Curated plugins."), ), - items: self.plugin_selection_items( - curated_entries, - /*include_marketplace_names*/ false, - "No OpenAI Curated plugins available", - "No OpenAI Curated plugins available.", - ), + items: curated_items, }); let mut additional_marketplaces: Vec<&PluginMarketplaceEntry> = marketplaces .iter() - .copied() - .filter(|marketplace| !is_openai_curated_marketplace_name(&marketplace.name)) + .filter(|marketplace| !MarketplaceProduct::from_marketplace(marketplace).is_by_openai()) .collect(); - additional_marketplaces.sort_by(|left, right| { - marketplace_display_name(left) - .to_ascii_lowercase() - .cmp(&marketplace_display_name(right).to_ascii_lowercase()) - .then_with(|| marketplace_display_name(left).cmp(&marketplace_display_name(right))) - .then_with(|| left.name.cmp(&right.name)) + additional_marketplaces.sort_by_cached_key(|marketplace| { + let display_name = marketplace_display_name(marketplace); + ( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + display_name.to_ascii_lowercase(), + display_name, + marketplace.name.clone(), + ) }); + let mut additional_tabs = Vec::new(); + for section in REMOTE_MARKETPLACE_SECTIONS { + if let Some(fallback_tab) = section.fallback_tab( + marketplaces, + self.plugin_remote_sections_loading, + self.plugin_remote_sections_loaded, + &self.plugin_remote_section_errors, + ) { + additional_tabs.push(fallback_tab); + } + } + let labels = disambiguate_duplicate_tab_labels( additional_marketplaces .iter() @@ -681,20 +906,28 @@ impl ChatWidget { ), ) }; - tabs.push(SelectionTab { - id: tab_id, - label: label.clone(), - header, - items: self.plugin_selection_items( - entries, - /*include_marketplace_names*/ false, - "No plugins available in this marketplace", - "No plugins available in this marketplace.", - ), - }); + additional_tabs.push(( + MarketplaceProduct::from_marketplace(marketplace).tab_order(), + SelectionTab { + id: tab_id, + label: label.clone(), + header, + items: self.plugin_selection_items( + entries, + &preferred_local_sources, + /*include_marketplace_names*/ false, + "No plugins available in this marketplace", + "No plugins available in this marketplace.", + ), + }, + )); } + additional_tabs.sort_by_key(|(tab_order, _)| *tab_order); + tabs.extend(additional_tabs.into_iter().map(|(_, tab)| tab)); tabs.push(self.marketplace_add_tab()); + let initial_tab_id = + active_tab_id.and_then(|tab_id| plugin_tab_id_matching_saved_id(&tab_id, &tabs)); SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), @@ -704,7 +937,7 @@ impl ChatWidget { )), tab_footer_hints, tabs, - initial_tab_id: active_tab_id, + initial_tab_id, is_searchable: true, search_placeholder: Some("Type to search plugins".to_string()), col_width_mode: ColumnWidthMode::AutoAllRows, @@ -744,24 +977,15 @@ impl ChatWidget { plugins_response: &PluginListResponse, plugin: &PluginDetail, ) -> SelectionViewParams { - let marketplace_label = plugin.marketplace_name.clone(); + let marketplace_label = MarketplaceProduct::from_marketplace_parts( + &plugin.marketplace_name, + plugin.marketplace_path.as_ref(), + ) + .label() + .map(str::to_string) + .unwrap_or_else(|| plugin.marketplace_name.clone()); let display_name = plugin_display_name(&plugin.summary); - let detail_status_label = - if plugin.summary.availability == PluginAvailability::DisabledByAdmin { - "Disabled by admin" - } else if plugin.summary.installed { - if plugin.summary.enabled { - "Installed" - } else { - "Disabled" - } - } else { - match plugin.summary.install_policy { - PluginInstallPolicy::NotAvailable => "Not installable", - PluginInstallPolicy::Available => "Can be installed", - PluginInstallPolicy::InstalledByDefault => "Available by default", - } - }; + let detail_status_label = plugin_detail_status_label(&plugin.summary); let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -791,9 +1015,9 @@ impl ChatWidget { description: Some("Return to the plugin list.".to_string()), selected_description: Some("Return to the plugin list.".to_string()), actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PluginsLoaded { + tx.send(AppEvent::OpenPluginsList { cwd: cwd.clone(), - result: Ok(plugins_response.clone()), + response: plugins_response.clone(), }); })], ..Default::default() @@ -875,6 +1099,8 @@ impl ChatWidget { }); } + items.extend(plugin_metadata_items(plugin)); + items.push(SelectionItem { name: "Skills".to_string(), description: Some(plugin_skill_summary(plugin)), @@ -913,6 +1139,7 @@ impl ChatWidget { fn plugin_selection_items<'a>( &self, mut plugin_entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, + preferred_local_sources: &HashMap, include_marketplace_names: bool, empty_name: &str, empty_description: &str, @@ -933,7 +1160,8 @@ impl ChatWidget { } else { plugin_brief_description_without_marketplace(plugin, status_label_width) }; - let plugin_detail_request = plugin_detail_request_for_entry(marketplace, plugin); + let plugin_detail_request = + plugin_detail_request_for_entry(marketplace, plugin, preferred_local_sources); let can_view_details = plugin_detail_request.is_some(); let disabled_by_admin = plugin.availability == PluginAvailability::DisabledByAdmin; let can_toggle_plugin = plugin.installed && !disabled_by_admin; @@ -1049,7 +1277,7 @@ pub(super) fn plugin_detail_hint_line() -> Line<'static> { Line::from("Press esc to close.") } -fn plugins_header(subtitle: String, count_line: String) -> Box { +pub(super) fn plugins_header(subtitle: String, count_line: String) -> Box { let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from(subtitle.dim())); @@ -1057,10 +1285,225 @@ fn plugins_header(subtitle: String, count_line: String) -> Box { Box::new(header) } +fn dedupe_plugin_entries<'a>( + entries: Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)>, +) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { + // App-server should eventually normalize local/remote duplicates. Keep this + // display-only pass narrow so shared plugins do not appear twice meanwhile. + let mut deduped: Vec<(&PluginMarketplaceEntry, &PluginSummary, String)> = Vec::new(); + let mut remote_entry_indexes = HashMap::new(); + for entry in entries { + let Some(remote_plugin_id) = plugin_remote_identity(entry.1) else { + deduped.push(entry); + continue; + }; + if let Some(existing_index) = remote_entry_indexes.get(&remote_plugin_id).copied() { + if plugin_entry_preferred(&entry, &deduped[existing_index]) { + deduped[existing_index] = entry; + } + } else { + remote_entry_indexes.insert(remote_plugin_id, deduped.len()); + deduped.push(entry); + } + } + deduped +} + +fn plugin_entry_preferred( + candidate: &(&PluginMarketplaceEntry, &PluginSummary, String), + existing: &(&PluginMarketplaceEntry, &PluginSummary, String), +) -> bool { + if candidate.1.installed != existing.1.installed { + return candidate.1.installed; + } + + let candidate_is_local_share = + candidate.1.share_context.is_some() && !matches!(&candidate.1.source, PluginSource::Remote); + let existing_is_local_share = + existing.1.share_context.is_some() && !matches!(&existing.1.source, PluginSource::Remote); + if candidate_is_local_share != existing_is_local_share { + return candidate_is_local_share; + } + + !matches!(&candidate.1.source, PluginSource::Remote) + && matches!(&existing.1.source, PluginSource::Remote) +} + +fn preferred_local_plugin_sources( + marketplaces: &[PluginMarketplaceEntry], +) -> HashMap { + let mut sources = HashMap::new(); + for marketplace in marketplaces { + let Some(marketplace_path) = marketplace.path.as_ref() else { + continue; + }; + for plugin in &marketplace.plugins { + if matches!(&plugin.source, PluginSource::Remote) { + continue; + } + let Some(share_context) = plugin.share_context.as_ref() else { + continue; + }; + sources + .entry(share_context.remote_plugin_id.clone()) + .or_insert_with(|| PreferredLocalPluginSource { + marketplace_path: marketplace_path.clone(), + plugin_name: plugin.name.clone(), + installed: plugin.installed, + }); + } + } + sources +} + +fn plugin_detail_status_label(plugin: &PluginSummary) -> &'static str { + if plugin.availability == PluginAvailability::DisabledByAdmin { + return "Disabled by admin"; + } + if plugin.installed { + if plugin.enabled { + "Installed" + } else { + "Disabled" + } + } else { + match plugin.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + } +} + +fn plugin_metadata_items(plugin: &PluginDetail) -> Vec { + let mut items = Vec::new(); + items.push(SelectionItem { + name: "Source".to_string(), + description: Some(plugin_source_summary(plugin)), + is_disabled: true, + ..Default::default() + }); + items.push(SelectionItem { + name: "Auth".to_string(), + description: Some(plugin_auth_policy_summary(plugin.summary.auth_policy)), + is_disabled: true, + ..Default::default() + }); + if let Some(version) = plugin_version_summary(&plugin.summary) { + items.push(SelectionItem { + name: "Version".to_string(), + description: Some(version), + is_disabled: true, + ..Default::default() + }); + } + if let Some(share_context) = &plugin.summary.share_context { + items.push(SelectionItem { + name: "Sharing".to_string(), + description: Some(plugin_share_context_summary(share_context)), + is_disabled: true, + ..Default::default() + }); + } + items +} + +fn plugin_source_summary(plugin: &PluginDetail) -> String { + match &plugin.summary.source { + PluginSource::Local { .. } => "Local".to_string(), + PluginSource::Git { url, ref_name, .. } => match ref_name { + Some(ref_name) => format!("Git · {url}@{ref_name}"), + None => format!("Git · {url}"), + }, + PluginSource::Remote => { + let marketplace_label = + MarketplaceProduct::from_marketplace_name(&plugin.marketplace_name) + .label() + .unwrap_or(plugin.marketplace_name.as_str()); + format!("Remote · {marketplace_label}") + } + } +} + +fn plugin_auth_policy_summary(auth_policy: PluginAuthPolicy) -> String { + match auth_policy { + PluginAuthPolicy::OnInstall => "Auth on install".to_string(), + PluginAuthPolicy::OnUse => "Auth on use".to_string(), + } +} + +fn plugin_version_summary(plugin: &PluginSummary) -> Option { + let mut parts = Vec::new(); + if let Some(local_version) = plugin.local_version.as_deref() { + parts.push(format!("local {local_version}")); + } + if let Some(remote_version) = plugin + .share_context + .as_ref() + .and_then(|context| context.remote_version.as_deref()) + { + parts.push(format!("remote {remote_version}")); + } + (!parts.is_empty()).then(|| parts.join(" · ")) +} + +fn plugin_share_context_summary(context: &PluginShareContext) -> String { + let mut parts = Vec::new(); + if let Some(discoverability) = context.discoverability { + parts.push(plugin_share_discoverability_label(discoverability).to_string()); + } + if let Some(creator_summary) = plugin_share_creator_summary(context) { + parts.push(creator_summary); + } + if let Some(principals) = context.share_principals.as_ref() { + parts.push(plugin_share_principals_summary(principals)); + } + if let Some(share_url) = context + .share_url + .as_deref() + .filter(|url| !url.trim().is_empty()) + { + parts.push(share_url.to_string()); + } + if parts.is_empty() { + format!("Remote ID {}", context.remote_plugin_id) + } else { + parts.join(" · ") + } +} + +fn plugin_share_discoverability_label(discoverability: PluginShareDiscoverability) -> &'static str { + match discoverability { + PluginShareDiscoverability::Listed => "Listed", + PluginShareDiscoverability::Unlisted => "Workspace link", + PluginShareDiscoverability::Private => "Private", + } +} + +fn plugin_share_creator_summary(context: &PluginShareContext) -> Option { + match ( + context.creator_name.as_deref(), + context.creator_account_user_id.as_deref(), + ) { + (Some(name), Some(account_id)) => Some(format!("creator {name} ({account_id})")), + (Some(name), None) => Some(format!("creator {name}")), + (None, Some(account_id)) => Some(format!("creator account {account_id}")), + (None, None) => None, + } +} + +fn plugin_share_principals_summary(principals: &[PluginSharePrincipal]) -> String { + match principals.len() { + 0 => "No explicit principals".to_string(), + 1 => format!("1 principal: {}", principals[0].name), + count => format!("{count} principals"), + } +} + fn plugin_entries_for_marketplaces<'a>( marketplaces: impl IntoIterator, ) -> Vec<(&'a PluginMarketplaceEntry, &'a PluginSummary, String)> { - marketplaces + let entries = marketplaces .into_iter() .flat_map(|marketplace| { marketplace @@ -1068,7 +1511,8 @@ fn plugin_entries_for_marketplaces<'a>( .iter() .map(move |plugin| (marketplace, plugin, plugin_display_name(plugin))) }) - .collect() + .collect::>(); + dedupe_plugin_entries(entries) } fn sort_plugin_entries(entries: &mut [(&PluginMarketplaceEntry, &PluginSummary, String)]) { @@ -1103,6 +1547,10 @@ pub(super) fn marketplace_tab_id_matching_saved_id( saved_tab_id: &str, marketplaces: &[PluginMarketplaceEntry], ) -> Option { + if let Some(tab_id) = remote_section_marketplace_tab_id(saved_tab_id, marketplaces) { + return Some(tab_id); + } + if let Some(tab_id) = marketplaces.iter().find_map(|marketplace| { let tab_id = marketplace_tab_id(marketplace); (tab_id == saved_tab_id).then_some(tab_id) @@ -1124,6 +1572,43 @@ pub(super) fn marketplace_tab_id_matching_saved_id( }) } +fn remote_section_marketplace_tab_id( + saved_tab_id: &str, + marketplaces: &[PluginMarketplaceEntry], +) -> Option { + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.is_fallback_tab_id(saved_tab_id))?; + + section + .marketplace_names + .iter() + .find_map(|marketplace_name| { + marketplaces + .iter() + .find(|marketplace| marketplace.name.as_str() == *marketplace_name) + .map(marketplace_tab_id) + }) +} + +fn plugin_tab_id_matching_saved_id(saved_tab_id: &str, tabs: &[SelectionTab]) -> Option { + if let Some(tab_id) = tabs + .iter() + .find(|tab| tab.id.as_str() == saved_tab_id) + .map(|tab| tab.id.clone()) + { + return Some(tab_id); + } + + let section = REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .find(|section| section.contains_tab_id(saved_tab_id))?; + + tabs.iter() + .find(|tab| section.contains_tab_id(&tab.id)) + .map(|tab| tab.id.clone()) +} + pub(super) fn merge_remote_marketplaces( response: &mut PluginListResponse, remote_marketplaces: Vec, @@ -1132,62 +1617,134 @@ pub(super) fn merge_remote_marketplaces( .iter() .map(|marketplace| marketplace.name.clone()) .collect::>(); + let remote_curated_present = remote_names.contains(REMOTE_GLOBAL_MARKETPLACE_NAME); response.marketplaces.retain(|marketplace| { + if remote_curated_present + && marketplace.path.is_some() + && is_openai_curated_marketplace_name(&marketplace.name) + { + return false; + } + marketplace.path.is_some() - || !remote_marketplace_is_remote_section(marketplace) + || !REMOTE_MARKETPLACE_SECTIONS + .into_iter() + .any(|section| section.contains_marketplace(&marketplace.name)) && !remote_names.contains(marketplace.name.as_str()) }); response.marketplaces.extend(remote_marketplaces); } -fn remote_marketplace_is_remote_section(marketplace: &PluginMarketplaceEntry) -> bool { - matches!( - marketplace.name.as_str(), - REMOTE_WORKSPACE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME - | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME - ) +fn is_personal_marketplace_path(marketplace_path: &AbsolutePathBuf) -> bool { + dirs::home_dir() + .and_then(|home| { + AbsolutePathBuf::try_from(home.join(PERSONAL_MARKETPLACE_RELATIVE_PATH)).ok() + }) + .is_some_and(|personal_path| personal_path.as_path() == marketplace_path.as_path()) +} + +fn remote_section_loading_item(label: &str) -> SelectionItem { + SelectionItem { + name: format!("Loading {label} plugins..."), + description: Some("This section updates when app-server returns it.".to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn remote_section_error_item(label: &str, message: &str) -> SelectionItem { + SelectionItem { + name: format!("{label} unavailable"), + description: Some(message.to_string()), + is_disabled: true, + ..Default::default() + } +} + +fn plugin_remote_section_error<'a>( + section_errors: &'a [PluginRemoteSectionError], + section_id: &str, +) -> Option<&'a PluginRemoteSectionError> { + section_errors + .iter() + .find(|section_error| section_error.section_id == section_id) +} + +fn remote_section_loading_tab(id: &str, label: &str) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_LOADING_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("Loading {label} plugins."), + "Local plugin functionality is already available.".to_string(), + ), + items: vec![remote_section_loading_item(label)], + } +} + +fn remote_section_empty_tab( + id: &str, + label: &str, + item_name: &str, + item_description: &str, +) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_EMPTY_TAB_ID_PREFIX}{id}"), + label: label.to_string(), + header: plugins_header( + format!("{label}."), + "This section loaded successfully.".to_string(), + ), + items: vec![SelectionItem { + name: item_name.to_string(), + description: Some(item_description.to_string()), + is_disabled: true, + ..Default::default() + }], + } +} + +fn remote_section_error_tab(section_error: &PluginRemoteSectionError) -> SelectionTab { + SelectionTab { + id: format!("{REMOTE_ERROR_TAB_ID_PREFIX}{}", section_error.section_id), + label: section_error.label.clone(), + header: plugins_header( + format!("{} unavailable.", section_error.label), + "Local plugin functionality is still available.".to_string(), + ), + items: vec![remote_section_error_item( + §ion_error.label, + §ion_error.message, + )], + } } fn disambiguate_duplicate_tab_labels(labels: Vec) -> Vec { - let mut counts: Vec<(String, usize)> = Vec::new(); + let mut counts = HashMap::new(); for label in &labels { - if let Some((_, count)) = counts.iter_mut().find(|(existing, _)| existing == label) { - *count += 1; - } else { - counts.push((label.clone(), 1)); - } + *counts.entry(label.clone()).or_insert(0) += 1; } - let mut seen: Vec<(String, usize)> = Vec::new(); + let mut seen = HashMap::new(); labels .into_iter() .map(|label| { - let total = counts - .iter() - .find(|(existing, _)| existing == &label) - .map(|(_, count)| *count) - .unwrap_or(1); + let total = counts[&label]; if total == 1 { return label; } - let current = if let Some((_, seen_count)) = - seen.iter_mut().find(|(existing, _)| existing == &label) - { - *seen_count += 1; - *seen_count - } else { - seen.push((label.clone(), 1)); - 1 - }; + let current = seen.entry(label.clone()).or_insert(0); + *current += 1; format!("{label} ({current}/{total})") }) .collect() } pub(super) fn marketplace_display_name(marketplace: &PluginMarketplaceEntry) -> String { + if let Some(label) = MarketplaceProduct::from_marketplace(marketplace).label() { + return label.to_string(); + } marketplace .interface .as_ref() @@ -1271,7 +1828,7 @@ fn plugin_status_label(plugin: &PluginSummary) -> &'static str { match plugin.install_policy { PluginInstallPolicy::NotAvailable => "Not installable", PluginInstallPolicy::Available => "Available", - PluginInstallPolicy::InstalledByDefault => "Available", + PluginInstallPolicy::InstalledByDefault => "Available by default", } } } @@ -1300,7 +1857,21 @@ fn plugin_detail_location(plugin: &PluginDetail) -> Option { fn plugin_detail_request_for_entry( marketplace: &PluginMarketplaceEntry, plugin: &PluginSummary, + preferred_local_sources: &HashMap, ) -> Option<(PluginLocation, String)> { + if matches!(&plugin.source, PluginSource::Remote) + && let Some(remote_plugin_id) = plugin_remote_identity(plugin) + && let Some(preferred_source) = preferred_local_sources.get(remote_plugin_id) + && preferred_source.installed == plugin.installed + { + return Some(( + PluginLocation::Local { + marketplace_path: preferred_source.marketplace_path.clone(), + }, + preferred_source.plugin_name.clone(), + )); + } + plugin_location_for_marketplace(marketplace, plugin) .map(|location| (location, plugin_request_name(plugin))) } @@ -1309,22 +1880,22 @@ fn plugin_request_name(plugin: &PluginSummary) -> String { if matches!(&plugin.source, PluginSource::Remote) && let Some(remote_plugin_id) = plugin_remote_identity(plugin) { - return remote_plugin_id; + return remote_plugin_id.to_string(); } plugin.name.clone() } -fn plugin_remote_identity(plugin: &PluginSummary) -> Option { +fn plugin_remote_identity(plugin: &PluginSummary) -> Option<&str> { plugin .share_context .as_ref() - .map(|context| context.remote_plugin_id.clone()) - .or_else(|| plugin.remote_plugin_id.clone()) + .map(|context| context.remote_plugin_id.as_str()) + .or(plugin.remote_plugin_id.as_deref()) } fn plugin_uninstall_id(plugin: &PluginSummary) -> Option { if matches!(&plugin.source, PluginSource::Remote) { - return plugin_remote_identity(plugin); + return plugin_remote_identity(plugin).map(str::to_string); } Some(plugin.id.clone()) } diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 8c259856fb..0ff8333ffc 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -42,6 +42,7 @@ pub(super) const ADD_MARKETPLACE_TAB_ID: &str = "add-marketplace"; pub(super) struct PluginListFetchState { pub(super) cache_cwd: Option, pub(super) in_flight_cwd: Option, + pub(super) vertical_section_requested: bool, } #[derive(Debug, Clone)] @@ -147,6 +148,7 @@ impl ChatWidget { Err(err) => { self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = false; + self.plugins_fetch_state.vertical_section_requested = false; if should_refresh_plugins_popup { self.plugins_fetch_state.cache_cwd = None; self.plugins_cache = PluginsCacheState::Failed(err.clone()); @@ -175,6 +177,7 @@ impl ChatWidget { .is_some(); self.plugin_remote_sections_loading = false; self.plugin_remote_sections_loaded = true; + self.plugins_fetch_state.vertical_section_requested = false; let refreshed_response = match &mut self.plugins_cache { PluginsCacheState::Ready(response) if self.plugins_fetch_state.cache_cwd.as_deref() == Some(cwd.as_path()) => @@ -212,6 +215,8 @@ impl ChatWidget { } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); + self.plugins_fetch_state.vertical_section_requested = + !self.config.features.enabled(Feature::RemotePlugin); if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -245,6 +250,36 @@ impl ChatWidget { )); } + pub(crate) fn open_plugins_list(&mut self, cwd: PathBuf, response: PluginListResponse) { + if self.config.cwd.as_path() != cwd.as_path() { + return; + } + + let response = match self.plugins_cache_for_current_cwd() { + PluginsCacheState::Ready(current_response) => current_response, + PluginsCacheState::Uninitialized + | PluginsCacheState::Loading + | PluginsCacheState::Failed(_) => response, + }; + self.plugins_fetch_state.cache_cwd = Some(cwd); + self.plugins_cache = PluginsCacheState::Ready(response.clone()); + let active_tab_id = self + .bottom_pane + .active_tab_id_for_active_view(PLUGINS_SELECTION_VIEW_ID) + .map(str::to_string) + .or_else(|| self.plugins_active_tab_id.clone()) + .or_else(|| Some(ALL_PLUGINS_TAB_ID.to_string())); + self.plugins_active_tab_id = active_tab_id.clone(); + let params = + self.plugins_popup_params(&response, active_tab_id, /*initial_selected_idx*/ None); + if !self + .bottom_pane + .replace_selection_view_if_active(PLUGINS_SELECTION_VIEW_ID, params) + { + self.open_plugins_popup(&response); + } + } + pub(crate) fn open_marketplace_add_prompt(&mut self) { self.plugins_active_tab_id = Some(ADD_MARKETPLACE_TAB_ID.to_string()); let tx = self.app_event_tx.clone(); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index c7edae1714..e5d14a4c51 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,13 +3,15 @@ source: tui/src/chatwidget/tests/popups_and_settings.rs expression: strip_osc8_for_snapshot(&popup) --- Plugins - Figma · Can be installed · ChatGPT Marketplace + Figma · Can be installed · Local Data shared with this app is subject to the app's terms of service and privacy policy. Learn more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. 2. Install plugin Install this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 4d91b17f34..2b69864cc5 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -8,6 +8,8 @@ expression: strip_osc8_for_snapshot(&popup) › 1. Back to plugins Return to the plugin list. 2. Uninstall plugin Remove this plugin now. + Source Local + Auth Auth on install Skills design-review, extract-copy Hooks PreToolUse (1), Stop (2) Apps Figma, Slack diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 40d26d3c9d..9f828ab3bc 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -9,9 +9,9 @@ expression: popup [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace Type to search plugins -› [ ] Alpha Sync Disabled Space to enable; Enter view details. - [-] Bravo Search Available · ChatGPT Marketplace · Search docs and tickets. - [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. - [-] Starter Available · ChatGPT Marketplace · Included by default. +› [ ] Alpha Sync Disabled Space to enable; Enter view details. + [-] Bravo Search Available · OpenAI Curated · Search docs and tickets. + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugi… + [-] Starter Available by default · OpenAI Curated · Included by default. space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap index 515b700925..803ff9fbe6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_newly_installed_marketplace.snap @@ -1,13 +1,13 @@ --- source: tui/src/chatwidget/tests/popups_and_settings.rs -assertion_line: 339 expression: popup --- Plugins Debug Marketplace installed successfully. Select the plugins you want to use and press Enter to install or view details. - All Plugins Installed (0) OpenAI Curated [Debug Marketplace] Add Marketplace + All Plugins Installed (0) OpenAI Curated Workspace Shared with me [Debug Marketplace] + Add Marketplace Type to search plugins › [-] Debug Plugin Available Press Enter to install or view plugin details. diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 4c225b3318..281d97305e 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -7,6 +7,7 @@ use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginShareContext; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -250,7 +251,7 @@ async fn plugins_popup_truncates_long_descriptions_in_list_rows() { .expect("expected verbose plugin row in popup"); insta::assert_snapshot!( verbose_row, - @" [-] Verbose Plugin Available · ChatGPT Marketplace · This descri…" + @" [-] Verbose Plugin Available · OpenAI Curated · This description…" ); assert!( !popup @@ -459,11 +460,20 @@ async fn marketplace_add_success_refreshes_to_new_marketplace_tab() { chat.handle_key_event(KeyEvent::from(KeyCode::Esc)); chat.add_plugins_output(); - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + let reopened_popup = (0..8) + .find_map(|_| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + if popup.contains("[Debug Marketplace]") { + Some(popup) + } else { + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + None + } + }) + .unwrap_or_else(|| { + let popup = render_bottom_popup(&chat, /*width*/ 100); + panic!("expected Debug Marketplace tab after reopening, got:\n{popup}"); + }); assert!( reopened_popup.contains("Installed 0 of 1 Debug Marketplace plugins.") && !reopened_popup.contains("installed successfully"), @@ -584,10 +594,17 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() { } #[tokio::test] -async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summaries() { +async fn plugin_detail_popup_snapshot_labels_personal_marketplace_as_local() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + let marketplace_name = "personal-marketplace"; + let personal_marketplace_path = AbsolutePathBuf::try_from( + dirs::home_dir() + .expect("home directory") + .join(".agents/plugins/marketplace.json"), + ) + .expect("absolute personal marketplace path"); let summary = plugins_test_summary( "plugin-figma", "figma", @@ -597,28 +614,29 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa /*enabled*/ true, PluginInstallPolicy::Available, ); - let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ - summary.clone(), - ])]); + let response = plugins_test_response(vec![PluginMarketplaceEntry { + name: marketplace_name.to_string(), + path: Some(personal_marketplace_path.clone()), + interface: None, + plugins: vec![summary.clone()], + }]); let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); - chat.on_plugin_detail_loaded( - cwd.to_path_buf(), - Ok(PluginReadResponse { - plugin: plugins_test_detail( - summary, - Some("Turn Figma files into implementation context."), - &["design-review", "extract-copy"], - &[ - (codex_app_server_protocol::HookEventName::PreToolUse, 1), - (codex_app_server_protocol::HookEventName::Stop, 2), - ], - &["Figma", "Slack"], - &["figma-mcp", "docs-mcp"], - ), - }), + let mut plugin = plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[ + (codex_app_server_protocol::HookEventName::PreToolUse, 1), + (codex_app_server_protocol::HookEventName::Stop, 2), + ], + &["Figma", "Slack"], + &["figma-mcp", "docs-mcp"], ); + plugin.marketplace_name = marketplace_name.to_string(); + plugin.marketplace_path = Some(personal_marketplace_path); + chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin })); let popup = render_bottom_popup(&chat, /*width*/ 100); assert_chatwidget_snapshot!( @@ -1022,6 +1040,222 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { assert_eq!(after, before); } +#[tokio::test] +async fn plugins_popup_admin_disabled_available_plugin_has_view_only_hint() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + availability: PluginAvailability::DisabledByAdmin, + ..plugins_test_summary( + "plugin-admin-blocked", + "admin-blocked", + Some("Admin Blocked"), + Some("Blocked by policy."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + let admin_blocked_row = popup + .lines() + .find(|line| line.contains("Admin Blocked")) + .expect("expected admin-disabled plugin row"); + assert!( + admin_blocked_row.contains("Press Enter to view plugin details.") + && !admin_blocked_row.contains("install or view"), + "expected admin-disabled available plugin to stay view-only, got:\n{admin_blocked_row}" + ); +} + +#[tokio::test] +async fn plugins_popup_remote_section_fallback_states_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let select_tab_containing = |chat: &mut ChatWidget, visible_text: &str| -> String { + for _ in 0..8 { + let popup = render_bottom_popup(chat, /*width*/ 100); + if popup.contains(visible_text) { + return popup; + } + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(chat, /*width*/ 100); + panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}"); + }; + let remote_section_state = |popup: &str| -> String { + let header = popup + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .nth(1) + .expect("expected remote section header"); + let item = popup + .lines() + .find_map(|line| line.trim_start().strip_prefix('›')) + .expect("expected selected remote section item") + .trim(); + format!("{header}\n{item}") + }; + + chat.add_plugins_output(); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let curated_loading_popup = + select_tab_containing(&mut chat, "Loading OpenAI Curated plugins..."); + let workspace_loading_popup = select_tab_containing(&mut chat, "Loading Workspace plugins."); + + chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), Vec::new(), Vec::new()); + let shared_empty_popup = select_tab_containing(&mut chat, "Shared with me."); + + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + Vec::new(), + vec![crate::app_event::PluginRemoteSectionError { + section_id: "workspace".to_string(), + label: "Workspace".to_string(), + message: "Sign in to ChatGPT to load workspace plugins.".to_string(), + }], + ); + let workspace_error_popup = select_tab_containing(&mut chat, "Workspace unavailable."); + + let (mut remote_chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + remote_chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + remote_chat.set_feature_enabled(Feature::RemotePlugin, /*enabled*/ true); + remote_chat.add_plugins_output(); + let remote_cwd = remote_chat.config.cwd.clone(); + remote_chat.on_plugins_loaded( + remote_cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let remote_curated_empty_popup = + select_tab_containing(&mut remote_chat, "No OpenAI Curated plugins available"); + + insta::assert_snapshot!( + [ + remote_section_state(&curated_loading_popup), + remote_section_state(&workspace_loading_popup), + remote_section_state(&shared_empty_popup), + remote_section_state(&workspace_error_popup), + remote_section_state(&remote_curated_empty_popup), + ] + .join("\n\n"), + @r###" + OpenAI Curated marketplace. + Loading OpenAI Curated plugins... This section updates when app-server returns it. + + Loading Workspace plugins. + Loading Workspace plugins... This section updates when app-server returns it. + + Shared with me. + No shared plugins available No plugins have been shared with you. + + Workspace unavailable. + Workspace unavailable Sign in to ChatGPT to load workspace plugins. + + OpenAI Curated marketplace. + No OpenAI Curated plugins available No OpenAI Curated plugins available. + "### + ); +} + +#[tokio::test] +async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share_is_uninstalled() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let remote_plugin_id = "plugins~Plugin_docs"; + let remote_marketplace_name = "workspace-shared-with-me-private"; + let local_summary = PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: remote_plugin_id.to_string(), + remote_version: None, + discoverability: None, + share_url: None, + creator_account_user_id: None, + creator_name: None, + share_principals: None, + }), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Local editable docs plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let popup = render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![local_summary]), + PluginMarketplaceEntry { + name: remote_marketplace_name.to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some("Shared with me".to_string()), + }), + plugins: vec![plugins_test_remote_summary( + remote_plugin_id, + "docs", + Some("Docs"), + Some("Shared docs plugin."), + /*installed*/ true, + )], + }, + ]), + ); + let all_plugins_row = popup + .lines() + .find(|line| line.contains("Docs")) + .expect("expected all-plugins row"); + assert!( + popup.contains("Installed 1 of 1 available plugins.") + && all_plugins_row.contains("Installed") + && !all_plugins_row.contains("Available"), + "expected installed remote duplicate to win over local mapped share, got:\n{popup}" + ); + + while rx.try_recv().is_ok() {} + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + match rx.try_recv() { + Ok(AppEvent::OpenPluginDetailLoading { + plugin_display_name, + }) => { + assert_eq!(plugin_display_name, "Docs"); + } + other => panic!("expected OpenPluginDetailLoading event, got {other:?}"), + } + match rx.try_recv() { + Ok(AppEvent::FetchPluginDetail { params, .. }) => { + assert_eq!(params.marketplace_path, None); + assert_eq!( + params.remote_marketplace_name, + Some(remote_marketplace_name.to_string()) + ); + assert_eq!(params.plugin_name, remote_plugin_id); + } + other => panic!("expected FetchPluginDetail event, got {other:?}"), + } +} + #[tokio::test] async fn plugin_detail_error_popup_skips_disabled_row_numbering() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 9f06cf1a0907f650887ca26af5cee13b4f9a13ea Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 18:33:28 +0100 Subject: [PATCH 34/86] Report remote sandbox denials semantically (#29424) ## Why #29113 moved remote sandbox setup and enforcement to the exec server. That gives the executor ownership of the platform-specific work: a Linux executor chooses and runs a Linux sandbox even when the Codex orchestrator is running on macOS or Windows. It also means the orchestrator no longer knows which concrete sandbox the executor selected. When that sandbox blocks a remote command, the orchestrator currently sees only a failed process and can treat the denial as an ordinary command failure. The existing sandbox approval and retry path is then skipped. This PR lets the executor report one portable fact: > This command probably failed because the executor sandbox blocked it. The executor keeps its concrete sandbox type private. The protocol sends only the semantic result. ## Example Suppose a local macOS Codex session asks a Linux devbox to write outside the allowed workspace. Before this PR: ```text Linux sandbox blocks the write -> remote process exits with "Permission denied" -> local orchestrator sees an ordinary command failure -> the normal sandbox approval and retry path can be skipped ``` With this PR: ```text Linux sandbox blocks the write -> executor reports sandboxDenied: true -> unified exec returns UnifiedExecError::SandboxDenied -> the existing approval prompt is shown -> an approved retry runs through the existing unsandboxed retry path ``` ## What changes ### The executor remembers its selected sandbox The prepared remote process now retains the executor-selected `SandboxType`. This value never crosses the executor boundary. Commands started without a sandbox retain `SandboxType::None` and are never reported as sandbox denials. ### The executor uses the existing denial heuristic The existing local denial heuristic moves from `codex-core` into the shared `codex-sandboxing` crate. When a sandboxed remote process exits, the executor: 1. waits the same short output grace period used by local unified exec; 2. reads the output currently available in the existing retained output buffer; 3. runs the existing heuristic using the exit code and common denial messages; 4. stores the yes/no result before publishing the process exit. This deliberately matches the old local unified-exec behavior. It does not add a new streaming classifier, another output buffer, or stronger output-retention guarantees. ### The protocol reports a portable boolean `process/read` gains `sandboxDenied`: ```json { "exited": true, "exitCode": 1, "closed": false, "sandboxDenied": true } ``` The field defaults to `false` when an older executor omits it. The response does not expose the executor sandbox implementation or executor-native paths. ### Unified exec uses the existing error path The exec-server client carries `sandboxDenied` into the unified process state. If it is true, unified exec returns the existing `SandboxDenied` error instead of trying to classify remote output using an orchestrator-side sandbox type. Remote process exit remains visible as soon as the process exits. This PR does not wait for stdout or stderr to close and does not change the existing process lifecycle. ## Scope This PR is intentionally limited to matching the existing local unified-exec behavior for the initial command execution path. It does not add: - incremental denial tracking across the full output stream; - new denial handling for commands completed later through `write_stdin`; - new guarantees for preserving the semantic flag during the narrow reconnect-recovery race. Those can be considered separately if the same behavior is added for local execution. ## Test coverage One remote end-to-end integration test covers the complete intended flow: ```text remote read-only sandbox -> denied write -> executor reports the denial -> Codex requests approval -> user approves -> retry succeeds on the remote executor ``` Existing lifecycle coverage continues to verify that remote process exit is reported before late output streams close. --- codex-rs/core/src/exec.rs | 63 +------- codex-rs/core/src/unified_exec/mod_tests.rs | 20 ++- codex-rs/core/src/unified_exec/process.rs | 19 ++- .../core/src/unified_exec/process_manager.rs | 15 +- .../src/unified_exec/process_manager_tests.rs | 19 ++- .../core/src/unified_exec/process_state.rs | 3 + .../core/src/unified_exec/process_tests.rs | 7 +- codex-rs/core/tests/suite/remote_env.rs | 152 +++++++++++++++++- codex-rs/exec-server/src/client.rs | 2 + codex-rs/exec-server/src/client_recovery.rs | 1 + codex-rs/exec-server/src/local_process.rs | 46 ++++++ codex-rs/exec-server/src/process_sandbox.rs | 3 + codex-rs/exec-server/src/protocol.rs | 3 + codex-rs/exec-server/tests/exec_process.rs | 18 +-- codex-rs/exec-server/tests/process.rs | 4 +- codex-rs/exec-server/tests/relay.rs | 2 +- codex-rs/sandboxing/src/denial.rs | 57 +++++++ codex-rs/sandboxing/src/lib.rs | 2 + 18 files changed, 330 insertions(+), 106 deletions(-) create mode 100644 codex-rs/sandboxing/src/denial.rs diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 99c87cdd99..647203db2e 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -42,6 +42,7 @@ use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; use codex_sandboxing::WindowsSandboxFilesystemOverrides; +pub(crate) use codex_sandboxing::is_likely_sandbox_denied; #[cfg(test)] use codex_sandboxing::permission_profile_supports_windows_restricted_token_sandbox; use codex_sandboxing::resolve_windows_elevated_filesystem_overrides; @@ -819,68 +820,6 @@ fn finalize_exec_result( } } -/// We don't have a fully deterministic way to tell if our command failed -/// because of the sandbox - a command in the user's zshrc file might hit an -/// error, but the command itself might fail or succeed for other reasons. -/// For now, we conservatively check for well known command failure exit codes and -/// also look for common sandbox denial keywords in the command output. -pub(crate) fn is_likely_sandbox_denied( - sandbox_type: SandboxType, - exec_output: &ExecToolCallOutput, -) -> bool { - if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { - return false; - } - - // Quick rejects: well-known non-sandbox shell exit codes - // 2: misuse of shell builtins - // 126: permission denied - // 127: command not found - const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ - "operation not permitted", - "permission denied", - "read-only file system", - "seccomp", - "sandbox", - "landlock", - "failed to write file", - ]; - - let has_sandbox_keyword = [ - &exec_output.stderr.text, - &exec_output.stdout.text, - &exec_output.aggregated_output.text, - ] - .into_iter() - .any(|section| { - let lower = section.to_lowercase(); - SANDBOX_DENIED_KEYWORDS - .iter() - .any(|needle| lower.contains(needle)) - }); - - if has_sandbox_keyword { - return true; - } - - const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; - if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { - return false; - } - - #[cfg(unix)] - { - const SIGSYS_CODE: i32 = libc::SIGSYS; - if sandbox_type == SandboxType::LinuxSeccomp - && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + SIGSYS_CODE - { - return true; - } - } - - false -} - #[derive(Debug)] struct RawExecToolCallOutput { pub exit_status: ExitStatus, diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index d5b813cb42..2dc0afd265 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -228,6 +228,7 @@ impl BlockingTerminateExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) } @@ -286,17 +287,14 @@ async fn blocking_terminate_unified_process( ) -> anyhow::Result> { let (wake_tx, _wake_rx) = watch::channel(0); Ok(Arc::new( - UnifiedExecProcess::from_exec_server_started( - StartedExecProcess { - process: Arc::new(BlockingTerminateExecProcess { - process_id: process_id.to_string().into(), - terminate_started, - allow_terminate, - wake_tx, - }), - }, - SandboxType::None, - ) + UnifiedExecProcess::from_exec_server_started(StartedExecProcess { + process: Arc::new(BlockingTerminateExecProcess { + process_id: process_id.to_string().into(), + terminate_started, + allow_terminate, + wake_tx, + }), + }) .await?, )) } diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 725be9eed2..5f785adfa7 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -285,8 +285,9 @@ impl UnifiedExecProcess { &self, text: &str, ) -> Result<(), UnifiedExecError> { + let executor_reported_denial = self.state_rx.borrow().sandbox_denied; let sandbox_type = self.sandbox_type(); - if sandbox_type == SandboxType::None || !self.has_exited() { + if !self.has_exited() || (!executor_reported_denial && sandbox_type == SandboxType::None) { return Ok(()); } @@ -297,7 +298,7 @@ impl UnifiedExecProcess { aggregated_output: StreamOutput::new(text.to_string()), ..Default::default() }; - if is_likely_sandbox_denied(sandbox_type, &exec_output) { + if executor_reported_denial || is_likely_sandbox_denied(sandbox_type, &exec_output) { let snippet = formatted_truncate_text( text, TruncationPolicy::Tokens(UNIFIED_EXEC_OUTPUT_MAX_TOKENS), @@ -374,10 +375,13 @@ impl UnifiedExecProcess { pub(super) async fn from_exec_server_started( started: StartedExecProcess, - sandbox_type: SandboxType, ) -> Result { let process_handle = ProcessHandle::ExecServer(Arc::clone(&started.process)); - let mut managed = Self::new(process_handle, sandbox_type, /*spawn_lifecycle*/ None); + let mut managed = Self::new( + process_handle, + SandboxType::None, + /*spawn_lifecycle*/ None, + ); let output_handles = managed.output_handles(); managed.output_task = Some(Self::spawn_exec_server_output_task( started, @@ -437,6 +441,7 @@ impl UnifiedExecProcess { exit_code, closed, failure, + sandbox_denied, } = response; for chunk in chunks { @@ -457,6 +462,12 @@ impl UnifiedExecProcess { break; } + if sandbox_denied { + let mut state = state_tx.borrow().clone(); + state.sandbox_denied = true; + let _ = state_tx.send_replace(state); + } + if exited { let state = state_tx.borrow().clone(); let _ = state_tx.send_replace(state.exited(exit_code)); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a3b4bcf9c7..2cb62a6d4b 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -10,6 +10,7 @@ use tokio::sync::watch; use tokio::time::Duration; use tokio::time::Instant; use tokio_util::sync::CancellationToken; +use uuid::Uuid; use crate::codex_thread::BackgroundTerminalInfo; use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; @@ -157,8 +158,14 @@ fn exec_server_params_for_request( tty: bool, ) -> codex_exec_server::ExecParams { let (env_policy, env) = exec_server_env_for_request(request); + // Sandbox retries reuse the unified-exec ID but start a distinct executor process. + let exec_server_process_id = if request.exec_server_sandbox.is_some() { + format!("{process_id}-{}", Uuid::new_v4()) + } else { + process_id.to_string() + }; codex_exec_server::ExecParams { - process_id: exec_server_process_id(process_id).into(), + process_id: exec_server_process_id.into(), argv: request.command.clone(), cwd: request.cwd.clone(), env_policy, @@ -198,10 +205,6 @@ impl Drop for InitialExecCommandGuard { } } -fn exec_server_process_id(process_id: i32) -> String { - process_id.to_string() -} - async fn unregister_network_approval_for_entry(entry: &ProcessEntry) { if let Some(network_approval) = entry.network_approval.as_ref() && let Some(session) = entry.session.upgrade() @@ -1041,7 +1044,7 @@ impl UnifiedExecProcessManager { .await .map_err(|err| UnifiedExecError::create_process(err.to_string()))?; spawn_lifecycle.after_spawn(); - return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await; + return UnifiedExecProcess::from_exec_server_started(started).await; } // TODO(anp): Keep PathUri through the local PTY/process launch boundary. diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index b6afdc85ba..cd48d01c7c 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -76,7 +76,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { codex_protocol::permissions::FileSystemSandboxPolicy::unrestricted(); let network_sandbox_policy = codex_protocol::permissions::NetworkSandboxPolicy::Restricted; let permission_profile = codex_protocol::models::PermissionProfile::Disabled; - let request = ExecRequest { + let mut request = ExecRequest { command: vec!["bash".to_string(), "-lc".to_string(), "true".to_string()], cwd: cwd.clone().into(), env: HashMap::from([ @@ -106,7 +106,7 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { windows_sandbox_workspace_roots: vec![cwd], windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, - permission_profile, + permission_profile: permission_profile.clone(), file_system_sandbox_policy, network_sandbox_policy, windows_sandbox_filesystem_overrides: None, @@ -128,11 +128,16 @@ fn exec_server_params_use_path_uri_and_env_policy_overlay_contract() { ("CODEX_THREAD_ID".to_string(), "thread-1".to_string()), ]) ); -} - -#[test] -fn exec_server_process_id_matches_unified_exec_process_id() { - assert_eq!(exec_server_process_id(/*process_id*/ 4321), "4321"); + request.exec_server_sandbox = Some( + codex_exec_server::FileSystemSandboxContext::from_permission_profile(permission_profile), + ); + let first = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + let second = + exec_server_params_for_request(/*process_id*/ 123, &request, /*tty*/ true); + assert!(first.process_id.as_str().starts_with("123-")); + assert!(second.process_id.as_str().starts_with("123-")); + assert_ne!(first.process_id, second.process_id); } #[cfg(windows)] diff --git a/codex-rs/core/src/unified_exec/process_state.rs b/codex-rs/core/src/unified_exec/process_state.rs index 267406da29..65e11b6f3e 100644 --- a/codex-rs/core/src/unified_exec/process_state.rs +++ b/codex-rs/core/src/unified_exec/process_state.rs @@ -3,6 +3,7 @@ pub(crate) struct ProcessState { pub(crate) has_exited: bool, pub(crate) exit_code: Option, pub(crate) failure_message: Option, + pub(crate) sandbox_denied: bool, } impl ProcessState { @@ -11,6 +12,7 @@ impl ProcessState { has_exited: true, exit_code, failure_message: self.failure_message.clone(), + sandbox_denied: self.sandbox_denied, } } @@ -19,6 +21,7 @@ impl ProcessState { has_exited: true, exit_code: self.exit_code, failure_message: Some(message), + sandbox_denied: self.sandbox_denied, } } } diff --git a/codex-rs/core/src/unified_exec/process_tests.rs b/codex-rs/core/src/unified_exec/process_tests.rs index 37ec5d858b..db64d461ec 100644 --- a/codex-rs/core/src/unified_exec/process_tests.rs +++ b/codex-rs/core/src/unified_exec/process_tests.rs @@ -10,7 +10,6 @@ use codex_exec_server::ReadResponse; use codex_exec_server::StartedExecProcess; use codex_exec_server::WriteResponse; use codex_exec_server::WriteStatus; -use codex_sandboxing::SandboxType; use pretty_assertions::assert_eq; use std::collections::VecDeque; use std::sync::Arc; @@ -40,6 +39,7 @@ impl MockExecProcess { exit_code: None, closed: false, failure: None, + sandbox_denied: false, })) } @@ -103,7 +103,7 @@ async fn remote_process( }), }; - UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should start") } @@ -190,6 +190,7 @@ async fn remote_process_waits_for_early_exit_event() { exit_code: Some(17), closed: true, failure: None, + sandbox_denied: false, }])), terminate_error: None, wake_tx: wake_tx.clone(), @@ -201,7 +202,7 @@ async fn remote_process_waits_for_early_exit_event() { let _ = wake_tx.send(1); }); - let process = UnifiedExecProcess::from_exec_server_started(started, SandboxType::None) + let process = UnifiedExecProcess::from_exec_server_started(started) .await .expect("remote process should observe early exit"); diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index 337ca8d405..fa805fa446 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -87,6 +87,7 @@ async fn submit_turn_with_approval_and_environments( test: &TestCodex, prompt: &str, environments: Vec, + approval_policy: AskForApproval, ) -> Result<()> { let turn_environment_selections = codex_protocol::protocol::TurnEnvironmentSelections::new( test.config.cwd.clone(), @@ -103,7 +104,7 @@ async fn submit_turn_with_approval_and_environments( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(turn_environment_selections), - approval_policy: Some(AskForApproval::OnRequest), + approval_policy: Some(approval_policy), approvals_reviewer: Some(ApprovalsReviewer::User), sandbox_policy: Some(SandboxPolicy::new_read_only_policy()), collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { @@ -312,6 +313,151 @@ async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor"); + let Some(_remote_env) = get_remote_test_env() else { + return Ok(()); + }; + + const CALL_ID: &str = "remote-sandbox-denial"; + const CONTENTS: &str = "remote sandbox retry succeeded"; + + let server = start_mock_server().await; + let test = unified_exec_test(&server).await?; + let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); + let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs(); + let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs(); + let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let target_uri = PathUri::from_path(&target_path)?; + test.fs() + .create_directory( + &remote_cwd_uri, + CreateDirectoryOptions { recursive: true }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + let command = format!("printf {CONTENTS:?} > {target_path:?} && cat {target_path:?}"); + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-remote-denial-1"), + ev_function_call( + CALL_ID, + "exec_command", + &json!({ + "shell": "/bin/sh", + "cmd": command, + "login": false, + "yield_time_ms": 5_000, + "environment_id": REMOTE_ENVIRONMENT_ID, + }) + .to_string(), + ), + ev_completed("resp-remote-denial-1"), + ]), + sse(vec![ + ev_response_created("resp-remote-denial-2"), + ev_assistant_message("msg-remote-denial", "done"), + ev_completed("resp-remote-denial-2"), + ]), + ], + ) + .await; + + submit_turn_with_approval_and_environments( + &test, + "retry a sandbox-denied command in the remote environment", + vec![TurnEnvironmentSelection { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: PathUri::from_abs_path(&remote_cwd), + }], + AskForApproval::OnFailure, + ) + .await?; + + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::ExecApprovalRequest(approval) = event else { + panic!("expected remote sandbox approval before completion: {event:?}"); + }; + assert_eq!(approval.call_id, CALL_ID); + assert_eq!( + approval.environment_id.as_deref(), + Some(REMOTE_ENVIRONMENT_ID) + ); + assert_eq!( + approval.reason.as_deref(), + Some("command failed; retry without sandbox?") + ); + + test.codex + .submit(Op::ExecApproval { + id: approval.effective_approval_id(), + turn_id: None, + decision: ReviewDecision::Approved, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert!( + response_mock + .function_call_output_text(CALL_ID) + .is_some_and(|output| output.contains(CONTENTS)), + "approved retry should return the remote command output" + ); + assert_eq!( + test.fs() + .read_file_text(&target_uri, /*sandbox*/ None) + .await?, + CONTENTS + ); + + test.fs() + .remove( + &target_uri, + RemoveOptions { + recursive: false, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + test.fs() + .remove( + &remote_cwd_uri, + RemoveOptions { + recursive: true, + force: true, + }, + /*sandbox*/ None, + ) + .await?; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> { let server = start_mock_server().await; @@ -834,6 +980,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result cwd: PathUri::from_abs_path(&remote_cwd), }, ], + AskForApproval::OnRequest, ) .await?; @@ -1115,6 +1262,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in local environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-local").await; @@ -1134,6 +1282,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch in remote environment", environments.clone(), + AskForApproval::OnRequest, ) .await?; let approval = expect_patch_approval(&test, "call-remote").await; @@ -1158,6 +1307,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { &test, "apply patch again in remote environment", environments, + AskForApproval::OnRequest, ) .await?; wait_for_completion_without_patch_approval(&test).await; diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 88c0c80342..25d1b54e8b 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -926,6 +926,7 @@ impl SessionState { exit_code: None, closed: true, failure: Some(message), + sandbox_denied: false, } } @@ -1869,6 +1870,7 @@ mod tests { exit_code: None, closed: false, failure: None, + sandbox_denied: false, }) .expect("read response should serialize"), }), diff --git a/codex-rs/exec-server/src/client_recovery.rs b/codex-rs/exec-server/src/client_recovery.rs index 77feb0a4ba..f58e365728 100644 --- a/codex-rs/exec-server/src/client_recovery.rs +++ b/codex-rs/exec-server/src/client_recovery.rs @@ -58,6 +58,7 @@ impl SessionState { exit_code, closed, failure, + sandbox_denied: _, } = response; if let Some(message) = failure { return Err(ExecServerError::Protocol(format!( diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index 802c8e69cf..c7e99de53c 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -10,7 +10,11 @@ use std::time::Duration; use codex_app_server_protocol::JSONRPCErrorError; use codex_protocol::config_types::EnvironmentVariablePattern; use codex_protocol::config_types::ShellEnvironmentPolicy; +use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::exec_output::StreamOutput; use codex_protocol::shell_environment; +use codex_sandboxing::SandboxType; +use codex_sandboxing::is_likely_sandbox_denied; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::ProcessSignal as PtyProcessSignal; use codex_utils_pty::TerminalSize; @@ -87,6 +91,8 @@ struct RunningProcess { output_notify: Arc, open_streams: usize, closed: bool, + sandbox: SandboxType, + sandbox_denied: bool, } /// Bounded cache of stdin write ids that have already been accepted for one process. @@ -313,6 +319,8 @@ impl LocalProcess { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: prepared.sandbox, + sandbox_denied: false, })), ); } @@ -407,6 +415,7 @@ impl LocalProcess { exit_code: process.exit_code, closed: process.closed, failure: None, + sandbox_denied: process.sandbox_denied, }, Arc::clone(&process.output_notify), ) @@ -797,12 +806,46 @@ async fn watch_exit( output_notify: Arc, ) { let exit_code = exit_rx.await.unwrap_or(-1); + let sandboxed = { + let processes = inner.processes.lock().await; + matches!( + processes.get(&process_id), + Some(ProcessEntry::Running(process)) if process.sandbox != SandboxType::None + ) + }; + if sandboxed { + let _ = tokio::time::timeout(Duration::from_millis(20), output_notify.notified()).await; + } let notification = { let mut processes = inner.processes.lock().await; if let Some(ProcessEntry::Running(process)) = processes.get_mut(&process_id) { let seq = process.next_seq; process.next_seq += 1; process.exit_code = Some(exit_code); + if process.sandbox != SandboxType::None { + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let mut aggregated = Vec::new(); + for chunk in &process.output { + match chunk.stream { + ExecOutputStream::Stdout | ExecOutputStream::Pty => { + stdout.extend_from_slice(&chunk.chunk); + } + ExecOutputStream::Stderr => stderr.extend_from_slice(&chunk.chunk), + } + aggregated.extend_from_slice(&chunk.chunk); + } + let exec_output = ExecToolCallOutput { + exit_code, + stdout: StreamOutput::new(String::from_utf8_lossy(&stdout).into_owned()), + stderr: StreamOutput::new(String::from_utf8_lossy(&stderr).into_owned()), + aggregated_output: StreamOutput::new( + String::from_utf8_lossy(&aggregated).into_owned(), + ), + ..Default::default() + }; + process.sandbox_denied = is_likely_sandbox_denied(process.sandbox, &exec_output); + } let _ = process.wake_tx.send(seq); process .events @@ -1000,6 +1043,7 @@ mod tests { exit_code: Some(0), closed: false, failure: None, + sandbox_denied: false, } ); @@ -1124,6 +1168,8 @@ mod tests { output_notify: Arc::clone(&output_notify), open_streams: 2, closed: false, + sandbox: SandboxType::None, + sandbox_denied: false, })), ); assert!(previous.is_none()); diff --git a/codex-rs/exec-server/src/process_sandbox.rs b/codex-rs/exec-server/src/process_sandbox.rs index be95b92d34..d157f4c6dc 100644 --- a/codex-rs/exec-server/src/process_sandbox.rs +++ b/codex-rs/exec-server/src/process_sandbox.rs @@ -20,6 +20,7 @@ pub(crate) struct PreparedExecRequest { pub(crate) cwd: AbsolutePathBuf, pub(crate) env: HashMap, pub(crate) arg0: Option, + pub(crate) sandbox: SandboxType, } pub(crate) fn prepare_exec_request( @@ -33,6 +34,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(¶ms.cwd, "cwd")?, env, arg0: params.arg0.clone(), + sandbox: SandboxType::None, }); }; let runtime_paths = runtime_paths @@ -116,6 +118,7 @@ pub(crate) fn prepare_exec_request( cwd: native_path(&request.cwd, "cwd")?, env: request.env, arg0: request.arg0, + sandbox: request.sandbox, }) } diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index e05595f273..99ee3431aa 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -153,6 +153,9 @@ pub struct ReadResponse { pub exit_code: Option, pub closed: bool, pub failure: Option, + /// Whether the executor classified the process failure as a sandbox denial. + #[serde(default)] + pub sandbox_denied: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index ac38f50eaf..f35923815d 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -230,7 +230,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "session output\n"); @@ -263,7 +263,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let actual = collect_process_event_snapshots(process).await?; assert_eq!( actual, @@ -312,7 +312,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let read_result = collect_process_output_from_reads(Arc::clone(&process), wake_rx).await?; assert_eq!( @@ -362,7 +362,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let exit_response = timeout( Duration::from_secs(2), @@ -439,7 +439,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { tokio::time::sleep(Duration::from_millis(200)).await; session.process.write(b"hello\n".to_vec()).await?; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -479,7 +479,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re tokio::time::sleep(Duration::from_millis(200)).await; let write_response = session.process.write(b"hello\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::Accepted); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let actual = collect_process_output_from_reads(process, wake_rx).await?; @@ -513,7 +513,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) let write_response = session.process.write(b"ignored\n".to_vec()).await?; assert_eq!(write_response.status, WriteStatus::StdinClosed); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; @@ -547,7 +547,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu .await?; assert_eq!(session.process.process_id().as_str(), process_id); - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let mut wake_rx = process.subscribe_wake(); let mut ready_output = String::new(); let mut after_seq = None; @@ -645,7 +645,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( tokio::time::sleep(Duration::from_millis(200)).await; - let StartedExecProcess { process } = session; + let StartedExecProcess { process, .. } = session; let wake_rx = process.subscribe_wake(); let (output, exit_code, closed) = collect_process_output_from_reads(process, wake_rx).await?; assert_eq!(output, "queued output\n"); diff --git a/codex-rs/exec-server/tests/process.rs b/codex-rs/exec-server/tests/process.rs index 571fd427fa..ba7f64a610 100644 --- a/codex-rs/exec-server/tests/process.rs +++ b/codex-rs/exec-server/tests/process.rs @@ -70,7 +70,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> { assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); @@ -135,7 +135,7 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re assert_eq!( process_start_response, ExecResponse { - process_id: ProcessId::from("proc-default-stdin") + process_id: ProcessId::from("proc-default-stdin"), } ); diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index 5f49655e39..c9f873c158 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -157,7 +157,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { assert_eq!( response, ExecResponse { - process_id: ProcessId::from("proc-1") + process_id: ProcessId::from("proc-1"), } ); diff --git a/codex-rs/sandboxing/src/denial.rs b/codex-rs/sandboxing/src/denial.rs new file mode 100644 index 0000000000..f355fa1c02 --- /dev/null +++ b/codex-rs/sandboxing/src/denial.rs @@ -0,0 +1,57 @@ +use codex_protocol::exec_output::ExecToolCallOutput; + +use crate::SandboxType; + +/// Returns whether a failed command was likely denied by the selected sandbox. +pub fn is_likely_sandbox_denied( + sandbox_type: SandboxType, + exec_output: &ExecToolCallOutput, +) -> bool { + if sandbox_type == SandboxType::None || exec_output.exit_code == 0 { + return false; + } + + const SANDBOX_DENIED_KEYWORDS: [&str; 7] = [ + "operation not permitted", + "permission denied", + "read-only file system", + "seccomp", + "sandbox", + "landlock", + "failed to write file", + ]; + + let has_sandbox_keyword = [ + &exec_output.stderr.text, + &exec_output.stdout.text, + &exec_output.aggregated_output.text, + ] + .into_iter() + .any(|section| { + let lower = section.to_lowercase(); + SANDBOX_DENIED_KEYWORDS + .iter() + .any(|needle| lower.contains(needle)) + }); + + if has_sandbox_keyword { + return true; + } + + const QUICK_REJECT_EXIT_CODES: [i32; 3] = [2, 126, 127]; + if QUICK_REJECT_EXIT_CODES.contains(&exec_output.exit_code) { + return false; + } + + #[cfg(unix)] + { + const EXIT_CODE_SIGNAL_BASE: i32 = 128; + if sandbox_type == SandboxType::LinuxSeccomp + && exec_output.exit_code == EXIT_CODE_SIGNAL_BASE + libc::SIGSYS + { + return true; + } + } + + false +} diff --git a/codex-rs/sandboxing/src/lib.rs b/codex-rs/sandboxing/src/lib.rs index 22c4984c5a..0688d0f920 100644 --- a/codex-rs/sandboxing/src/lib.rs +++ b/codex-rs/sandboxing/src/lib.rs @@ -1,5 +1,6 @@ #[cfg(target_os = "linux")] mod bwrap; +mod denial; pub mod landlock; mod manager; pub mod policy_transforms; @@ -11,6 +12,7 @@ mod windows; pub use bwrap::find_system_bwrap_in_path; #[cfg(target_os = "linux")] pub use bwrap::system_bwrap_warning; +pub use denial::is_likely_sandbox_denied; pub use manager::SandboxCommand; pub use manager::SandboxDirectSpawnTransformRequest; pub use manager::SandboxExecRequest; From 5b95745eae1b13be75d6c395a6baec8e7b7ad99b Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 22 Jun 2026 11:11:25 -0700 Subject: [PATCH 35/86] core: rename metadata -> internal_chat_message_metadata_passthrough (#28968) ## Description This PR cuts Codex over from generic `ResponseItem.metadata` (introduced here: https://github.com/openai/codex/pull/28355) to `ResponseItem.internal_chat_message_metadata_passthrough`, which is the blessed path and has strongly-typed keys. For now we have to drop this MAv2 usage of `metadata`: https://github.com/openai/codex/pull/28561 until we figure out where that should live. --- .../schema/json/ClientRequest.json | 89 +++--- .../codex_app_server_protocol.schemas.json | 89 +++--- .../codex_app_server_protocol.v2.schemas.json | 89 +++--- .../RawResponseItemCompletedNotification.json | 89 +++--- .../schema/json/v2/ThreadResumeParams.json | 89 +++--- .../InternalChatMessageMetadataPassthrough.ts | 11 + .../schema/typescript/ResponseItem.ts | 6 +- .../schema/typescript/ResponseItemMetadata.ts | 5 - .../schema/typescript/index.ts | 2 +- .../src/protocol/thread_history.rs | 2 +- .../app-server/tests/suite/v2/compaction.rs | 4 +- .../tests/suite/v2/thread_inject_items.rs | 4 +- .../tests/suite/v2/thread_resume.rs | 4 +- .../src/endpoint/responses_websocket.rs | 2 +- codex-rs/codex-api/src/endpoint/search.rs | 2 +- codex-rs/codex-api/tests/clients.rs | 4 +- codex-rs/context-fragments/src/fragment.rs | 4 +- codex-rs/core/src/agent/control_tests.rs | 22 +- codex-rs/core/src/client.rs | 6 +- codex-rs/core/src/client_common_tests.rs | 12 +- codex-rs/core/src/client_tests.rs | 2 +- codex-rs/core/src/codex_delegate_tests.rs | 2 +- codex-rs/core/src/codex_thread.rs | 2 +- codex-rs/core/src/compact.rs | 21 +- codex-rs/core/src/compact_remote.rs | 12 +- codex-rs/core/src/compact_remote_v2.rs | 32 +- codex-rs/core/src/compact_tests.rs | 91 +++--- codex-rs/core/src/context_manager/history.rs | 8 +- .../core/src/context_manager/history_tests.rs | 207 +++++++------ .../core/src/context_manager/normalize.rs | 8 +- codex-rs/core/src/context_manager/updates.rs | 2 +- codex-rs/core/src/event_mapping_tests.rs | 38 +-- codex-rs/core/src/guardian/tests.rs | 54 ++-- codex-rs/core/src/image_preparation_tests.rs | 8 +- codex-rs/core/src/realtime_context_tests.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 6 +- codex-rs/core/src/session/tests.rs | 53 ++-- .../core/src/session/tests/guardian_tests.rs | 4 +- codex-rs/core/src/session/turn_tests.rs | 2 +- codex-rs/core/src/stream_events_utils.rs | 8 +- .../core/src/stream_events_utils_tests.rs | 20 +- codex-rs/core/src/tasks/mod.rs | 2 +- codex-rs/core/src/tasks/review.rs | 4 +- codex-rs/core/src/thread_manager_tests.rs | 8 +- .../src/thread_rollout_truncation_tests.rs | 10 +- codex-rs/core/src/tools/code_mode/delegate.rs | 2 +- .../src/tools/handlers/extension_tools.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 17 +- .../handlers/multi_agents_v2/message_tool.rs | 7 +- .../tools/handlers/multi_agents_v2/spawn.rs | 7 +- codex-rs/core/src/tools/router_tests.rs | 6 +- codex-rs/core/src/turn_timing_tests.rs | 10 +- codex-rs/core/tests/common/responses.rs | 2 +- codex-rs/core/tests/responses_headers.rs | 6 +- codex-rs/core/tests/suite/client.rs | 45 +-- .../core/tests/suite/client_websockets.rs | 4 +- codex-rs/core/tests/suite/compact.rs | 12 +- codex-rs/core/tests/suite/compact_remote.rs | 18 +- codex-rs/core/tests/suite/image_rollout.rs | 4 +- .../core/tests/suite/prompt_debug_tests.rs | 2 +- .../core/tests/suite/realtime_conversation.rs | 4 +- codex-rs/core/tests/suite/review.rs | 4 +- .../tests/suite/subagent_notifications.rs | 3 - codex-rs/ext/image-generation/src/tests.rs | 16 +- codex-rs/ext/web-search/src/history.rs | 14 +- .../external-agent-sessions/src/export.rs | 2 +- codex-rs/memories/write/src/phase1.rs | 16 +- codex-rs/memories/write/src/startup_tests.rs | 2 +- codex-rs/protocol/src/items.rs | 2 +- codex-rs/protocol/src/models.rs | 290 ++++++++++++------ codex-rs/protocol/src/protocol.rs | 13 +- codex-rs/rollout-trace/src/inference.rs | 2 +- codex-rs/rollout/src/tests.rs | 2 +- codex-rs/state/src/extract.rs | 2 +- codex-rs/thread-store/src/local/mod.rs | 2 +- codex-rs/tools/src/response_history.rs | 2 +- codex-rs/tui/src/app/side.rs | 2 +- 77 files changed, 866 insertions(+), 797 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7ea246f0ea..e1399a2dd8 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1315,6 +1315,18 @@ ], "type": "object" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LegacyAppPathString": { "type": "string" }, @@ -2302,10 +2314,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2358,10 +2370,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2412,10 +2424,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2462,10 +2474,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2505,10 +2517,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2559,10 +2571,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2602,10 +2614,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2645,10 +2657,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2692,10 +2704,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2744,10 +2756,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2796,10 +2808,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2834,10 +2846,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2883,10 +2895,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2910,10 +2922,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2948,10 +2960,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -2990,23 +3002,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 9c2abf525a..3a2af11db3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -11145,6 +11145,18 @@ } ] }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14948,10 +14960,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15004,10 +15016,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15058,10 +15070,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15108,10 +15120,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15151,10 +15163,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15205,10 +15217,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15248,10 +15260,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15291,10 +15303,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15338,10 +15350,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15390,10 +15402,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15442,10 +15454,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15480,10 +15492,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15529,10 +15541,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15556,10 +15568,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15594,10 +15606,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/v2/ResponseItemMetadata" + "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -15636,23 +15648,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index c8b172c7dc..286fc0e2f6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -7549,6 +7549,18 @@ } ] }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "ItemCompletedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -11352,10 +11364,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11408,10 +11420,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11462,10 +11474,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11512,10 +11524,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11555,10 +11567,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11609,10 +11621,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11652,10 +11664,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11695,10 +11707,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11742,10 +11754,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11794,10 +11806,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11846,10 +11858,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11884,10 +11896,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11933,10 +11945,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11960,10 +11972,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -11998,10 +12010,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -12040,23 +12052,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index 22385f816f..d06d821a7d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -216,6 +216,18 @@ ], "type": "string" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LocalShellAction": { "oneOf": [ { @@ -383,10 +395,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -439,10 +451,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -493,10 +505,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -543,10 +555,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -586,10 +598,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -640,10 +652,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -683,10 +695,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -726,10 +738,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -773,10 +785,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -825,10 +837,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -877,10 +889,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -915,10 +927,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -964,10 +976,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -991,10 +1003,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1029,10 +1041,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1071,23 +1083,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 528076c6ae..4ce88a1162 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -279,6 +279,18 @@ ], "type": "string" }, + "InternalChatMessageMetadataPassthrough": { + "description": "Internal Responses API passthrough metadata copied into underlying chat messages.\n\nResponses API strongly types this payload. Do not modify it without first getting API approval and making the corresponding Responses API change.", + "properties": { + "turn_id": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "LocalShellAction": { "oneOf": [ { @@ -454,10 +466,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -510,10 +522,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -564,10 +576,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -614,10 +626,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -657,10 +669,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -711,10 +723,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -754,10 +766,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -797,10 +809,10 @@ "input": { "type": "string" }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -844,10 +856,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -896,10 +908,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -948,10 +960,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -986,10 +998,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1035,10 +1047,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1062,10 +1074,10 @@ }, { "properties": { - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1100,10 +1112,10 @@ "null" ] }, - "metadata": { + "internal_chat_message_metadata_passthrough": { "anyOf": [ { - "$ref": "#/definitions/ResponseItemMetadata" + "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" }, { "type": "null" @@ -1142,23 +1154,6 @@ } ] }, - "ResponseItemMetadata": { - "properties": { - "source_call_id": { - "type": [ - "string", - "null" - ] - }, - "turn_id": { - "type": [ - "string", - "null" - ] - } - }, - "type": "object" - }, "ResponsesApiWebSearchAction": { "oneOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts b/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts new file mode 100644 index 0000000000..6ccf386884 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/InternalChatMessageMetadataPassthrough.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Internal Responses API passthrough metadata copied into underlying chat messages. + * + * Responses API strongly types this payload. Do not modify it without first getting API + * approval and making the corresponding Responses API change. + */ +export type InternalChatMessageMetadataPassthrough = { turn_id?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index fc399b925f..074e11a16e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -4,15 +4,15 @@ import type { AgentMessageInputContent } from "./AgentMessageInputContent"; import type { ContentItem } from "./ContentItem"; import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; +import type { InternalChatMessageMetadataPassthrough } from "./InternalChatMessageMetadataPassthrough"; import type { LocalShellAction } from "./LocalShellAction"; import type { LocalShellStatus } from "./LocalShellStatus"; import type { MessagePhase } from "./MessagePhase"; import type { ReasoningItemContent } from "./ReasoningItemContent"; import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; -import type { ResponseItemMetadata } from "./ResponseItemMetadata"; import type { WebSearchAction } from "./WebSearchAction"; -export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, metadata?: ResponseItemMetadata, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, metadata?: ResponseItemMetadata, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, metadata?: ResponseItemMetadata, } | { "type": "local_shell_call", +export type ResponseItem = { "type": "message", id?: string, role: string, content: Array, phase?: MessagePhase, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "agent_message", id?: string, author: string, recipient: string, content: Array, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "reasoning", id?: string, summary: Array, content?: Array, encrypted_content: string | null, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "local_shell_call", /** * Legacy id field retained for compatibility with older payloads. */ @@ -20,4 +20,4 @@ id?: string, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, metadata?: ResponseItemMetadata, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, metadata?: ResponseItemMetadata, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, metadata?: ResponseItemMetadata, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, metadata?: ResponseItemMetadata, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, metadata?: ResponseItemMetadata, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], metadata?: ResponseItemMetadata, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, metadata?: ResponseItemMetadata, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, metadata?: ResponseItemMetadata, } | { "type": "compaction", id?: string, encrypted_content: string, metadata?: ResponseItemMetadata, } | { "type": "compaction_trigger", metadata?: ResponseItemMetadata, } | { "type": "context_compaction", id?: string, encrypted_content?: string, metadata?: ResponseItemMetadata, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts deleted file mode 100644 index 365c561fd1..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItemMetadata.ts +++ /dev/null @@ -1,5 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type ResponseItemMetadata = { turn_id?: string, source_call_id?: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/index.ts b/codex-rs/app-server-protocol/schema/typescript/index.ts index 7c94d9c601..dcfecf1282 100644 --- a/codex-rs/app-server-protocol/schema/typescript/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/index.ts @@ -41,6 +41,7 @@ export type { InitializeCapabilities } from "./InitializeCapabilities"; export type { InitializeParams } from "./InitializeParams"; export type { InitializeResponse } from "./InitializeResponse"; export type { InputModality } from "./InputModality"; +export type { InternalChatMessageMetadataPassthrough } from "./InternalChatMessageMetadataPassthrough"; export type { InternalSessionSource } from "./InternalSessionSource"; export type { LegacyAppPathString } from "./LegacyAppPathString"; export type { LocalShellAction } from "./LocalShellAction"; @@ -68,7 +69,6 @@ export type { Resource } from "./Resource"; export type { ResourceContent } from "./ResourceContent"; export type { ResourceTemplate } from "./ResourceTemplate"; export type { ResponseItem } from "./ResponseItem"; -export type { ResponseItemMetadata } from "./ResponseItemMetadata"; export type { ReviewDecision } from "./ReviewDecision"; export type { ServerNotification } from "./ServerNotification"; export type { ServerRequest } from "./ServerRequest"; diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs index 0157101cf0..1e087c4c5f 100644 --- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs +++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs @@ -3775,7 +3775,7 @@ mod tests { text: "plain text".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id: "turn-a".into(), diff --git a/codex-rs/app-server/tests/suite/v2/compaction.rs b/codex-rs/app-server/tests/suite/v2/compaction.rs index 092c64505f..bca34d360d 100644 --- a/codex-rs/app-server/tests/suite/v2/compaction.rs +++ b/codex-rs/app-server/tests/suite/v2/compaction.rs @@ -134,12 +134,12 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<() text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 21dae50d6f..00b90e64d8 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -60,7 +60,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu text: injected_text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let inject_req = mcp @@ -198,7 +198,7 @@ async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<() text: "Injected after first turn".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let injected_value = serde_json::to_value(&injected_item)?; diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 0d1fcd1e5d..ea7a1a9d46 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -2589,7 +2589,7 @@ async fn thread_resume_rejects_history_when_thread_is_running() -> Result<()> { text: "history override".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]), ..Default::default() }) @@ -3598,7 +3598,7 @@ async fn thread_resume_supports_history_and_overrides() -> Result<()> { text: history_text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; // Resume with explicit history and override the model. diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index d1f06ee2f8..d44825154a 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -825,7 +825,7 @@ mod tests { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: vec![json!({ "type": "function", diff --git a/codex-rs/codex-api/src/endpoint/search.rs b/codex-rs/codex-api/src/endpoint/search.rs index 93f6249f10..d33e502ce3 100644 --- a/codex-rs/codex-api/src/endpoint/search.rs +++ b/codex-rs/codex-api/src/endpoint/search.rs @@ -162,7 +162,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }])), commands: Some(SearchCommands { search_query: Some(vec![SearchQuery { diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 488f6dc674..2d69f83860 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -313,7 +313,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: Vec::new(), tool_choice: "auto".into(), @@ -518,7 +518,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], tools: Vec::new(), tool_choice: "auto".into(), diff --git a/codex-rs/context-fragments/src/fragment.rs b/codex-rs/context-fragments/src/fragment.rs index 38714e2512..5f44e33530 100644 --- a/codex-rs/context-fragments/src/fragment.rs +++ b/codex-rs/context-fragments/src/fragment.rs @@ -83,7 +83,7 @@ pub trait ContextualUserFragment { text: self.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -95,7 +95,7 @@ pub trait ContextualUserFragment { text: self.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 7eac69a88f..17532bd785 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -72,7 +72,7 @@ fn assistant_message(text: &str, phase: Option) -> ResponseItem { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -92,7 +92,7 @@ fn spawn_agent_call(call_id: &str) -> ResponseItem { namespace: None, arguments: "{}".to_string(), call_id: call_id.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -909,7 +909,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -918,7 +918,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Parent subagent guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_message("parent commentary", Some(MessagePhase::Commentary)), assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), @@ -928,7 +928,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { summary: Vec::new(), content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, trigger_message.to_response_input_item().into(), spawn_agent_call(&parent_spawn_call_id), @@ -996,7 +996,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "parent seed context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), ResponseItem::Message { @@ -1006,7 +1006,7 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { text: "Child subagent guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!( @@ -1125,7 +1125,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { text: "compacted parent summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1134,7 +1134,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; parent_thread @@ -1449,7 +1449,7 @@ async fn spawn_agent_fork_last_n_turns_drops_parent_startup_prefix_when_under_li text: "parent startup developer context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -1571,7 +1571,7 @@ async fn spawn_agent_fork_last_n_turns_strips_parent_usage_hints() { text: "Parent root guidance.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, spawn_agent_call(&parent_spawn_call_id), ], diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 73d7398a3d..c6cb92907f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -781,7 +781,9 @@ impl ModelClient { let instructions = &prompt.base_instructions.text; let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); if !self.state.provider.info().is_openai() { - input.iter_mut().for_each(ResponseItem::clear_metadata); + input + .iter_mut() + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let tools = create_tools_json_for_responses_api(&prompt.tools)?; let reasoning = Self::build_reasoning(model_info, effort, summary); @@ -1072,7 +1074,7 @@ impl ModelClientSession { if !self.client.state.provider.info().is_openai() { response_items .iter_mut() - .for_each(ResponseItem::clear_metadata); + .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let Some(incremental_items) = after_previous_input.strip_prefix(response_items.as_slice()) else { diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 8107049ab3..8b25c7e300 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -20,7 +20,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Original), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -31,7 +31,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::High), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -43,7 +43,7 @@ fn prompt_with_image_outputs() -> Prompt { detail: Some(ImageDetail::Auto), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ..Default::default() @@ -68,7 +68,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -79,7 +79,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -91,7 +91,7 @@ fn responses_lite_request_copies_strip_image_details() { detail: None, }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 1d0c4c48c2..0584e290fc 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -226,7 +226,7 @@ fn output_message(id: &str, text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 21a86f224e..6d51a0032b 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -81,7 +81,7 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { call_id: "call-1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, }), }) diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 85ca1e1a61..a522cd62aa 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -452,7 +452,7 @@ impl CodexThread { role: "user".to_string(), content: vec![ContentItem::InputText { text: message }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; self.codex .session diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 8a5efd5947..94c26b8ad7 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -30,9 +30,9 @@ use codex_protocol::error::Result as CodexResult; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::ContentItem; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::CompactedItem; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::TurnStartedEvent; @@ -459,7 +459,7 @@ pub fn content_items_to_text(content: &[ContentItem]) -> Option { #[derive(Clone, Debug, PartialEq)] pub(crate) struct CompactedUserMessage { message: String, - metadata: Option, + internal_chat_message_metadata_passthrough: Option, } pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec { @@ -472,8 +472,11 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec metadata.clone(), + internal_chat_message_metadata_passthrough: match item { + ResponseItem::Message { + internal_chat_message_metadata_passthrough, + .. + } => internal_chat_message_metadata_passthrough.clone(), _ => None, }, }) @@ -580,7 +583,9 @@ fn build_compacted_history_with_limit( truncate_text(&message.message, TruncationPolicy::Tokens(remaining)); selected_messages.push(CompactedUserMessage { message: truncated, - metadata: message.metadata.clone(), + internal_chat_message_metadata_passthrough: message + .internal_chat_message_metadata_passthrough + .clone(), }); break; } @@ -596,7 +601,9 @@ fn build_compacted_history_with_limit( text: message.message.clone(), }], phase: None, - metadata: message.metadata.clone(), + internal_chat_message_metadata_passthrough: message + .internal_chat_message_metadata_passthrough + .clone(), }); } @@ -611,7 +618,7 @@ fn build_compacted_history_with_limit( role: "user".to_string(), content: vec![ContentItem::InputText { text: summary_text }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); history diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 7a47881e47..3d23140684 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -414,31 +414,31 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), output: truncated_output_payload(output), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::CustomToolCallOutput { id, call_id, name, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::CustomToolCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncated_output_payload(output), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::ToolSearchOutput { call_id, status, execution, - metadata, + internal_chat_message_metadata_passthrough: metadata, .. } => ResponseItem::ToolSearchOutput { id: item.id().map(str::to_string), @@ -446,7 +446,7 @@ fn rewritten_output_for_context_window(item: &ResponseItem) -> Option return None, }) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0b382700ac..0778c74729 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -234,7 +234,9 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { metadata: None }); + input.push(ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), @@ -526,7 +528,7 @@ fn truncate_message_text_to_token_budget( role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } = item else { return Some(item); @@ -565,7 +567,7 @@ fn truncate_message_text_to_token_budget( role, content: truncated_content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, }) } @@ -586,7 +588,7 @@ mod tests { text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -618,18 +620,18 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call_1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "old".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (history, _) = build_v2_compacted_history(&input, output.clone()); @@ -658,7 +660,7 @@ mod tests { let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (history, _) = build_v2_compacted_history(&input, output.clone()); @@ -685,12 +687,12 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let output = ResponseItem::Compaction { id: None, encrypted_content: "new".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let (_, retained_image_count) = build_v2_compacted_history(&input, output); @@ -738,7 +740,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let truncated = @@ -762,7 +764,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -777,7 +779,7 @@ mod tests { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let newest = message("user", "new", /*phase*/ None); let retained = vec![ @@ -802,7 +804,7 @@ mod tests { detail: None, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let newest = message("user", "new", /*phase*/ None); let retained = vec![image_only_message, newest.clone()]; @@ -818,7 +820,7 @@ mod tests { let compaction = ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let stream = response_stream(vec![ Ok(ResponseEvent::OutputItemDone(message( diff --git a/codex-rs/core/src/compact_tests.rs b/codex-rs/core/src/compact_tests.rs index a09a6dbb3e..bb6adf643d 100644 --- a/codex-rs/core/src/compact_tests.rs +++ b/codex-rs/core/src/compact_tests.rs @@ -2,7 +2,7 @@ use super::*; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; use codex_protocol::models::DEFAULT_IMAGE_DETAIL; -use codex_protocol::models::ResponseItemMetadata; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use pretty_assertions::assert_eq; async fn process_compacted_history_with_test_session( @@ -32,14 +32,14 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn compacted_user_message(text: &str) -> CompactedUserMessage { CompactedUserMessage { message: text.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -84,7 +84,7 @@ fn collect_user_messages_extracts_user_text_only() { text: "ignored".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: Some("user".to_string()), @@ -93,7 +93,7 @@ fn collect_user_messages_extracts_user_text_only() { text: "first".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Other, ]; @@ -118,7 +118,7 @@ do things .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -127,7 +127,7 @@ do things text: "cwd=/tmp".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -136,7 +136,7 @@ do things text: "real user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -231,15 +231,16 @@ fn build_token_limited_compacted_history_appends_summary_message() { } #[test] -fn build_compacted_history_preserves_user_message_metadata() { +fn build_compacted_history_preserves_user_message_passthrough_metadata() { let history = build_compacted_history( Vec::new(), &[CompactedUserMessage { message: "first user message".to_string(), - metadata: Some(ResponseItemMetadata { - turn_id: Some("turn-1".to_string()), - ..Default::default() - }), + internal_chat_message_metadata_passthrough: Some( + InternalChatMessageMetadataPassthrough { + turn_id: Some("turn-1".to_string()), + }, + ), }], "summary text", ); @@ -282,7 +283,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "stale permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -291,7 +292,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -300,7 +301,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "stale personality".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let (refreshed, mut expected) = process_compacted_history_with_test_session( @@ -315,7 +316,7 @@ async fn process_compacted_history_replaces_developer_messages() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -329,7 +330,7 @@ async fn process_compacted_history_reinjects_full_initial_context() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let (refreshed, mut expected) = process_compacted_history_with_test_session( compacted_history, @@ -343,7 +344,7 @@ async fn process_compacted_history_reinjects_full_initial_context() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -363,7 +364,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -376,7 +377,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -389,7 +390,7 @@ keep me updated .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -398,7 +399,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -407,7 +408,7 @@ keep me updated text: "stale developer instructions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let (refreshed, mut expected) = process_compacted_history_with_test_session( @@ -422,7 +423,7 @@ keep me updated text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -462,7 +463,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -471,7 +472,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -480,7 +481,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -497,7 +498,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -506,7 +507,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; expected.extend(initial_context); @@ -517,7 +518,7 @@ async fn process_compacted_history_inserts_context_before_last_real_user_message text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -531,7 +532,7 @@ async fn process_compacted_history_reinjects_model_switch_message() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let previous_turn_settings = PreviousTurnSettings { model: "previous-regular-model".to_string(), @@ -562,7 +563,7 @@ async fn process_compacted_history_reinjects_model_switch_message() { text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); assert_eq!(refreshed, expected); } @@ -577,7 +578,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -586,7 +587,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -595,7 +596,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let initial_context = vec![ResponseItem::Message { @@ -605,7 +606,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let refreshed = @@ -618,7 +619,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "older user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -627,7 +628,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -636,7 +637,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: "latest user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -645,7 +646,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_summary_last() text: format!("{SUMMARY_PREFIX}\nsummary text"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(refreshed, expected); @@ -656,7 +657,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let initial_context = vec![ResponseItem::Message { id: None, @@ -665,7 +666,7 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let refreshed = @@ -678,12 +679,12 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last text: "fresh permissions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "encrypted".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(refreshed, expected); diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index eb6f094248..a639aa51ba 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -359,25 +359,25 @@ impl ContextManager { id, call_id, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::FunctionCallOutput { id: id.clone(), call_id: call_id.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::CustomToolCallOutput { id, call_id, name, output, - metadata, + internal_chat_message_metadata_passthrough: metadata, } => ResponseItem::CustomToolCallOutput { id: id.clone(), call_id: call_id.clone(), name: name.clone(), output: truncate_function_output_payload(output, policy_with_serialization_budget), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }, ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index bbf8d8059a..b388a1a060 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -10,12 +10,12 @@ use codex_protocol::models::FunctionCallOutputBody; use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::LocalShellAction; use codex_protocol::models::LocalShellExecAction; use codex_protocol::models::LocalShellStatus; use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; @@ -43,7 +43,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -62,7 +62,7 @@ fn inter_agent_assistant_msg(text: &str) -> ResponseItem { text: serde_json::to_string(&communication).unwrap(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -96,7 +96,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -108,7 +108,7 @@ fn user_input_text_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -120,7 +120,7 @@ fn developer_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -135,7 +135,7 @@ fn developer_msg_with_fragments(texts: &[&str]) -> ResponseItem { }) .collect(), phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -174,7 +174,7 @@ fn custom_tool_call_output(call_id: &str, output: &str) -> ResponseItem { call_id: call_id.to_string(), name: None, output: FunctionCallOutputPayload::from_text(output.to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -188,7 +188,7 @@ fn reasoning_msg(text: &str) -> ResponseItem { text: text.to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -200,7 +200,7 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { }], content: None, encrypted_content: Some("a".repeat(len)), - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -224,7 +224,7 @@ fn filters_non_api_messages() { text: "ignored".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let reasoning = reasoning_msg("thinking..."); h.record_items([&system, &reasoning, &ResponseItem::Other], policy); @@ -247,7 +247,7 @@ fn filters_non_api_messages() { text: "thinking...".to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -256,7 +256,7 @@ fn filters_non_api_messages() { text: "hi".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -265,7 +265,7 @@ fn filters_non_api_messages() { text: "hello".to_string() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } ] ); @@ -415,7 +415,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -423,7 +423,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -437,7 +437,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -445,7 +445,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -460,7 +460,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let history = create_history_with_items(items); @@ -484,7 +484,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -492,7 +492,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -506,7 +506,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { .to_string(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -514,7 +514,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { call_id: "tool-1".to_string(), name: "js_repl".to_string(), input: "view_image".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, @@ -529,7 +529,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { .to_string(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; assert_eq!(stripped, expected); @@ -549,7 +549,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]); let preserved = with_images.for_prompt(&modalities); assert_eq!(preserved.len(), 1); @@ -569,7 +569,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -578,7 +578,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { text: "hi".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]); @@ -590,7 +590,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { status: "generating".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -599,7 +599,7 @@ fn for_prompt_preserves_image_generation_calls_when_images_are_supported() { text: "hi".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } ] ); @@ -615,14 +615,14 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { text: "generate a lobster".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]); @@ -636,14 +636,14 @@ fn for_prompt_clears_image_generation_result_when_images_are_unsupported() { text: "generate a lobster".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("ig_123".to_string()), status: "completed".to_string(), revised_prompt: Some("lobster".to_string()), result: String::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -680,13 +680,13 @@ fn remove_first_item_removes_matching_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -701,7 +701,7 @@ fn remove_first_item_removes_matching_call_for_output() { id: None, call_id: "call-2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -709,7 +709,7 @@ fn remove_first_item_removes_matching_call_for_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-2".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -733,7 +733,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut history = create_history_with_items(items); @@ -755,7 +755,7 @@ fn replace_last_turn_images_replaces_tool_output_images() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -771,7 +771,7 @@ fn replace_last_turn_images_does_not_touch_user_images() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut history = create_history_with_items(items.clone()); @@ -793,13 +793,13 @@ fn remove_first_item_handles_local_shell_pair() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-3".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1022,14 +1022,14 @@ fn remove_first_item_handles_custom_tool_pair() { call_id: "tool-1".to_string(), name: "my_tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "tool-1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1051,13 +1051,13 @@ fn normalization_retains_local_shell_outputs() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("Total output lines: 1\n\nok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -1082,9 +1082,8 @@ fn record_items_truncates_function_call_output_content() { body: FunctionCallOutputBody::Text(long_output.clone()), success: Some(true), }, - metadata: Some(ResponseItemMetadata { + internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { turn_id: Some("turn-1".to_string()), - ..Default::default() }), }; @@ -1120,7 +1119,7 @@ fn record_items_truncates_custom_tool_call_output_content() { call_id: "tool-200".to_string(), name: None, output: FunctionCallOutputPayload::from_text(long_output.clone()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&item], policy); @@ -1155,7 +1154,7 @@ fn record_items_respects_custom_token_limit() { body: FunctionCallOutputBody::Text(long_output), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; history.record_items([&item], policy); @@ -1275,7 +1274,7 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1290,13 +1289,13 @@ fn normalize_adds_missing_output_for_function_call() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1311,7 +1310,7 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1326,14 +1325,14 @@ fn normalize_adds_missing_output_for_custom_tool_call() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "tool-x".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1353,7 +1352,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1373,13 +1372,13 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "shell-1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1392,7 +1391,7 @@ fn normalize_removes_orphan_function_call_output() { id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1409,7 +1408,7 @@ fn normalize_removes_orphan_custom_tool_call_output() { call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1429,14 +1428,14 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Orphan output that should be removed ResponseItem::FunctionCallOutput { id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Will get an inserted custom tool output ResponseItem::CustomToolCall { @@ -1445,7 +1444,7 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, // Local shell call also gets an inserted function call output ResponseItem::LocalShellCall { @@ -1459,7 +1458,7 @@ fn normalize_mixed_inserts_and_removals() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1475,13 +1474,13 @@ fn normalize_mixed_inserts_and_removals() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "c1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -1489,14 +1488,14 @@ fn normalize_mixed_inserts_and_removals() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "t1".to_string(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::LocalShellCall { id: None, @@ -1509,13 +1508,13 @@ fn normalize_mixed_inserts_and_removals() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "s1".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1529,7 +1528,7 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1542,13 +1541,13 @@ fn normalize_adds_missing_output_for_function_call_inserts_output() { namespace: None, arguments: "{}".to_string(), call_id: "call-x".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-x".to_string(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1562,7 +1561,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: Some("completed".to_string()), execution: "client".to_string(), arguments: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1577,7 +1576,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: Some("completed".to_string()), execution: "client".to_string(), arguments: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchOutput { id: None, @@ -1585,7 +1584,7 @@ fn normalize_adds_missing_output_for_tool_search_call() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ] ); @@ -1601,7 +1600,7 @@ fn normalize_adds_missing_output_for_custom_tool_call_panics_in_debug() { call_id: "tool-x".to_string(), name: "custom".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1622,7 +1621,7 @@ fn normalize_adds_missing_output_for_local_shell_call_with_id_panics_in_debug() env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1636,7 +1635,7 @@ fn normalize_removes_orphan_function_call_output_panics_in_debug() { id: None, call_id: "orphan-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1651,7 +1650,7 @@ fn normalize_removes_orphan_custom_tool_call_output_panics_in_debug() { call_id: "orphan-2".to_string(), name: None, output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1666,7 +1665,7 @@ fn normalize_removes_orphan_client_tool_search_output() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1685,7 +1684,7 @@ fn normalize_removes_orphan_client_tool_search_output_panics_in_debug() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); h.normalize_history(&default_input_modalities()); @@ -1699,7 +1698,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { status: "completed".to_string(), execution: "server".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut h = create_history_with_items(items); @@ -1713,7 +1712,7 @@ fn normalize_keeps_server_tool_search_output_without_matching_call() { status: "completed".to_string(), execution: "server".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -1729,13 +1728,13 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { namespace: None, arguments: "{}".to_string(), call_id: "c1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "c2".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -1743,7 +1742,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { call_id: "t1".to_string(), name: "tool".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::LocalShellCall { id: None, @@ -1756,7 +1755,7 @@ fn normalize_mixed_inserts_and_removals_panics_in_debug() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let mut h = create_history_with_items(items); @@ -1780,7 +1779,7 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let text_only_item = ResponseItem::Message { id: None, @@ -1789,7 +1788,7 @@ fn image_data_url_payload_does_not_dominate_message_estimate() { text: "Here is the screenshot".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&image_item).unwrap().len() as i64; @@ -1818,7 +1817,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1846,7 +1845,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1867,7 +1866,7 @@ fn non_base64_image_urls_are_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let function_output_item = ResponseItem::FunctionCallOutput { id: None, @@ -1878,7 +1877,7 @@ fn non_base64_image_urls_are_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!( @@ -1902,7 +1901,7 @@ fn encrypted_function_output_uses_plaintext_byte_estimate() { encrypted_content: encrypted_content.clone(), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1923,7 +1922,7 @@ fn data_url_without_base64_marker_is_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!( @@ -1945,7 +1944,7 @@ fn non_image_base64_data_url_is_unchanged() { detail: Some(DEFAULT_IMAGE_DETAIL), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1966,7 +1965,7 @@ fn mixed_case_data_url_markers_are_adjusted() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -1999,7 +1998,7 @@ fn multiple_inline_images_apply_multiple_fixed_costs() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2034,7 +2033,7 @@ fn original_detail_images_scale_with_dimensions() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2066,7 +2065,7 @@ fn original_detail_images_are_capped_at_max_patch_count() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2101,7 +2100,7 @@ fn original_detail_webp_images_scale_with_dimensions() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let raw_len = serde_json::to_string(&item).unwrap().len() as i64; @@ -2120,7 +2119,7 @@ fn text_only_items_unchanged() { text: "Hello world, this is a response.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let estimated = estimate_response_item_model_visible_bytes(&item); diff --git a/codex-rs/core/src/context_manager/normalize.rs b/codex-rs/core/src/context_manager/normalize.rs index 13aacca1e6..d11cffe4e9 100644 --- a/codex-rs/core/src/context_manager/normalize.rs +++ b/codex-rs/core/src/context_manager/normalize.rs @@ -50,7 +50,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -67,7 +67,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -84,7 +84,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { call_id: call_id.clone(), name: None, output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } @@ -102,7 +102,7 @@ pub(crate) fn ensure_call_outputs_present(items: &mut Vec) { id: None, call_id: call_id.clone(), output: FunctionCallOutputPayload::from_text("aborted".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )); } diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 22f2dbe6fe..b83c1b985c 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -235,7 +235,7 @@ fn build_text_message(role: &str, text_sections: Vec) -> Option\ntest_text\n".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -235,7 +235,7 @@ fn skips_user_instructions_and_env() { text: "test_text".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -243,7 +243,7 @@ fn skips_user_instructions_and_env() { text: "# AGENTS.md instructions for test_directory\n\n\ntest_text\n".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -252,7 +252,7 @@ fn skips_user_instructions_and_env() { .to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -260,7 +260,7 @@ fn skips_user_instructions_and_env() { text: "echo 42".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "user".to_string(), @@ -275,7 +275,7 @@ fn skips_user_instructions_and_env() { }, ], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ]; for item in items { @@ -325,7 +325,7 @@ fn parses_hook_prompt_and_hides_other_contextual_fragments() { }, ], phase: None, - metadata: None,}; + internal_chat_message_metadata_passthrough: None,}; let turn_item = parse_turn_item(&item).expect("expected hook prompt turn item"); @@ -357,7 +357,7 @@ fn internal_model_context_does_not_parse_as_visible_turn_item() { .render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!(parse_turn_item(&item).is_none()); @@ -372,7 +372,7 @@ fn parses_agent_message() { text: "Hello from Codex".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected agent message turn item"); @@ -404,7 +404,7 @@ fn parses_reasoning_summary_and_raw_content() { text: "raw details".to_string(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); @@ -437,7 +437,7 @@ fn parses_reasoning_including_raw_content() { }, ]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected reasoning turn item"); @@ -463,7 +463,7 @@ fn parses_web_search_call() { query: Some("weather".to_string()), queries: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -492,7 +492,7 @@ fn parses_web_search_open_page_call() { action: Some(WebSearchAction::OpenPage { url: Some("https://example.com".to_string()), }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -521,7 +521,7 @@ fn parses_web_search_find_in_page_call() { url: Some("https://example.com".to_string()), pattern: Some("needle".to_string()), }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); @@ -548,7 +548,7 @@ fn parses_partial_web_search_call_without_action_as_other() { id: Some("ws_partial".to_string()), status: Some("in_progress".to_string()), action: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_item = parse_turn_item(&item).expect("expected web search turn item"); diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 15024990a2..db8c0647f3 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -298,7 +298,7 @@ async fn seed_guardian_parent_history(session: &Arc, turn: &Arc, turn: &Arc, turn: &Arc, turn: &Arc anyh text: "Please also push the second docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -540,7 +540,7 @@ async fn build_guardian_prompt_delta_mode_preserves_original_numbering() -> anyh text: "I need approval for the second push.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -663,7 +663,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Compacted retained user request.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -672,7 +672,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Compacted summary of earlier guardian context.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], /*reference_context_item*/ None, @@ -689,7 +689,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "Please push after the compaction.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -698,7 +698,7 @@ async fn build_guardian_prompt_stale_delta_version_falls_back_to_full_prompt() - text: "I need approval for the post-compaction push.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -746,7 +746,7 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() { text: "\n/tmp\n".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -755,7 +755,7 @@ fn collect_guardian_transcript_entries_skips_contextual_user_messages() { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -783,7 +783,7 @@ fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() text: "ordinary developer context".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -792,7 +792,7 @@ fn collect_guardian_transcript_entries_keeps_manual_approval_developer_message() text: approval_text.clone(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -817,7 +817,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { text: "check the repo".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -825,7 +825,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { namespace: None, arguments: "{\"path\":\"README.md\"}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, @@ -833,7 +833,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { output: codex_protocol::models::FunctionCallOutputPayload::from_text( "repo is public".to_string(), ), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -842,7 +842,7 @@ fn collect_guardian_transcript_entries_includes_recent_tool_calls_and_output() { text: "I need to push a fix".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -1698,7 +1698,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() ), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -1930,7 +1930,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "Please push the second docs fix too.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1939,7 +1939,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "I need approval for the second docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -1977,7 +1977,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "Please push the third docs fix too.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -1986,7 +1986,7 @@ async fn guardian_reuses_prompt_cache_key_and_appends_prior_reviews() -> anyhow: text: "I need approval for the third docs fix.".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], ) @@ -2726,7 +2726,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "Please inspect pending changes before pushing.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "assistant".to_string(), @@ -2734,7 +2734,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "I need approval to run git diff.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ], ) .await; @@ -2793,7 +2793,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "Now inspect whether pushing is safe.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ResponseItem::Message { id: None, role: "assistant".to_string(), @@ -2801,7 +2801,7 @@ async fn guardian_ephemeral_retry_preserves_parallel_trunk_and_fork_history() -> text: "I need approval to push after the diff check.".to_string(), }], phase: None, - metadata: None,}, + internal_chat_message_metadata_passthrough: None,}, ], ) .await; diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 8ddbd76965..7bc4b8e103 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -49,7 +49,7 @@ fn preparation_preserves_small_image_bytes_and_non_data_urls() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -86,7 +86,7 @@ fn detail_policies_apply_the_expected_budgets() { role: "user".to_string(), content: vec![ContentItem::InputImage { image_url, detail }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -133,7 +133,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prepare_response_items(&mut items); @@ -165,7 +165,7 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } diff --git a/codex-rs/core/src/realtime_context_tests.rs b/codex-rs/core/src/realtime_context_tests.rs index 352d6ae8e4..e74d5b3e06 100644 --- a/codex-rs/core/src/realtime_context_tests.rs +++ b/codex-rs/core/src/realtime_context_tests.rs @@ -78,7 +78,7 @@ fn message(role: &str, content: ContentItem) -> ResponseItem { role: role.to_string(), content: vec![content], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 1823b28fdb..b28bd37c79 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -20,7 +20,7 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -32,7 +32,7 @@ fn assistant_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -51,7 +51,7 @@ fn inter_agent_assistant_message(text: &str) -> ResponseItem { text: serde_json::to_string(&communication).unwrap(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5c56f03d02..903ea0b477 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -107,8 +107,8 @@ use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; +use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ResponseItem; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::CompactedItem; @@ -196,7 +196,7 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -210,7 +210,7 @@ fn assign_missing_response_item_ids_skips_agent_messages() { content: vec![AgentMessageInputContent::InputText { text: "done".to_string(), }], - metadata: None, + internal_chat_message_metadata_passthrough: None, }, user_message("hello"), ]); @@ -229,7 +229,7 @@ fn assistant_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -289,7 +289,7 @@ fn skill_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -1636,9 +1636,8 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { text: "summary".to_string(), }], phase: None, - metadata: Some(ResponseItemMetadata { + internal_chat_message_metadata_passthrough: Some(InternalChatMessageMetadataPassthrough { turn_id: Some("compact-turn".to_string()), - ..Default::default() }), }; let replacement_history = vec![ @@ -1650,7 +1649,7 @@ async fn reconstruct_history_uses_replacement_history_verbatim() { text: "stale developer instructions".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let first_window_id = Uuid::now_v7(); @@ -1722,7 +1721,7 @@ async fn prepares_image_failures_before_history_insertion() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session @@ -1757,7 +1756,7 @@ async fn prepares_image_failures_before_history_insertion() { ]), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; assert_eq!(history.raw_items(), expected.as_slice()); } @@ -1778,7 +1777,7 @@ async fn prepares_resumed_history_before_installing_it() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session @@ -1803,7 +1802,7 @@ async fn prepares_resumed_history_before_installing_it() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }] ); } @@ -7923,7 +7922,7 @@ async fn build_initial_context_omits_default_image_save_location_with_image_hist status: "completed".to_string(), revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }], /*reference_context_item*/ None, ) @@ -8176,7 +8175,7 @@ async fn handle_output_item_done_records_image_save_history_message() { status: "completed".to_string(), revised_prompt: Some("a tiny blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { @@ -8233,7 +8232,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { status: "completed".to_string(), revised_prompt: Some("broken payload".to_string()), result: "_-8".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { @@ -8399,7 +8398,7 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co text: format!("{}\nsummary", crate::compact::SUMMARY_PREFIX), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn_context, std::slice::from_ref(&compacted_summary)) @@ -8998,7 +8997,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() text: "late pending input".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!( history.raw_items().iter().any(|item| item == &expected), @@ -9431,7 +9430,7 @@ async fn abort_empty_active_turn_preserves_pending_input() { text: "late pending input".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let turn_state = { let mut active = sess.active_turn.lock().await; @@ -9690,7 +9689,7 @@ async fn tool_calls_reopen_mailbox_delivery_for_current_turn() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let mut ctx = HandleOutputCtx { sess: Arc::clone(&sess), @@ -9818,7 +9817,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() { call_id: "call-1".to_string(), name: "shell_command".to_string(), input: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let call = ToolRouter::build_tool_call(item.clone()) @@ -9903,7 +9902,7 @@ async fn sample_rollout( text: "first user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user1), @@ -9918,7 +9917,7 @@ async fn sample_rollout( text: "assistant reply one".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant1), @@ -9950,7 +9949,7 @@ async fn sample_rollout( text: "second user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user2), @@ -9965,7 +9964,7 @@ async fn sample_rollout( text: "assistant reply two".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant2), @@ -9997,7 +9996,7 @@ async fn sample_rollout( text: "third user".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&user3), @@ -10012,7 +10011,7 @@ async fn sample_rollout( text: "assistant reply three".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; live_history.record_items( std::iter::once(&assistant3), @@ -10158,7 +10157,7 @@ while :; do sleep 1; done"#, }) .to_string(), call_id: "shell-cleanup-call".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let call = ToolRouter::build_tool_call(item)? .expect("shell command response item should build a tool call"); diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 7011e6d0f5..e44b12ef72 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -535,7 +535,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message text: "stale developer message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -544,7 +544,7 @@ async fn process_compacted_history_preserves_separate_guardian_developer_message text: "summary".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ], InitialContextInjection::BeforeLastUserMessage, diff --git a/codex-rs/core/src/session/turn_tests.rs b/codex-rs/core/src/session/turn_tests.rs index 7c9f003cb4..051799cf85 100644 --- a/codex-rs/core/src/session/turn_tests.rs +++ b/codex-rs/core/src/session/turn_tests.rs @@ -33,7 +33,7 @@ fn assistant_output_text(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/src/stream_events_utils.rs b/codex-rs/core/src/stream_events_utils.rs index 5a2f854782..71a9ca7d21 100644 --- a/codex-rs/core/src/stream_events_utils.rs +++ b/codex-rs/core/src/stream_events_utils.rs @@ -627,7 +627,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti id: None, call_id: call_id.clone(), output: output.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::CustomToolCallOutput { @@ -639,7 +639,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti call_id: call_id.clone(), name: name.clone(), output: output.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.as_function_call_output_payload(); @@ -647,7 +647,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti id: None, call_id: call_id.clone(), output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } ResponseInputItem::ToolSearchOutput { @@ -661,7 +661,7 @@ pub(crate) fn response_input_to_response_item(input: &ResponseInputItem) -> Opti status: status.clone(), execution: execution.clone(), tools: tools.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), _ => None, } diff --git a/codex-rs/core/src/stream_events_utils_tests.rs b/codex-rs/core/src/stream_events_utils_tests.rs index 45c5d18e68..b01ed4c4ca 100644 --- a/codex-rs/core/src/stream_events_utils_tests.rs +++ b/codex-rs/core/src/stream_events_utils_tests.rs @@ -42,7 +42,7 @@ fn assistant_output_text_with_phase(text: &str, phase: Option) -> text: text.to_string(), }], phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -53,7 +53,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { id: None, status: Some("completed".to_string()), action: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchCall { id: None, @@ -61,7 +61,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { status: None, execution: "client".to_string(), arguments: serde_json::json!({"query": "calendar"}), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ToolSearchOutput { id: None, @@ -69,7 +69,7 @@ fn external_context_pollution_items_include_web_search_and_tool_search() { status: "completed".to_string(), execution: "client".to_string(), tools: Vec::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -94,7 +94,7 @@ fn external_context_pollution_items_exclude_local_tool_calls() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -102,13 +102,13 @@ fn external_context_pollution_items_exclude_local_tool_calls() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -116,14 +116,14 @@ fn external_context_pollution_items_exclude_local_tool_calls() { call_id: "custom-1".to_string(), name: "apply_patch".to_string(), input: "*** Begin Patch\n*** End Patch\n".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "custom-1".to_string(), name: Some("apply_patch".to_string()), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_output_text("plain assistant text"), ]; @@ -425,7 +425,7 @@ fn completed_item_defers_mailbox_delivery_for_image_generation_calls() { status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert!(completed_item_defers_mailbox_delivery_to_next_turn( diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index b24d43df3d..1745a40256 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -113,7 +113,7 @@ pub(crate) fn interrupted_turn_history_marker( text: marker.render(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }) } } diff --git a/codex-rs/core/src/tasks/review.rs b/codex-rs/core/src/tasks/review.rs index 7c18a5f787..19ac0fdc35 100644 --- a/codex-rs/core/src/tasks/review.rs +++ b/codex-rs/core/src/tasks/review.rs @@ -248,7 +248,7 @@ pub(crate) async fn exit_review_mode( role: "user".to_string(), content: vec![ContentItem::InputText { text: user_message }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], ) .await; @@ -269,7 +269,7 @@ pub(crate) async fn exit_review_mode( text: assistant_message, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ) .await; diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index d4515e87d8..afb103b883 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -42,7 +42,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } fn assistant_msg(text: &str) -> ResponseItem { @@ -53,7 +53,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -82,7 +82,7 @@ fn truncates_before_requested_user_message() { }], content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -90,7 +90,7 @@ fn truncates_before_requested_user_message() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/thread_rollout_truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs index bdbdbc4c75..2df3d1c98e 100644 --- a/codex-rs/core/src/thread_rollout_truncation_tests.rs +++ b/codex-rs/core/src/thread_rollout_truncation_tests.rs @@ -15,7 +15,7 @@ fn user_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -27,7 +27,7 @@ fn assistant_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -39,7 +39,7 @@ fn developer_msg(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -79,7 +79,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { }], content: None, encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -87,7 +87,7 @@ fn truncates_rollout_from_start_before_nth_user_only() { name: "tool".to_string(), namespace: None, arguments: "{}".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, assistant_msg("a4"), ]; diff --git a/codex-rs/core/src/tools/code_mode/delegate.rs b/codex-rs/core/src/tools/code_mode/delegate.rs index 7dd6615095..c467fe0d6f 100644 --- a/codex-rs/core/src/tools/code_mode/delegate.rs +++ b/codex-rs/core/src/tools/code_mode/delegate.rs @@ -303,7 +303,7 @@ impl CoreTurnHost { call_id, name: Some(PUBLIC_TOOL_NAME.to_string()), output: FunctionCallOutputPayload::from_text(text), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]) .await .map_err(|_| { diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index ae79ba405d..808a9c123d 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -329,7 +329,7 @@ mod tests { text: "extension history".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 76881f5a06..2561cd7ded 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -1198,11 +1198,6 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-spawn-message") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && communication.trigger_turn ) })); @@ -1230,11 +1225,6 @@ async fn multi_agent_v2_spawn_returns_path_and_send_message_accepts_relative_pat && communication.other_recipients.is_empty() && communication.content.is_empty() && communication.encrypted_content.as_deref() == Some("encrypted-send-message") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && !communication.trigger_turn ) })); @@ -2048,11 +2038,6 @@ async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() if communication.author == AgentPath::root() && communication.recipient == worker_path && communication.encrypted_content.as_deref() == Some("continue") - && communication - .metadata - .as_ref() - .and_then(|metadata| metadata.source_call_id.as_deref()) - == Some("call-1") && communication.trigger_turn ) })); @@ -2831,7 +2816,7 @@ async fn resume_agent_restores_closed_agent_and_accepts_send_input() { text: "materialized".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })]), AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")), /*parent_trace*/ None, diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs index 81eebed4a3..7fdb80e8bc 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/message_tool.rs @@ -6,7 +6,6 @@ use super::*; use crate::tools::context::FunctionToolOutput; use crate::turn_timing::now_unix_timestamp_ms; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::InterAgentCommunication; #[derive(Clone, Copy, PartialEq, Eq)] @@ -100,12 +99,8 @@ pub(crate) async fn handle_message_string_tool( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let mut communication = + let communication = communication_from_tool_message(author, receiver_agent_path.clone(), message); - communication - .metadata - .get_or_insert_with(ResponseItemMetadata::default) - .source_call_id = Some(call_id.clone()); let result = session .services .agent_control diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index c04d46c6e4..837257dec9 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -8,7 +8,6 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::turn_timing::now_unix_timestamp_ms; use codex_protocol::AgentPath; -use codex_protocol::models::ResponseItemMetadata; use codex_protocol::protocol::Op; use codex_tools::ToolSpec; @@ -123,12 +122,8 @@ async fn handle_spawn_agent( .session_source .get_agent_path() .unwrap_or_else(AgentPath::root); - let mut communication = + let communication = communication_from_tool_message(author, new_agent_path.clone(), message); - communication - .metadata - .get_or_insert_with(ResponseItemMetadata::default) - .source_call_id = Some(call_id.clone()); Op::InterAgentCommunication { communication } } initial_operation => initial_operation, diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index a38ab8af26..9cf237477f 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -157,7 +157,7 @@ async fn build_tool_call_uses_namespace_for_registry_name() -> anyhow::Result<() namespace: Some("mcp__codex_apps__calendar".to_string()), arguments: "{}".to_string(), call_id: "call-namespace".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call"); @@ -340,7 +340,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow text: "extension history".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) @@ -376,7 +376,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow namespace: Some("extension/".to_string()), arguments: json!({ "message": "hello" }).to_string(), call_id: "call-extension".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, })? .expect("function_call should produce a tool call"); let result = router diff --git a/codex-rs/core/src/turn_timing_tests.rs b/codex-rs/core/src/turn_timing_tests.rs index 9f1bcd31b1..956468da23 100644 --- a/codex-rs/core/src/turn_timing_tests.rs +++ b/codex-rs/core/src/turn_timing_tests.rs @@ -112,7 +112,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); assert!(response_item_records_turn_ttft( @@ -122,7 +122,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { call_id: "call-2".to_string(), name: "custom".to_string(), input: "echo hi".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); assert!(response_item_records_turn_ttft(&ResponseItem::Message { @@ -132,7 +132,7 @@ fn response_item_records_turn_ttft_for_first_output_signals() { text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })); } @@ -145,14 +145,14 @@ fn response_item_records_turn_ttft_ignores_empty_non_output_items() { text: String::new(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, })); assert!(!response_item_records_turn_ttft( &ResponseItem::FunctionCallOutput { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("ok".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } )); } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index b071535c52..10abf64b6b 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -684,7 +684,7 @@ pub fn user_message_item(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index cf9b9a939f..2f323e0dd3 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -141,7 +141,7 @@ async fn responses_stream_includes_subagent_header_on_review() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session @@ -273,7 +273,7 @@ async fn responses_stream_includes_subagent_header_on_other() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session @@ -391,7 +391,7 @@ async fn responses_respects_model_info_overrides_from_config() { text: "hello".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let mut stream = client_session diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 3c905351cc..cbd014c3f7 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -186,7 +186,7 @@ fn assert_codex_client_metadata( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn non_openai_responses_requests_omit_item_turn_metadata() { +async fn non_openai_responses_requests_omit_item_passthrough_metadata() { let server = MockServer::start().await; let response_mock = mount_sse_once( &server, @@ -230,8 +230,9 @@ async fn non_openai_responses_requests_omit_item_turn_metadata() { assert!(!input.is_empty(), "request should include input items"); for item in input { assert!( - item.get("metadata").is_none(), - "input item should omit metadata: {item}" + item.get("internal_chat_message_metadata_passthrough") + .is_none(), + "input item should omit internal chat message metadata passthrough: {item}" ); assert!( item.get("id").is_none(), @@ -537,7 +538,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_user_json = serde_json::to_value(&prior_user).unwrap(); writeln!( @@ -559,7 +560,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed system instruction".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_system_json = serde_json::to_value(&prior_system).unwrap(); writeln!( @@ -581,7 +582,7 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { text: "resumed assistant message".to_string(), }], phase: Some(MessagePhase::Commentary), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let prior_item_json = serde_json::to_value(&prior_item).unwrap(); writeln!( @@ -724,7 +725,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { call_id: "legacy-js-call".to_string(), name: "js_repl".to_string(), input: "console.log('legacy image flow')".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let legacy_image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; let thread_id = ThreadId::default(); @@ -757,7 +758,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { call_id: "legacy-js-call".to_string(), name: None, output: FunctionCallOutputPayload::from_text("legacy js_repl stdout".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -770,7 +771,7 @@ async fn resume_replays_legacy_js_repl_image_rollout_shapes() { detail: Some(DEFAULT_IMAGE_DETAIL), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, ]; @@ -890,7 +891,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { namespace: None, arguments: "{\"path\":\"/tmp/example.png\"}".to_string(), call_id: function_call_id.to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -904,7 +905,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -915,7 +916,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { call_id: custom_call_id.to_string(), name: "js_repl".to_string(), input: "console.log('image flow')".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, RolloutLine { @@ -930,7 +931,7 @@ async fn resume_replays_image_tool_outputs_with_detail() { detail: Some(ImageDetail::Original), }, ]), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }, ]; @@ -1179,7 +1180,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth text: "hello".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); let mut stream = client_session @@ -2793,7 +2794,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { text: "content".into(), }]), encrypted_content: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::Message { id: Some("message-id".into()), @@ -2802,7 +2803,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { text: "message".into(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::WebSearchCall { id: Some("web-search-id".into()), @@ -2811,7 +2812,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { query: Some("weather".into()), queries: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCall { id: Some("function-id".into()), @@ -2819,13 +2820,13 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { namespace: None, arguments: "{}".into(), call_id: "function-call-id".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::FunctionCallOutput { id: None, call_id: "function-call-id".into(), output: FunctionCallOutputPayload::from_text("ok".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::LocalShellCall { id: Some("local-shell-id".into()), @@ -2838,7 +2839,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { env: None, user: None, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::CustomToolCall { id: Some("custom-tool-id".into()), @@ -2846,14 +2847,14 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { call_id: "custom-tool-call-id".into(), name: "custom_tool".into(), input: "{}".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); prompt.input.push(ResponseItem::CustomToolCallOutput { id: None, call_id: "custom-tool-call-id".into(), name: None, output: FunctionCallOutputPayload::from_text("ok".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }); let mut stream = client_session diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 1ccbd302d8..5d1721ee75 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2059,7 +2059,7 @@ fn message_item(text: &str) -> ResponseItem { role: "user".into(), content: vec![ContentItem::InputText { text: text.into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -2069,7 +2069,7 @@ fn assistant_message_item(id: &str, text: &str) -> ResponseItem { role: "assistant".into(), content: vec![ContentItem::OutputText { text: text.into() }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index fb20603850..dec106d0d8 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -2037,12 +2037,12 @@ async fn auto_compact_runs_after_resume_when_token_usage_is_over_limit() { text: remote_summary.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = @@ -4163,12 +4163,12 @@ async fn auto_compact_counts_encrypted_reasoning_before_last_user() { text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = @@ -4291,12 +4291,12 @@ async fn auto_compact_runs_when_reasoning_header_clears_between_turns() { text: "REMOTE_COMPACT_SUMMARY".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, codex_protocol::models::ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index 77561093c6..ececb02b22 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -159,7 +159,7 @@ fn compacted_summary_only_output(summary: &str) -> Vec { vec![ResponseItem::Compaction { id: None, encrypted_content: summary_with_prefix(summary), - metadata: None, + internal_chat_message_metadata_passthrough: None, }] } @@ -333,7 +333,7 @@ async fn remote_compact_replaces_history_for_followups() -> Result<()> { let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let compact_mock = responses::mount_compact_json_once( harness.server(), @@ -2361,7 +2361,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Message { id: None, @@ -2370,7 +2370,7 @@ async fn remote_compact_persists_replacement_history_in_rollout() -> Result<()> text: "COMPACTED_ASSISTANT_NOTE".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -2512,12 +2512,12 @@ async fn remote_compact_and_resume_refresh_stale_developer_instructions() -> Res text: stale_developer_message.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -2655,12 +2655,12 @@ async fn remote_compact_refreshes_stale_developer_instructions_without_resume() text: stale_developer_message.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::Compaction { id: None, encrypted_content: "ENCRYPTED_COMPACTION_SUMMARY".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; let compact_mock = responses::mount_compact_json_once( @@ -4067,7 +4067,7 @@ async fn snapshot_request_shape_remote_mid_turn_compaction_summary_only_reinject let compacted_history = vec![ResponseItem::Compaction { id: None, encrypted_content: summary_with_prefix("REMOTE_SUMMARY_ONLY"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; let compact_mock = responses::mount_compact_json_once( harness.server(), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index c97e8cc896..f644f33404 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -178,7 +178,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(actual, expected); @@ -269,7 +269,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(actual, expected); diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 5883a8564b..0628f8ff98 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -49,7 +49,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { text: "hello from debug prompt".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(input.last(), Some(&expected_user_message)); assert!(input.iter().any(|item| { diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index dd97e5a5d3..1a95223bff 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -2350,7 +2350,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge role: "user".to_string(), content: vec![ContentItem::InputText { text: user_turn }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::ResponseItem(ResponseItem::Message { id: None, @@ -2359,7 +2359,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge text: assistant_turn, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), ] }) diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 99794939dc..af44baf6ca 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -532,7 +532,7 @@ async fn review_input_isolated_from_parent_history() { text: "parent: earlier user message".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let user_json = serde_json::to_value(&user).unwrap(); let user_line = serde_json::json!({ @@ -552,7 +552,7 @@ async fn review_input_isolated_from_parent_history() { text: "parent: assistant reply".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let assistant_json = serde_json::to_value(&assistant).unwrap(); let assistant_line = serde_json::json!({ diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index 2b9395f598..b24f1c1654 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -1090,9 +1090,6 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result "type": "agent_message", "author": "/root", "recipient": "/root/worker", - "metadata": { - "source_call_id": SPAWN_CALL_ID, - }, "content": [ { "type": "input_text", diff --git a/codex-rs/ext/image-generation/src/tests.rs b/codex-rs/ext/image-generation/src/tests.rs index e2f5ac666c..fe915746f9 100644 --- a/codex-rs/ext/image-generation/src/tests.rs +++ b/codex-rs/ext/image-generation/src/tests.rs @@ -76,7 +76,7 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCall { id: None, @@ -84,13 +84,13 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { namespace: None, arguments: "{}".to_string(), call_id: "mcp-call".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "mcp-call".to_string(), output: image_output("mcp"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCall { id: None, @@ -98,27 +98,27 @@ async fn recent_image_fallback_selects_newest_images_in_chronological_order() { call_id: "code-mode-call".to_string(), name: "exec".to_string(), input: String::new(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::CustomToolCallOutput { id: None, call_id: "code-mode-call".to_string(), name: Some("exec".to_string()), output: image_output("code-mode"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::ImageGenerationCall { id: Some("generated-call".to_string()), status: "completed".to_string(), revised_prompt: None, result: "generated".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseItem::FunctionCallOutput { id: None, call_id: "orphan-call".to_string(), output: image_output("orphan"), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ]; @@ -206,7 +206,7 @@ async fn recent_image_fallback_requires_requested_count() { role: "user".to_string(), content: vec![input_image("only-image")], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }], &[], ) diff --git a/codex-rs/ext/web-search/src/history.rs b/codex-rs/ext/web-search/src/history.rs index df3faf123c..10fb0dd65e 100644 --- a/codex-rs/ext/web-search/src/history.rs +++ b/codex-rs/ext/web-search/src/history.rs @@ -36,7 +36,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { ResponseItem::AgentMessage { author, content, - metadata, + internal_chat_message_metadata_passthrough: metadata, .. } => { if let Some(text) = plaintext_agent_message_content(content) { @@ -47,7 +47,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { text: format!("Agent message from {author}:\n{text}"), }], phase: None, - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }); } } @@ -56,7 +56,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } if role == USER_ROLE && matches!(parse_turn_item(item), Some(TurnItem::UserMessage(_))) => { @@ -71,7 +71,7 @@ fn push_visible_message(messages: &mut Vec, item: &ResponseItem) { role: role.clone(), content, phase: phase.clone(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }); } } @@ -104,7 +104,7 @@ mod tests { } }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } @@ -125,7 +125,7 @@ mod tests { namespace: None, arguments: "{}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }, previous_assistant, message("developer", "developer"), @@ -158,7 +158,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let items = vec![ previous_user, diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 2990c0f5ea..6391d27bd3 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -141,7 +141,7 @@ fn response_item(message: ConversationMessage) -> ResponseItem { }, content: vec![content], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/memories/write/src/phase1.rs b/codex-rs/memories/write/src/phase1.rs index 026826b7c9..5b5ae81662 100644 --- a/codex-rs/memories/write/src/phase1.rs +++ b/codex-rs/memories/write/src/phase1.rs @@ -303,7 +303,7 @@ mod job { )?, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }]; prompt.base_instructions = BaseInstructions { text: crate::stage_one::PROMPT.to_string(), @@ -429,7 +429,7 @@ mod job { role, content, phase, - metadata, + internal_chat_message_metadata_passthrough: metadata, } = item else { return should_persist_response_item_for_memories(item).then(|| item.clone()); @@ -457,7 +457,7 @@ mod job { role: role.clone(), content, phase: phase.clone(), - metadata: metadata.clone(), + internal_chat_message_metadata_passthrough: metadata.clone(), }) } @@ -687,7 +687,7 @@ mod tests { }, ], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let skill_message = ResponseItem::Message { id: None, @@ -698,7 +698,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let subagent_message = ResponseItem::Message { id: None, @@ -708,7 +708,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let serialized = job::serialize_filtered_rollout_response_items(&[ @@ -730,7 +730,7 @@ mod tests { .to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, subagent_message, ] @@ -750,7 +750,7 @@ mod tests { ), success: Some(true), }, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, )]) .expect("serialize"); diff --git a/codex-rs/memories/write/src/startup_tests.rs b/codex-rs/memories/write/src/startup_tests.rs index 13ba9da3b7..fad1ce14a4 100644 --- a/codex-rs/memories/write/src/startup_tests.rs +++ b/codex-rs/memories/write/src/startup_tests.rs @@ -685,7 +685,7 @@ async fn seed_stage1_candidate( text: "remember this startup test conversation".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }; let jsonl = serde_json::to_string(&line)?; diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 08d257dc3b..76bc0e3992 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -404,7 +404,7 @@ pub fn build_hook_prompt_message(fragments: &[HookPromptFragment]) -> Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub source_call_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] @@ -931,7 +932,7 @@ pub enum ResponseItem { phase: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, AgentMessage { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -942,7 +943,7 @@ pub enum ResponseItem { content: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, Reasoning { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -955,7 +956,7 @@ pub enum ResponseItem { encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, LocalShellCall { /// Legacy id field retained for compatibility with older payloads. @@ -968,7 +969,7 @@ pub enum ResponseItem { action: LocalShellAction, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, FunctionCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -985,7 +986,7 @@ pub enum ResponseItem { call_id: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ToolSearchCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1000,7 +1001,7 @@ pub enum ResponseItem { arguments: serde_json::Value, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // NOTE: The `output` field for `function_call_output` uses a dedicated payload type with // custom serialization. On the wire it is either: @@ -1017,7 +1018,7 @@ pub enum ResponseItem { output: FunctionCallOutputPayload, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, CustomToolCall { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1032,7 +1033,7 @@ pub enum ResponseItem { input: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // `custom_tool_call_output.output` uses the same wire encoding as // `function_call_output.output` so freeform tools can return either plain @@ -1050,7 +1051,7 @@ pub enum ResponseItem { output: FunctionCallOutputPayload, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ToolSearchOutput { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1063,7 +1064,7 @@ pub enum ResponseItem { tools: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Emitted by the Responses API when the agent triggers a web search. // Example payload (from SSE `response.output_item.done`): @@ -1085,7 +1086,7 @@ pub enum ResponseItem { action: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Emitted by the Responses API when the agent triggers image generation. // Example payload: @@ -1107,7 +1108,7 @@ pub enum ResponseItem { result: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, #[serde(alias = "compaction_summary")] Compaction { @@ -1117,14 +1118,14 @@ pub enum ResponseItem { encrypted_content: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, // Compaction triggers are request controls, and the Responses API does not // accept an `id` field for them. CompactionTrigger { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1135,7 +1136,7 @@ pub enum ResponseItem { encrypted_content: Option, #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] - metadata: Option, + internal_chat_message_metadata_passthrough: Option, }, #[serde(other)] Other, @@ -1191,7 +1192,7 @@ impl ResponseItem { /// Returns the non-empty turn ID stamped onto this item, if present. pub fn turn_id(&self) -> Option<&str> { - self.metadata() + self.internal_chat_message_metadata_passthrough() .and_then(|metadata| metadata.turn_id.as_deref()) .filter(|turn_id| !turn_id.is_empty()) } @@ -1201,59 +1202,154 @@ impl ResponseItem { if turn_id.is_empty() || self.turn_id().is_some() { return; } - let Some(metadata) = self.metadata_mut() else { + let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else { return; }; metadata - .get_or_insert_with(ResponseItemMetadata::default) + .get_or_insert_with(InternalChatMessageMetadataPassthrough::default) .turn_id = Some(turn_id.to_string()); } - /// Removes Responses API item metadata before sending to a provider that does not accept it. - pub fn clear_metadata(&mut self) { - if let Some(metadata) = self.metadata_mut() { + /// Removes internal chat message metadata passthrough before sending to a provider that does + /// not accept it. + pub fn clear_internal_chat_message_metadata_passthrough(&mut self) { + if let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() { *metadata = None; } } - fn metadata(&self) -> Option<&ResponseItemMetadata> { + fn internal_chat_message_metadata_passthrough( + &self, + ) -> Option<&InternalChatMessageMetadataPassthrough> { match self { - Self::Message { metadata, .. } - | Self::AgentMessage { metadata, .. } - | Self::Reasoning { metadata, .. } - | Self::LocalShellCall { metadata, .. } - | Self::FunctionCall { metadata, .. } - | Self::ToolSearchCall { metadata, .. } - | Self::FunctionCallOutput { metadata, .. } - | Self::CustomToolCall { metadata, .. } - | Self::CustomToolCallOutput { metadata, .. } - | Self::ToolSearchOutput { metadata, .. } - | Self::WebSearchCall { metadata, .. } - | Self::ImageGenerationCall { metadata, .. } - | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata, .. } - | Self::ContextCompaction { metadata, .. } => metadata.as_ref(), + Self::Message { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::AgentMessage { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Reasoning { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::LocalShellCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::WebSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ImageGenerationCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Compaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CompactionTrigger { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => metadata.as_ref(), Self::Other => None, } } - fn metadata_mut(&mut self) -> Option<&mut Option> { + fn internal_chat_message_metadata_passthrough_mut( + &mut self, + ) -> Option<&mut Option> { match self { - Self::Message { metadata, .. } - | Self::AgentMessage { metadata, .. } - | Self::Reasoning { metadata, .. } - | Self::LocalShellCall { metadata, .. } - | Self::FunctionCall { metadata, .. } - | Self::ToolSearchCall { metadata, .. } - | Self::FunctionCallOutput { metadata, .. } - | Self::CustomToolCall { metadata, .. } - | Self::CustomToolCallOutput { metadata, .. } - | Self::ToolSearchOutput { metadata, .. } - | Self::WebSearchCall { metadata, .. } - | Self::ImageGenerationCall { metadata, .. } - | Self::Compaction { metadata, .. } - | Self::CompactionTrigger { metadata, .. } - | Self::ContextCompaction { metadata, .. } => Some(metadata), + Self::Message { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::AgentMessage { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Reasoning { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::LocalShellCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::FunctionCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CustomToolCallOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ToolSearchOutput { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::WebSearchCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ImageGenerationCall { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::Compaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::CompactionTrigger { + internal_chat_message_metadata_passthrough: metadata, + .. + } + | Self::ContextCompaction { + internal_chat_message_metadata_passthrough: metadata, + .. + } => Some(metadata), Self::Other => None, } } @@ -1495,13 +1591,13 @@ impl From for ResponseItem { content, id: None, phase, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::FunctionCallOutput { call_id, output } => Self::FunctionCallOutput { id: None, call_id, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::McpToolCallOutput { call_id, output } => { let output = output.into_function_call_output_payload(); @@ -1509,7 +1605,7 @@ impl From for ResponseItem { id: None, call_id, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } ResponseInputItem::CustomToolCallOutput { @@ -1521,7 +1617,7 @@ impl From for ResponseItem { call_id, name, output, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, ResponseInputItem::ToolSearchOutput { call_id, @@ -1534,7 +1630,7 @@ impl From for ResponseItem { execution, tools, id: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }, } } @@ -2087,14 +2183,15 @@ mod tests { text: "still working".to_string(), }], phase: Some(MessagePhase::Commentary), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } #[test] - fn response_item_metadata_round_trips_and_stamps_turn_ids() -> Result<()> { - let mut item = response_item_with_metadata(Some(response_item_metadata("turn-1"))); + fn response_item_passthrough_metadata_round_trips_and_stamps_turn_ids() -> Result<()> { + let mut item = + response_item_with_passthrough_metadata(Some(passthrough_metadata("turn-1"))); let round_trip: ResponseItem = serde_json::from_value(serde_json::to_value(&item)?)?; assert_eq!(round_trip, item); @@ -2102,7 +2199,7 @@ mod tests { "type": "message", "role": "user", "content": [{"type": "input_text", "text": "hello"}], - "metadata": { + "internal_chat_message_metadata_passthrough": { "turn_id": "turn-1", "other": "ignored", }, @@ -2112,11 +2209,14 @@ mod tests { item.stamp_turn_id_if_missing("turn-2"); assert_eq!(item.turn_id(), Some("turn-1")); - let mut empty_turn_id = response_item_with_metadata(Some(response_item_metadata(""))); + let mut empty_turn_id = + response_item_with_passthrough_metadata(Some(passthrough_metadata(""))); empty_turn_id.stamp_turn_id_if_missing("turn-1"); assert_eq!(empty_turn_id.turn_id(), Some("turn-1")); - let mut missing_turn_id = response_item_with_metadata(/*metadata*/ None); + let mut missing_turn_id = response_item_with_passthrough_metadata( + /*internal_chat_message_metadata_passthrough*/ None, + ); missing_turn_id.stamp_turn_id_if_missing(""); missing_turn_id.stamp_turn_id_if_missing("turn-1"); assert_eq!(missing_turn_id.turn_id(), Some("turn-1")); @@ -2129,7 +2229,9 @@ mod tests { #[test] fn response_item_id_getter_and_setter() { - let mut item = response_item_with_metadata(/*metadata*/ None); + let mut item = response_item_with_passthrough_metadata( + /*internal_chat_message_metadata_passthrough*/ None, + ); assert_eq!(item.id(), None); item.set_id(Some("msg_test".to_string())); @@ -2141,7 +2243,9 @@ mod tests { assert_eq!(item.id(), None); } - fn response_item_with_metadata(metadata: Option) -> ResponseItem { + fn response_item_with_passthrough_metadata( + internal_chat_message_metadata_passthrough: Option, + ) -> ResponseItem { ResponseItem::Message { id: None, role: "user".to_string(), @@ -2149,14 +2253,13 @@ mod tests { text: "hello".to_string(), }], phase: None, - metadata, + internal_chat_message_metadata_passthrough, } } - fn response_item_metadata(turn_id: &str) -> ResponseItemMetadata { - ResponseItemMetadata { + fn passthrough_metadata(turn_id: &str) -> InternalChatMessageMetadataPassthrough { + InternalChatMessageMetadataPassthrough { turn_id: Some(turn_id.to_string()), - ..Default::default() } } @@ -2261,7 +2364,7 @@ mod tests { status: "completed".to_string(), revised_prompt: Some("A small blue square".to_string()), result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2283,7 +2386,7 @@ mod tests { status: "completed".to_string(), revised_prompt: None, result: "Zm9v".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2635,7 +2738,7 @@ mod tests { namespace: Some("mcp__codex_apps__gmail".to_string()), arguments: "{\"top_k\":5}".to_string(), call_id: "call-1".to_string(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } @@ -2983,7 +3086,7 @@ mod tests { ResponseItem::Compaction { id: None, encrypted_content: "abc".into(), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -3000,7 +3103,7 @@ mod tests { ResponseItem::ContextCompaction { id: None, encrypted_content: Some("abc".into()), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); Ok(()) @@ -3008,7 +3111,9 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { metadata: None }; + let item = ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }; assert_eq!( serde_json::to_value(item)?, @@ -3020,15 +3125,17 @@ mod tests { } #[test] - fn serializes_stamped_compaction_trigger_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { metadata: None }; + fn serializes_stamped_compaction_trigger_passthrough_metadata() -> Result<()> { + let mut item = ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + }; item.stamp_turn_id_if_missing("turn-1"); assert_eq!( serde_json::to_value(item)?, serde_json::json!({ "type": "compaction_trigger", - "metadata": { + "internal_chat_message_metadata_passthrough": { "turn_id": "turn-1", }, }) @@ -3042,7 +3149,12 @@ mod tests { let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!(item, ResponseItem::CompactionTrigger { metadata: None }); + assert_eq!( + item, + ResponseItem::CompactionTrigger { + internal_chat_message_metadata_passthrough: None, + } + ); Ok(()) } @@ -3134,7 +3246,7 @@ mod tests { id: expected_id.clone(), status: expected_status.clone(), action: expected_action.clone(), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; assert_eq!(parsed, expected); @@ -3219,7 +3331,7 @@ mod tests { "query": "calendar create", "limit": 1, }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3281,7 +3393,7 @@ mod tests { "additionalProperties": false, } })], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3335,7 +3447,7 @@ mod tests { arguments: serde_json::json!({ "paths": ["crm"], }), - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); @@ -3356,7 +3468,7 @@ mod tests { status: "completed".to_string(), execution: "server".to_string(), tools: vec![], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ea1df417a4..dfcc6cb7a5 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -42,7 +42,6 @@ use crate::models::MessagePhase; use crate::models::PermissionProfile; use crate::models::ResponseInputItem; use crate::models::ResponseItem; -use crate::models::ResponseItemMetadata; use crate::models::SandboxEnforcement; use crate::models::WebSearchAction; use crate::num_format::format_with_separators; @@ -697,9 +696,6 @@ pub struct InterAgentCommunication { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub encrypted_content: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - pub metadata: Option, pub trigger_turn: bool, } @@ -717,7 +713,6 @@ impl InterAgentCommunication { other_recipients, content, encrypted_content: None, - metadata: None, trigger_turn, } } @@ -735,7 +730,6 @@ impl InterAgentCommunication { other_recipients, content: String::new(), encrypted_content: Some(encrypted_content), - metadata: None, trigger_turn, } } @@ -779,7 +773,7 @@ impl InterAgentCommunication { author: self.author.to_string(), recipient: self.recipient.to_string(), content, - metadata: self.metadata.clone(), + internal_chat_message_metadata_passthrough: None, } } @@ -3060,7 +3054,7 @@ impl From for ResponseItem { text: value.message, }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } } @@ -4349,7 +4343,6 @@ mod tests { other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), encrypted_content: None, - metadata: None, trigger_turn: true, }; @@ -4390,7 +4383,7 @@ mod tests { encrypted_content: "encrypted payload".to_string(), }, ], - metadata: None, + internal_chat_message_metadata_passthrough: None, } ); } diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index 7afdcf5847..66f26f9758 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -504,7 +504,7 @@ mod tests { text: "raw reasoning".to_string(), }]), encrypted_content: Some("encoded".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }; let normal = serde_json::to_value(&item).expect("response item serializes"); diff --git a/codex-rs/rollout/src/tests.rs b/codex-rs/rollout/src/tests.rs index 21d712e0d1..983fff824c 100644 --- a/codex-rs/rollout/src/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1320,7 +1320,7 @@ async fn test_updated_at_uses_file_mtime() -> Result<()> { text: format!("reply-{idx}"), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }), }; writeln!(file, "{}", serde_json::to_string(&response_line)?)?; diff --git a/codex-rs/state/src/extract.rs b/codex-rs/state/src/extract.rs index b419ef3286..aa1dd04036 100644 --- a/codex-rs/state/src/extract.rs +++ b/codex-rs/state/src/extract.rs @@ -191,7 +191,7 @@ mod tests { text: "hello from response item".to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, }); apply_rollout_item(&mut metadata, &item, "test-provider"); diff --git a/codex-rs/thread-store/src/local/mod.rs b/codex-rs/thread-store/src/local/mod.rs index e158c901ab..b96e05cfd5 100644 --- a/codex-rs/thread-store/src/local/mod.rs +++ b/codex-rs/thread-store/src/local/mod.rs @@ -513,7 +513,7 @@ mod tests { id: None, call_id: "call-1".to_string(), output: FunctionCallOutputPayload::from_text("tool output".to_string()), - metadata: None, + internal_chat_message_metadata_passthrough: None, }), RolloutItem::EventMsg(EventMsg::TokenCount( codex_protocol::protocol::TokenCountEvent { diff --git a/codex-rs/tools/src/response_history.rs b/codex-rs/tools/src/response_history.rs index 4196d7c3ef..ab16769afd 100644 --- a/codex-rs/tools/src/response_history.rs +++ b/codex-rs/tools/src/response_history.rs @@ -95,7 +95,7 @@ mod tests { } }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 0b69153fa5..11ce6cb9e5 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -462,7 +462,7 @@ impl App { text: SIDE_BOUNDARY_PROMPT.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } From 20431d49a07d7cb6f3271a1e03929b444f3b50bb Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 11:34:14 -0700 Subject: [PATCH 36/86] [sdk/python] Stop advertising HTTP image URLs (#29464) ## Summary - use generated image data URLs in the Python SDK examples and notebook - document HTTP and HTTPS image URLs as deprecated and recommend `LocalImageInput` - replace the remote-URL integration test with data-URL coverage `ImageInput` remains available for data URLs. The SDK does not duplicate app-server URL validation. ## Testing - `uv run --frozen --no-sync ruff check --output-format=full .` - `uv run --frozen --no-sync ruff format --check .` - full Python SDK test suite with an isolated writable `CODEX_SQLITE_HOME` (119 passed, 38 skipped) --- sdk/python/docs/api-reference.md | 3 +++ .../examples/07_image_and_text/async.py | 6 ++--- sdk/python/examples/07_image_and_text/sync.py | 6 ++--- sdk/python/examples/README.md | 2 +- sdk/python/examples/_bootstrap.py | 6 +++++ sdk/python/notebooks/sdk_walkthrough.ipynb | 8 +++--- sdk/python/src/openai_codex/_inputs.py | 2 +- sdk/python/tests/test_app_server_inputs.py | 27 +++++++++++-------- 8 files changed, 37 insertions(+), 23 deletions(-) diff --git a/sdk/python/docs/api-reference.md b/sdk/python/docs/api-reference.md index f003a28511..f253185dc5 100644 --- a/sdk/python/docs/api-reference.md +++ b/sdk/python/docs/api-reference.md @@ -245,6 +245,9 @@ Input = list[InputItem] | InputItem RunInput = Input | str ``` +Use `ImageInput` with a base64-encoded `data:image/...` URL. HTTP and HTTPS image URLs are +deprecated; download remote images and pass their local paths with `LocalImageInput` instead. + Use a plain `str` as shorthand for `TextInput(...)` anywhere a turn input is accepted: `thread.run("...")`, `thread.turn("...")`, and `turn.steer("...")`. diff --git a/sdk/python/examples/07_image_and_text/async.py b/sdk/python/examples/07_image_and_text/async.py index c3237e320e..8673e455a3 100644 --- a/sdk/python/examples/07_image_and_text/async.py +++ b/sdk/python/examples/07_image_and_text/async.py @@ -5,7 +5,7 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ensure_local_sdk_src, runtime_config +from _bootstrap import ensure_local_sdk_src, generated_sample_image_data_url, runtime_config ensure_local_sdk_src() @@ -13,7 +13,7 @@ import asyncio from openai_codex import AsyncCodex, ImageInput, TextInput -REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" +IMAGE_DATA_URL = generated_sample_image_data_url() async def main() -> None: @@ -24,7 +24,7 @@ async def main() -> None: turn = await thread.turn( [ TextInput("What is in this image? Give 3 bullets."), - ImageInput(REMOTE_IMAGE_URL), + ImageInput(IMAGE_DATA_URL), ] ) result = await turn.run() diff --git a/sdk/python/examples/07_image_and_text/sync.py b/sdk/python/examples/07_image_and_text/sync.py index f7402f18c9..1b20f8462d 100644 --- a/sdk/python/examples/07_image_and_text/sync.py +++ b/sdk/python/examples/07_image_and_text/sync.py @@ -5,20 +5,20 @@ _EXAMPLES_ROOT = Path(__file__).resolve().parents[1] if str(_EXAMPLES_ROOT) not in sys.path: sys.path.insert(0, str(_EXAMPLES_ROOT)) -from _bootstrap import ensure_local_sdk_src, runtime_config +from _bootstrap import ensure_local_sdk_src, generated_sample_image_data_url, runtime_config ensure_local_sdk_src() from openai_codex import Codex, ImageInput, TextInput -REMOTE_IMAGE_URL = "https://raw.githubusercontent.com/github/explore/main/topics/python/python.png" +IMAGE_DATA_URL = generated_sample_image_data_url() with Codex(config=runtime_config()) as codex: thread = codex.thread_start(model="gpt-5.4", config={"model_reasoning_effort": "high"}) result = thread.turn( [ TextInput("What is in this image? Give 3 bullets."), - ImageInput(REMOTE_IMAGE_URL), + ImageInput(IMAGE_DATA_URL), ] ).run() diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 719fb29a41..a86580cc76 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -72,7 +72,7 @@ python examples/01_quickstart_constructor/async.py - `06_thread_lifecycle_and_controls/` - thread lifecycle + control calls - `07_image_and_text/` - - remote image URL + text multimodal turn + - image data URL + text multimodal turn - `08_local_image_and_text/` - local image + text multimodal turn using a generated temporary sample image - `09_async_parity/` diff --git a/sdk/python/examples/_bootstrap.py b/sdk/python/examples/_bootstrap.py index fde7a32c00..88039f4b9c 100644 --- a/sdk/python/examples/_bootstrap.py +++ b/sdk/python/examples/_bootstrap.py @@ -1,5 +1,6 @@ from __future__ import annotations +import base64 import contextlib import importlib.util import sys @@ -95,6 +96,11 @@ def _generated_sample_png_bytes() -> bytes: ) +def generated_sample_image_data_url() -> str: + encoded = base64.b64encode(_generated_sample_png_bytes()).decode("ascii") + return f"data:image/png;base64,{encoded}" + + @contextlib.contextmanager def temporary_sample_image_path() -> Iterator[Path]: with tempfile.TemporaryDirectory(prefix="codex-python-example-image-") as temp_root: diff --git a/sdk/python/notebooks/sdk_walkthrough.ipynb b/sdk/python/notebooks/sdk_walkthrough.ipynb index 1bb9bf731f..3f84cf0675 100644 --- a/sdk/python/notebooks/sdk_walkthrough.ipynb +++ b/sdk/python/notebooks/sdk_walkthrough.ipynb @@ -103,7 +103,7 @@ "outputs": [], "source": [ "# Cell 2: imports (public only)\n", - "from _bootstrap import server_label\n", + "from _bootstrap import generated_sample_image_data_url, server_label\n", "from openai_codex import (\n", " AsyncCodex,\n", " Codex,\n", @@ -349,14 +349,14 @@ "metadata": {}, "outputs": [], "source": [ - "# Cell 6: multimodal with remote image\n", - "remote_image_url = 'https://raw.githubusercontent.com/github/explore/main/topics/python/python.png'\n", + "# Cell 6: multimodal with an image data URL\n", + "image_data_url = generated_sample_image_data_url()\n", "\n", "with Codex() as codex:\n", " thread = codex.thread_start(model='gpt-5.4', config={'model_reasoning_effort': 'high'})\n", " result = thread.turn([\n", " TextInput('What do you see in this image? 3 bullets.'),\n", - " ImageInput(remote_image_url),\n", + " ImageInput(image_data_url),\n", " ]).run()\n", " print('status:', result.status)\n", " print(result.final_response)\n" diff --git a/sdk/python/src/openai_codex/_inputs.py b/sdk/python/src/openai_codex/_inputs.py index a6e5e7b528..0a1fbe1100 100644 --- a/sdk/python/src/openai_codex/_inputs.py +++ b/sdk/python/src/openai_codex/_inputs.py @@ -14,7 +14,7 @@ class TextInput: @dataclass(slots=True) class ImageInput: - """Remote image URL supplied as turn input.""" + """Image data URL supplied as turn input.""" url: str diff --git a/sdk/python/tests/test_app_server_inputs.py b/sdk/python/tests/test_app_server_inputs.py index 56505e4817..29b8fc7726 100644 --- a/sdk/python/tests/test_app_server_inputs.py +++ b/sdk/python/tests/test_app_server_inputs.py @@ -1,40 +1,45 @@ from __future__ import annotations +import base64 + from app_server_harness import AppServerHarness from app_server_helpers import TINY_PNG_BYTES from openai_codex import Codex, ImageInput, LocalImageInput, SkillInput, TextInput -def test_remote_image_input_reaches_responses_api( +def test_data_url_image_input_reaches_responses_api( tmp_path, ) -> None: - """Remote image inputs should survive the SDK and app-server boundary.""" - remote_image_url = "https://example.com/codex.png" + """Data URL image inputs should survive the SDK and app-server boundary.""" + image_data_url = "data:image/png;base64," + base64.b64encode(TINY_PNG_BYTES).decode("ascii") with AppServerHarness(tmp_path) as harness: harness.responses.enqueue_assistant_message( - "remote image received", - response_id="remote-image", + "data URL image received", + response_id="data-url-image", ) with Codex(config=harness.app_server_config()) as codex: result = codex.thread_start().run( [ - TextInput("Describe the remote image."), - ImageInput(remote_image_url), + TextInput("Describe the data URL image."), + ImageInput(image_data_url), ] ) request = harness.responses.single_request() assert { "final_response": result.final_response, - "contains_user_prompt": "Describe the remote image." in request.message_input_texts("user"), - "image_urls": request.message_image_urls("user"), + "contains_user_prompt": "Describe the data URL image." + in request.message_input_texts("user"), + "image_url_is_png_data_url": request.message_image_urls("user")[-1].startswith( + "data:image/png;base64," + ), } == { - "final_response": "remote image received", + "final_response": "data URL image received", "contains_user_prompt": True, - "image_urls": [remote_image_url], + "image_url_is_png_data_url": True, } From 5c0fbf34925041ac94a267aa17237e4e07b0ce42 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 22 Jun 2026 11:45:41 -0700 Subject: [PATCH 37/86] [codex] Fix usage-limit reset copy and state (#28793) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The reset flow introduced in #28154 still describes earned reset credits as "rate-limit resets" and uses generic reset-scope copy. It can also retain a stale available-credit count after redemption or an account change, leaving the reset action enabled after the last credit is used. This follow-up updates terminology only within that reset feature. Existing rate-limit wording elsewhere in the CLI and TUI is unchanged. ## What changed - Rename reset-specific `/usage` menu items, startup hints, and reset dialogs to "usage limit reset." - Describe monthly resets for Free, Go, and accounts that report a monthly usage window; otherwise describe the current 5-hour and weekly limits. - Recheck a cached zero balance when `/usage` is reopened, and refresh the balance after redemption so the final reset immediately disables the action. - Correlate async refresh results before updating snapshots and clear account-derived reset state, warnings, prompts, and status surfaces when the account changes. ## Validation - `just test -p codex-tui chatwidget::tests::usage` — 29 passed. - `just test -p codex-tui chatwidget::tests::status_command_tests` — 7 passed. - Account-boundary prompt and plan-mode prompt regression tests passed. - `cargo insta pending-snapshots` from `codex-rs/tui` — no pending snapshots.\ image view for monthly image --- codex-rs/tui/src/app/background_requests.rs | 24 +- codex-rs/tui/src/app/event_dispatch.rs | 59 ++++- codex-rs/tui/src/app_event.rs | 9 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/constructor.rs | 1 + codex-rs/tui/src/chatwidget/rate_limits.rs | 2 + codex-rs/tui/src/chatwidget/settings.rs | 25 ++- ...ests__rate_limit_reset_available_hint.snap | 2 +- ...it_reset_hint_waits_for_active_output.snap | 2 +- ..._tests__rate_limit_reset_popup_states.snap | 32 +-- ...chatwidget__tests__usage_command_menu.snap | 4 +- ...age_command_menu_before_reset_refresh.snap | 4 +- ...ts__usage_command_menu_without_resets.snap | 4 +- ...sage_limit_reset_confirmation_monthly.snap | 30 +++ .../tui/src/chatwidget/status_controls.rs | 16 +- .../src/chatwidget/tests/status_and_layout.rs | 33 +++ .../chatwidget/tests/status_command_tests.rs | 30 ++- codex-rs/tui/src/chatwidget/tests/usage.rs | 205 +++++++++++++++++- codex-rs/tui/src/chatwidget/usage.rs | 123 ++++++++--- codex-rs/tui/src/slash_command.rs | 2 +- 20 files changed, 496 insertions(+), 112 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 08e7f9b071..4a0652166d 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -19,7 +19,6 @@ use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::MarketplaceUpgradeParams; use codex_app_server_protocol::MarketplaceUpgradeResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::RequestId; use crate::hooks_rpc::fetch_hooks_list; @@ -90,7 +89,8 @@ impl App { .and_then(|result| result.map_err(|err| err.to_string())) } RateLimitRefreshOrigin::StartupPrefetch { .. } - | RateLimitRefreshOrigin::StatusCommand { .. } => { + | RateLimitRefreshOrigin::StatusCommand { .. } + | RateLimitRefreshOrigin::UsageMenu { .. } => { request.await.map_err(|err| err.to_string()) } }; @@ -127,7 +127,7 @@ impl App { tokio::spawn(async move { let result = tokio::time::timeout( RATE_LIMIT_RESET_REQUEST_TIMEOUT, - fetch_rate_limit_reset_credits(request_handle), + fetch_account_rate_limits(request_handle), ) .await .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) @@ -789,24 +789,6 @@ pub(super) async fn fetch_account_token_activity( .wrap_err("account/usage/read failed in TUI") } -pub(super) async fn fetch_rate_limit_reset_credits( - request_handle: AppServerRequestHandle, -) -> Result { - let request_id = RequestId::String(format!("account-rate-limit-resets-{}", Uuid::new_v4())); - let response: GetAccountRateLimitsResponse = request_handle - .request_typed(ClientRequest::GetAccountRateLimits { - request_id, - params: None, - }) - .await - .wrap_err("account/rateLimits/read failed in TUI")?; - response.rate_limit_reset_credits.ok_or_else(|| { - color_eyre::eyre::eyre!( - "account/rateLimits/read response did not include rateLimitResetCredits" - ) - }) -} - pub(super) async fn consume_rate_limit_reset_credit_request( request_handle: AppServerRequestHandle, idempotency_key: String, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 0830c69b0f..174c4d14e5 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -758,15 +758,14 @@ impl App { AppEvent::RateLimitsLoaded { origin, result } => match result { Ok(response) => { let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); - for snapshot in app_server_rate_limit_snapshots(response) { - self.chat_widget.on_rate_limit_snapshot(Some(snapshot)); - } + let snapshots = app_server_rate_limit_snapshots(response); match origin { RateLimitRefreshOrigin::StartupPrefetch { reset_hint_request_id, } => { if self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -779,6 +778,7 @@ impl App { RateLimitRefreshOrigin::ResetConsume { request_id } => { self.chat_widget.finish_post_consume_reset_credits_refresh( request_id, + snapshots, rate_limit_reset_credits.ok_or_else(|| { "account/rateLimits/read response did not include rateLimitResetCredits" .to_string() @@ -788,7 +788,17 @@ impl App { } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, snapshots); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + snapshots, + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); } } } @@ -800,16 +810,27 @@ impl App { } => { self.chat_widget.finish_rate_limit_reset_hint_refresh( reset_hint_request_id, + Vec::new(), Err(err), ); } RateLimitRefreshOrigin::ResetConsume { request_id } => { - self.chat_widget - .finish_post_consume_reset_credits_refresh(request_id, Err(err)); + self.chat_widget.finish_post_consume_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } RateLimitRefreshOrigin::StatusCommand { request_id } => { self.chat_widget - .finish_status_rate_limit_refresh(request_id); + .finish_status_rate_limit_refresh(request_id, Vec::new()); + } + RateLimitRefreshOrigin::UsageMenu { request_id } => { + self.chat_widget.finish_usage_menu_rate_limit_refresh( + request_id, + Vec::new(), + Err(err), + ); } } } @@ -822,15 +843,29 @@ impl App { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); self.refresh_rate_limit_reset_credits(app_server, request_id); } - AppEvent::RateLimitResetCreditsLoaded { request_id, result } => { - if let Err(err) = &result { + AppEvent::RateLimitResetCreditsLoaded { request_id, result } => match result { + Ok(response) => { + let rate_limit_reset_credits = response.rate_limit_reset_credits.clone(); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + app_server_rate_limit_snapshots(response), + rate_limit_reset_credits.ok_or_else(|| { + "account/rateLimits/read response did not include rateLimitResetCredits" + .to_string() + }), + ); + } + Err(err) => { tracing::warn!( "account/rateLimits/read failed during reset-credit refresh: {err}" ); + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); } - self.chat_widget - .finish_rate_limit_reset_credits_refresh(request_id, result); - } + }, AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => { let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup(); self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 26686c3b53..55d67f2926 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -27,7 +27,6 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginUninstallResponse; -use codex_app_server_protocol::RateLimitResetCreditsSummary; use codex_app_server_protocol::SkillsListResponse; use codex_app_server_protocol::ThreadGoalStatus; use codex_file_search::FileMatch; @@ -119,7 +118,9 @@ pub(crate) struct PluginRemoteSectionError { /// updates the cached snapshots and any available reset-credit notice (no /// status card to finalize). A `StatusCommand` is tied to a specific `/status` /// invocation and must call `finish_status_rate_limit_refresh` when done so the -/// card stops showing a "refreshing" state. +/// card stops showing a "refreshing" state. A `UsageMenu` refreshes a cached +/// zero reset count so the disabled menu entry can become available without a +/// restart. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RateLimitRefreshOrigin { /// Eagerly fetched after bootstrap for `/status` data and reset availability. @@ -127,6 +128,8 @@ pub(crate) enum RateLimitRefreshOrigin { /// User-initiated via `/status`; the `request_id` correlates with the /// status card that should be updated when the fetch completes. StatusCommand { request_id: u64 }, + /// User reopened `/usage` while the cached reset-credit count was zero. + UsageMenu { request_id: u64 }, /// Refresh requested after a reset credit was successfully consumed. ResetConsume { request_id: u64 }, } @@ -315,7 +318,7 @@ pub(crate) enum AppEvent { /// Result of reading the current reset-credit balance. RateLimitResetCreditsLoaded { request_id: u64, - result: Result, + result: Result, }, /// Consume one reset credit using a stable idempotency key. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 20bef58a1a..aa7879f76d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -557,6 +557,7 @@ pub(crate) struct ChatWidget { next_token_activity_request_id: u64, pending_rate_limit_reset_request_id: Option, pending_rate_limit_reset_hint_request_id: Option, + pending_usage_menu_rate_limit_request_id: Option, pending_rate_limit_reset_hint: Option, available_rate_limit_reset_credits: Option, next_rate_limit_reset_request_id: u64, diff --git a/codex-rs/tui/src/chatwidget/constructor.rs b/codex-rs/tui/src/chatwidget/constructor.rs index 17f2c8c3b0..414a693bde 100644 --- a/codex-rs/tui/src/chatwidget/constructor.rs +++ b/codex-rs/tui/src/chatwidget/constructor.rs @@ -131,6 +131,7 @@ impl ChatWidget { next_token_activity_request_id: 0, pending_rate_limit_reset_request_id: None, pending_rate_limit_reset_hint_request_id: None, + pending_usage_menu_rate_limit_request_id: None, pending_rate_limit_reset_hint: None, available_rate_limit_reset_credits: None, next_rate_limit_reset_request_id: 0, diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index efb0b63628..77a0c5c7ec 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -5,6 +5,7 @@ use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; pub(super) const RATE_LIMIT_SWITCH_PROMPT_THRESHOLD: f64 = 90.0; +pub(super) const RATE_LIMIT_SWITCH_PROMPT_VIEW_ID: &str = "rate-limit-switch-prompt"; const RATE_LIMIT_WARNING_THRESHOLDS: [f64; 3] = [75.0, 90.0, 95.0]; const PRIMARY_LIMIT_FALLBACK_LABEL: &str = "usage"; @@ -399,6 +400,7 @@ impl ChatWidget { ]; self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID), title: Some("Approaching rate limits".to_string()), subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), footer_hint: Some(standard_popup_hint_line()), diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 75c4cc66e4..269606098f 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -2,6 +2,7 @@ use super::*; use crate::app_event::AppEvent; +use crate::chatwidget::rate_limits::RATE_LIMIT_SWITCH_PROMPT_VIEW_ID; impl ChatWidget { /// Set the approval policy in the widget's config copy. @@ -217,12 +218,22 @@ impl ChatWidget { has_chatgpt_account: bool, has_codex_backend_auth: bool, ) { - let account_state_changed = self.status_account_display != status_account_display - || self.has_chatgpt_account != has_chatgpt_account - || self.has_codex_backend_auth != has_codex_backend_auth; - if account_state_changed { - self.clear_pending_token_activity_refreshes(); - self.clear_pending_rate_limit_reset_requests(); + // Account-update notifications are the identity boundary. The visible account fields can + // be identical across two accounts, so always invalidate account-scoped requests and data. + self.clear_pending_token_activity_refreshes(); + self.clear_pending_rate_limit_reset_requests(); + self.codex_rate_limit_reached_type = None; + self.rate_limit_warnings = RateLimitWarningState::default(); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + self.bottom_pane + .dismiss_view_by_id(RATE_LIMIT_SWITCH_PROMPT_VIEW_ID); + let had_refreshing_status_outputs = !self.refreshing_status_outputs.is_empty(); + let now = Local::now(); + for (_, handle) in self.refreshing_status_outputs.drain(..) { + handle.finish_rate_limit_refresh(&[], now); + } + if had_refreshing_status_outputs { + self.request_redraw(); } self.status_line_workspace_headline = None; self.status_line_workspace_headline_pending_request_id = None; @@ -236,7 +247,7 @@ impl ChatWidget { .set_connectors_enabled(self.connectors_enabled()); self.bottom_pane .set_token_activity_command_enabled(has_codex_backend_auth); - self.refresh_status_line(); + self.refresh_status_surfaces(); } /// Set the syntax theme override in the widget's config copy. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap index 5b528bb1df..56f0fc2f89 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_available_hint.snap @@ -2,4 +2,4 @@ source: tui/src/chatwidget/tests/usage.rs expression: rendered --- -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap index 9e332a9256..e18178d460 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_hint_waits_for_active_output.snap @@ -4,4 +4,4 @@ expression: "lines_to_single_string(&chat.active_cell_transcript_lines(80).expec --- active tool -• You have 2 rate-limit resets available. Run /usage to use one. +• You have 2 usage limit resets available. Run /usage to use one. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap index ba6066e0bc..12b1568f55 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_popup_states.snap @@ -2,41 +2,41 @@ source: tui/src/chatwidget/tests/usage.rs expression: "states.join(\"\\n---\\n\")" --- - Rate-limit resets + Usage limit resets Checking your available resets... › Loading... Press enter to confirm or esc to go back --- - Rate-limit resets - You have 2 rate-limit resets available. + Usage limit resets + You have 2 usage limit resets available. - 1. Use a reset Reset your current Codex usage windows. + 1. Use a reset Reset your current 5-hour and weekly usage limits. › 2. Cancel Press enter to confirm or esc to go back --- - Rate-limit resets - You don't have any rate-limit resets available. + Usage limit resets + You don't have any usage limit resets available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - Couldn't load rate-limit resets. Please try again. + Usage limit resets + Couldn't load usage limit resets. Please try again. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Resetting your usage... › Using a reset... --- - Rate-limit resets + Usage limit resets Couldn't reset usage. Please try again. › 1. Try again @@ -44,27 +44,27 @@ expression: "states.join(\"\\n---\\n\")" Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Your usage does not need a reset right now. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets - No rate-limit resets are available. + Usage limit resets + No usage limit resets are available. › 1. Close Press enter to confirm or esc to go back --- - Rate-limit resets + Usage limit resets Usage reset. Checking your remaining resets... › Refreshing... --- - Rate-limit resets - Usage reset. You have 1 rate-limit reset left. + Usage limit resets + Usage reset. You have 1 usage limit reset left. › 1. Close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap index c3fc73a49f..70aa225aaf 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset You have 2 rate-limit resets available. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset You have 2 usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap index 6bfee11dea..d4dc5deff1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_before_reset_refresh.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + 2. Redeem usage limit reset Check reset availability. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap index 6bfee11dea..127e37f7a3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_command_menu_without_resets.snap @@ -5,7 +5,7 @@ expression: "render_bottom_popup(&chat, 80)" Usage View account usage or redeem an earned reset. -› 1. Show usage View recent account token usage. - 2. Redeem rate limit reset Check reset availability. +› 1. Show usage View recent account token usage. + Redeem usage limit reset No usage limit resets available. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap new file mode 100644 index 0000000000..08a595d4a2 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__usage_limit_reset_confirmation_monthly.snap @@ -0,0 +1,30 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "states.join(\"\\n---\\n\")" +--- +Free: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Go: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back +--- +Business with monthly window: + Usage limit resets + You have 1 usage limit reset available. + + 1. Use a reset Reset your current monthly usage limit. +› 2. Cancel + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs index d4de738314..2c58be9370 100644 --- a/codex-rs/tui/src/chatwidget/status_controls.rs +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -247,11 +247,23 @@ impl ChatWidget { self.add_to_history(cell); } - pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { - if self.refreshing_status_outputs.is_empty() { + pub(crate) fn finish_status_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + ) { + if !self + .refreshing_status_outputs + .iter() + .any(|(pending_request_id, _)| *pending_request_id == request_id) + { return; } + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + let rate_limit_snapshots: Vec = self .rate_limit_snapshots_by_limit_id .values() diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 9e528fb6c2..d484c1ee56 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1032,6 +1032,39 @@ async fn rate_limit_switch_prompt_shows_once_per_session() { )); } +#[tokio::test] +async fn account_update_clears_derived_usage_limit_state_and_prompt() { + let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; + set_chatgpt_auth(&mut chat); + let mut limits = snapshot(/*percent*/ 95.0); + limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached); + chat.on_rate_limit_snapshot(Some(limits)); + chat.maybe_show_pending_rate_limit_prompt(); + + assert!(chat.rate_limit_warnings.primary_index > 0); + assert!(chat.codex_rate_limit_reached_type.is_some()); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + )); + assert!(!chat.bottom_pane.no_modal_or_popup_active()); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + + assert_eq!(chat.rate_limit_warnings.primary_index, 0); + assert_eq!(chat.rate_limit_warnings.secondary_index, 0); + assert_eq!(chat.codex_rate_limit_reached_type, None); + assert!(matches!( + chat.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Idle + )); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn rate_limit_switch_prompt_respects_hidden_notice() { let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs index 953de6b89c..21e02db252 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_command_tests.rs @@ -46,8 +46,7 @@ async fn status_command_refresh_updates_cached_limits_for_future_status_outputs( other => panic!("expected rate-limit refresh request, got {other:?}"), }; - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, vec![snapshot(/*percent*/ 92.0)]); drain_insert_history(&mut rx); chat.dispatch_command(SlashCommand::Status); @@ -170,10 +169,31 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() { "expected /status to avoid transient refresh text in terminal history, got: {second_rendered}" ); - chat.finish_status_rate_limit_refresh(first_request_id); + chat.finish_status_rate_limit_refresh(first_request_id, Vec::new()); pretty_assertions::assert_eq!(chat.refreshing_status_outputs.len(), 1); - chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0))); - chat.finish_status_rate_limit_refresh(second_request_id); + chat.finish_status_rate_limit_refresh(second_request_id, vec![snapshot(/*percent*/ 92.0)]); assert!(chat.refreshing_status_outputs.is_empty()); } + +#[tokio::test] +async fn account_update_rejects_stale_status_rate_limit_snapshots() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + chat.dispatch_command(SlashCommand::Status); + assert_matches!(rx.try_recv(), Ok(AppEvent::InsertHistoryCell(_))); + let request_id = match rx.try_recv() { + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::StatusCommand { request_id }, + }) => request_id, + other => panic!("expected status refresh request, got {other:?}"), + }; + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_status_rate_limit_refresh(request_id, vec![snapshot(/*percent*/ 92.0)]); + + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); +} diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 4e49e3561c..c05b4a62ff 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -13,6 +13,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -27,12 +28,13 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { } #[tokio::test] -async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot() { +async fn usage_command_disables_reset_after_cached_zero_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); @@ -42,11 +44,109 @@ async fn usage_command_can_recheck_reset_availability_after_cached_zero_snapshot "usage_command_menu_without_resets", render_bottom_popup(&chat, /*width*/ 80) ); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn usage_menu_refresh_enables_newly_available_reset() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } +#[tokio::test] +async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + Vec::new(), + Err("backend unavailable".to_string()), + ); + + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("No usage limit resets available.")); + chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); +} + +#[tokio::test] +async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unchanged() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let startup_request_id = chat.start_rate_limit_reset_startup_check(); + assert!(chat.finish_rate_limit_reset_hint_refresh( + startup_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 0 }), + )); + chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 1 } + }) + ); + + chat.update_account_state( + /*status_account_display*/ None, /*plan_type*/ None, + /*has_chatgpt_account*/ true, /*has_codex_backend_auth*/ true, + ); + chat.finish_usage_menu_rate_limit_refresh( + /*request_id*/ 1, + vec![snapshot(/*percent*/ 92.0)], + Ok(RateLimitResetCreditsSummary { available_count: 2 }), + ); + + assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.rate_limit_snapshots_by_limit_id.is_empty()); + assert!(chat.bottom_pane.no_modal_or_popup_active()); +} + #[tokio::test] async fn usage_command_can_check_reset_availability_before_startup_refresh_finishes_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -65,7 +165,7 @@ async fn usage_command_can_check_reset_availability_before_startup_refresh_finis } #[tokio::test] -async fn usage_command_disables_reset_for_workspace_accounts() { +async fn usage_command_can_check_reset_availability_for_workspace_accounts() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -74,7 +174,7 @@ async fn usage_command_disables_reset_for_workspace_accounts() { chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } #[tokio::test] @@ -84,6 +184,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { let request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); chat.dispatch_command(SlashCommand::Usage); @@ -104,6 +205,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); record_popup(&chat, &mut states); @@ -112,6 +214,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let empty_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); record_popup(&chat, &mut states); @@ -120,6 +223,7 @@ async fn rate_limit_reset_popup_states_snapshot() { let load_error_request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( load_error_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); record_popup(&chat, &mut states); @@ -165,6 +269,7 @@ async fn rate_limit_reset_popup_states_snapshot() { record_popup(&chat, &mut states); assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); record_popup(&chat, &mut states); @@ -172,12 +277,66 @@ async fn rate_limit_reset_popup_states_snapshot() { assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n")); } +#[tokio::test] +async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + set_chatgpt_auth(&mut chat); + let mut states = Vec::new(); + + chat.plan_type = Some(PlanType::Free); + let free_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + free_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Free:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + dismiss_popup(&mut chat); + chat.plan_type = Some(PlanType::Go); + let go_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + go_request_id, + Vec::new(), + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); + + dismiss_popup(&mut chat); + let mut monthly_business_snapshot = snapshot(/*percent*/ 50.0); + monthly_business_snapshot.plan_type = Some(PlanType::Business); + monthly_business_snapshot + .primary + .as_mut() + .expect("monthly business snapshot primary window") + .window_duration_mins = Some(30 * 24 * 60); + let business_request_id = chat.show_rate_limit_reset_loading_popup(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + business_request_id, + vec![monthly_business_snapshot], + Ok(RateLimitResetCreditsSummary { available_count: 1 }), + )); + states.push(format!( + "Business with monthly window:\n{}", + render_bottom_popup(&chat, /*width*/ 80) + )); + + assert_chatwidget_snapshot!( + "usage_limit_reset_confirmation_monthly", + states.join("\n---\n") + ); +} + #[tokio::test] async fn rate_limit_reset_confirmation_selects_cancel_by_default() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -193,6 +352,7 @@ async fn rate_limit_reset_confirmation_can_use_reset() { let request_id = chat.show_rate_limit_reset_loading_popup(); assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); @@ -226,12 +386,13 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { } #[tokio::test] -async fn no_credit_outcome_allows_reset_availability_recheck() { +async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -244,10 +405,16 @@ async fn no_credit_outcome_allows_reset_availability_recheck() { dismiss_popup(&mut chat); chat.dispatch_command(SlashCommand::Usage); + assert_matches!( + rx.try_recv(), + Ok(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id: 2 } + }) + ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); } #[tokio::test] @@ -270,6 +437,7 @@ async fn rate_limit_reset_redemption_cannot_be_dismissed_while_in_flight() { assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); dismiss_popup(&mut chat); @@ -300,11 +468,12 @@ async fn already_redeemed_is_an_idempotent_success() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 0 rate-limit resets left.") + .contains("Usage reset. You have 0 usage limit resets left.") ); } @@ -315,6 +484,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let startup_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); @@ -327,6 +497,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_post_consume_reset_credits_refresh( consume_request_id, + Vec::new(), Err("backend unavailable".to_string()), )); dismiss_popup(&mut chat); @@ -334,7 +505,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { let rendered = render_bottom_popup(&chat, /*width*/ 80); assert!(rendered.contains("Check reset availability.")); - assert!(!rendered.contains("You have 2 rate-limit resets available.")); + assert!(!rendered.contains("You have 2 usage limit resets available.")); } #[tokio::test] @@ -350,6 +521,7 @@ async fn account_change_invalidates_pending_reset_requests() { assert!(!chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -363,6 +535,7 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -385,6 +558,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert_eq!( @@ -395,7 +569,7 @@ async fn rate_limit_reset_load_result_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("You have 2 rate-limit resets available.") + .contains("You have 2 usage limit resets available.") ); } @@ -413,6 +587,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { )); assert!(chat.finish_post_consume_reset_credits_refresh( request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 1 }), )); assert_eq!( @@ -423,7 +598,7 @@ async fn rate_limit_reset_success_updates_popup_beneath_overlay() { chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( render_bottom_popup(&chat, /*width*/ 80) - .contains("Usage reset. You have 1 rate-limit reset left.") + .contains("Usage reset. You have 1 usage limit reset left.") ); } @@ -455,6 +630,7 @@ async fn startup_check_shows_available_reset_hint_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); let rendered = lines_to_single_string( @@ -477,6 +653,7 @@ async fn startup_reset_hint_waits_for_active_output_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); @@ -507,6 +684,7 @@ async fn opening_rate_limit_reset_flow_invalidates_in_flight_startup_hint() { assert!(!chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); @@ -519,6 +697,7 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { let hint_request_id = chat.start_rate_limit_reset_startup_check(); assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -536,13 +715,14 @@ async fn startup_check_omits_reset_hint_when_none_are_available() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 0 }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } #[tokio::test] -async fn startup_check_omits_reset_hint_for_workspace_accounts() { +async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; set_chatgpt_auth(&mut chat); chat.plan_type = Some(PlanType::Business); @@ -550,10 +730,11 @@ async fn startup_check_omits_reset_hint_for_workspace_accounts() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, + Vec::new(), Ok(RateLimitResetCreditsSummary { available_count: 2 }), )); - assert!(chat.pending_rate_limit_reset_hint().is_none()); - assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!(chat.pending_rate_limit_reset_hint().is_some()); + assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); } fn consume_response( diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 4cee18af11..53925e86d9 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -3,6 +3,7 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::RateLimitResetCreditsSummary; use uuid::Uuid; +use super::rate_limits::get_limits_duration; use super::*; const USAGE_MENU_VIEW_ID: &str = "usage-menu"; @@ -11,8 +12,21 @@ const RATE_LIMIT_RESET_VIEW_ID: &str = "rate-limit-reset"; impl ChatWidget { pub(super) fn open_usage_menu(&mut self) { self.clear_pending_rate_limit_reset_hint(); - let reset_eligible = - self.has_chatgpt_account && !self.plan_type.is_some_and(PlanType::is_workspace_account); + let should_refresh_reset_availability = self.available_rate_limit_reset_credits == Some(0); + self.bottom_pane + .show_selection_view(self.usage_menu_params()); + if should_refresh_reset_availability { + let request_id = self.take_next_rate_limit_reset_request_id(); + self.pending_usage_menu_rate_limit_request_id = Some(request_id); + self.app_event_tx.send(AppEvent::RefreshRateLimits { + origin: RateLimitRefreshOrigin::UsageMenu { request_id }, + }); + } + self.request_redraw(); + } + + fn usage_menu_params(&self) -> SelectionViewParams { + let reset_eligible = self.has_chatgpt_account; let (reset_action_enabled, reset_description) = match (reset_eligible, self.available_rate_limit_reset_credits) { (true, Some(available_count)) if available_count > 0 => ( @@ -22,10 +36,12 @@ impl ChatWidget { reset_label(available_count) ), ), - (true, _) => (true, "Check reset availability.".to_string()), - (false, _) => (false, "No rate-limit resets available.".to_string()), + (true, None) => (true, "Check reset availability.".to_string()), + (true, Some(_)) | (false, _) => { + (false, "No usage limit resets available.".to_string()) + } }; - self.bottom_pane.show_selection_view(SelectionViewParams { + SelectionViewParams { view_id: Some(USAGE_MENU_VIEW_ID), title: Some("Usage".to_string()), subtitle: Some("View account usage or redeem an earned reset.".to_string()), @@ -41,7 +57,7 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Redeem rate limit reset".to_string(), + name: "Redeem usage limit reset".to_string(), description: Some(reset_description), is_disabled: !reset_action_enabled, actions: vec![Box::new(|tx| { @@ -52,8 +68,32 @@ impl ChatWidget { }, ], ..Default::default() - }); - self.request_redraw(); + } + } + + pub(crate) fn finish_usage_menu_rate_limit_refresh( + &mut self, + request_id: u64, + snapshots: Vec, + result: Result, + ) { + if self.pending_usage_menu_rate_limit_request_id != Some(request_id) { + return; + } + self.pending_usage_menu_rate_limit_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } + if let Ok(response) = result { + self.available_rate_limit_reset_credits = Some(response.available_count); + } + let params = self.usage_menu_params(); + if self + .bottom_pane + .replace_selection_view_if_present(USAGE_MENU_VIEW_ID, params) + { + self.request_redraw(); + } } pub(crate) fn show_rate_limit_reset_loading_popup(&mut self) -> u64 { @@ -62,7 +102,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Checking your available resets...".to_string()), items: vec![SelectionItem { name: "Loading...".to_string(), @@ -78,26 +118,30 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let params = match result { Ok(response) => { self.available_rate_limit_reset_credits = Some(response.available_count); if response.available_count > 0 { - Self::rate_limit_reset_confirmation_params(response.available_count) + self.rate_limit_reset_confirmation_params(response.available_count) } else { Self::rate_limit_reset_message_params( - "You don't have any rate-limit resets available.", + "You don't have any usage limit resets available.", ) } } Err(_) => Self::rate_limit_reset_message_params( - "Couldn't load rate-limit resets. Please try again.", + "Couldn't load usage limit resets. Please try again.", ), }; let replaced = self @@ -109,11 +153,32 @@ impl ChatWidget { replaced } - fn rate_limit_reset_confirmation_params(available_count: i64) -> SelectionViewParams { + fn rate_limit_reset_confirmation_params(&self, available_count: i64) -> SelectionViewParams { let idempotency_key = Uuid::new_v4().to_string(); + let has_monthly_window = self + .rate_limit_snapshots_by_limit_id + .iter() + .find(|(limit_id, _)| limit_id.eq_ignore_ascii_case("codex")) + .into_iter() + .flat_map(|(_, snapshot)| [snapshot.primary.as_ref(), snapshot.secondary.as_ref()]) + .flatten() + .any(|window| { + window + .window_minutes + .and_then(get_limits_duration) + .as_deref() + == Some("monthly") + }); + let reset_description = if has_monthly_window + || matches!(self.plan_type, Some(PlanType::Free | PlanType::Go)) + { + "Reset your current monthly usage limit." + } else { + "Reset your current 5-hour and weekly usage limits." + }; SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(format!( "You have {available_count} {} available.", reset_label(available_count) @@ -122,7 +187,7 @@ impl ChatWidget { items: vec![ SelectionItem { name: "Use a reset".to_string(), - description: Some("Reset your current Codex usage windows.".to_string()), + description: Some(reset_description.to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::ConsumeRateLimitResetCredit { idempotency_key: idempotency_key.clone(), @@ -145,7 +210,7 @@ impl ChatWidget { fn rate_limit_reset_message_params(message: &str) -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some(message.to_string()), items: vec![SelectionItem { name: "Close".to_string(), @@ -162,7 +227,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = Some(request_id); self.bottom_pane.show_selection_view(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Resetting your usage...".to_string()), items: vec![SelectionItem { name: "Using a reset...".to_string(), @@ -206,7 +271,7 @@ impl ChatWidget { } ConsumeAccountRateLimitResetCreditOutcome::NoCredit => { self.available_rate_limit_reset_credits = Some(0); - "No rate-limit resets are available." + "No usage limit resets are available." } ConsumeAccountRateLimitResetCreditOutcome::Reset | ConsumeAccountRateLimitResetCreditOutcome::AlreadyRedeemed => unreachable!(), @@ -218,7 +283,7 @@ impl ChatWidget { self.pending_rate_limit_reset_request_id = None; self.replace_rate_limit_reset_popup(SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Couldn't reset usage. Please try again.".to_string()), items: vec![ SelectionItem { @@ -247,12 +312,16 @@ impl ChatWidget { pub(crate) fn finish_post_consume_reset_credits_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } let message = match result { Ok(response) => { @@ -272,7 +341,7 @@ impl ChatWidget { fn rate_limit_reset_success_loading_params() -> SelectionViewParams { SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), - title: Some("Rate-limit resets".to_string()), + title: Some("Usage limit resets".to_string()), subtitle: Some("Usage reset. Checking your remaining resets...".to_string()), items: vec![SelectionItem { name: "Refreshing...".to_string(), @@ -303,18 +372,19 @@ impl ChatWidget { pub(crate) fn finish_rate_limit_reset_hint_refresh( &mut self, request_id: u64, + snapshots: Vec, result: Result, ) -> bool { if self.pending_rate_limit_reset_hint_request_id != Some(request_id) { return false; } self.pending_rate_limit_reset_hint_request_id = None; + for snapshot in snapshots { + self.on_rate_limit_snapshot(Some(snapshot)); + } if !self.has_codex_backend_auth { return false; } - if self.plan_type.is_some_and(PlanType::is_workspace_account) { - return true; - } if let Ok(response) = result { self.available_rate_limit_reset_credits = Some(response.available_count); self.set_rate_limit_reset_available_hint(response.available_count); @@ -324,8 +394,11 @@ impl ChatWidget { pub(crate) fn clear_pending_rate_limit_reset_requests(&mut self) { self.pending_rate_limit_reset_request_id = None; + self.pending_usage_menu_rate_limit_request_id = None; self.available_rate_limit_reset_credits = None; + self.rate_limit_snapshots_by_limit_id.clear(); self.clear_pending_rate_limit_reset_hint(); + self.bottom_pane.dismiss_view_by_id(USAGE_MENU_VIEW_ID); self.bottom_pane .dismiss_view_by_id(RATE_LIMIT_RESET_VIEW_ID); } @@ -375,8 +448,8 @@ impl ChatWidget { fn reset_label(count: i64) -> &'static str { if count == 1 { - "rate-limit reset" + "usage limit reset" } else { - "rate-limit resets" + "usage limit resets" } } diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 5e3e906bae..646380a2fa 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -103,7 +103,7 @@ impl SlashCommand { SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::Usage => "view account usage or use a rate-limit reset", + SlashCommand::Usage => "view account usage or use a usage limit reset", SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", From 15f448d8b06c25c9ad04b2abeb3e4e2f07e4c327 Mon Sep 17 00:00:00 2001 From: jgershen-oai Date: Mon, 22 Jun 2026 11:54:44 -0700 Subject: [PATCH 38/86] [codex] Start the guardian child session when parent session is started (#27982) ## Why The first auto-review currently creates its Guardian child session on demand, adding avoidable latency before the review can begin. Creating the ordinary Guardian child during parent-session initialization lets that child use the existing session startup WebSocket prewarm before the first escalation. This does not introduce a Guardian-specific prewarm mechanism. ## What changed - initialize the existing Guardian review-session manager owned by `Session` when a thread starts with auto-review enabled and an approval policy that routes to Guardian - use the standard Guardian child-session construction and the existing session startup WebSocket prewarm - preserve the existing reuse-key invalidation and lazy creation fallback when startup initialization fails or the effective review configuration changes - add an integration test that verifies normal root-session startup emits a Guardian `generate=false` prewarm request ## Benchmark I compared release builds against main. Each prompt first ran a non-escalated `sleep 3`, then requested an escalated marker command. | binary | count | avg Guardian duration | median Guardian duration | avg Guardian TTFT | |---|---:|---:|---:|---:| | origin-main | 10 | 4008.7 ms | 3949.5 ms | 3746.5 ms | | session-fix | 10 | 2865.0 ms | 2594.0 ms | 2492.7 ms | Guardian duration fell by 28.5% and Guardian TTFT fell by 33.5%. These measurements cover Guardian review latency; they do not measure parent thread-start latency. --- codex-rs/core/src/guardian/review.rs | 108 +++++++++++-------- codex-rs/core/src/guardian/review_session.rs | 62 +++++++++-- codex-rs/core/src/session/session.rs | 1 - codex-rs/core/src/session_startup_prewarm.rs | 15 ++- codex-rs/core/tests/suite/guardian_review.rs | 80 ++++++++++++++ 5 files changed, 210 insertions(+), 56 deletions(-) diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 92223083d8..4756cc1fc4 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -665,40 +665,23 @@ pub(crate) fn spawn_approval_request_review( rx } -/// Runs the guardian in a locked-down reusable review session. -/// -/// The guardian itself should not mutate state or trigger further approvals, so -/// it is pinned to a read-only sandbox with `approval_policy = never` and -/// nonessential agent features disabled. When the cached trunk session is idle, -/// later approvals append onto that same guardian conversation to preserve a -/// stable prompt-cache key. If the trunk is already busy, the review runs in an -/// ephemeral fork from the last committed trunk rollout so parallel approvals -/// do not block each other or mutate the cached thread. The trunk is recreated -/// when the effective review-session config changes, and any future compaction -/// must continue to preserve the guardian policy as exact top-level developer -/// context. It may still reuse the parent's managed-network allowlist for -/// read-only checks, but it intentionally runs without inherited exec-policy -/// rules. -async fn run_guardian_review_session_before_deadline( - session: Arc, - turn: Arc, - request: GuardianApprovalRequest, - retry_reason: Option, - schema: serde_json::Value, - external_cancel: Option, - deadline: Instant, -) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { +pub(super) struct GuardianReviewSessionConfig { + pub(super) spawn_config: crate::config::Config, + model: String, + reasoning_effort: Option, + default_review_model_id: String, + catalog_contains_auto_review: bool, + model_overridden: bool, + model_override: Option, +} + +pub(super) async fn guardian_review_session_config( + session: &Session, + turn: &TurnContext, +) -> anyhow::Result { let network_proxy = session.services.network_proxy.load_full(); let live_network_config = match network_proxy.as_ref() { - Some(network_proxy) => match network_proxy.proxy().current_cfg().await { - Ok(config) => Some(config), - Err(err) => { - return ( - GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), - GuardianReviewAnalyticsResult::without_session(), - ); - } - }, + Some(network_proxy) => Some(network_proxy.proxy().current_cfg().await?), None => None, }; let available_models = session @@ -750,14 +733,50 @@ async fn run_guardian_review_session_before_deadline( reasoning_effort, ) }; - let guardian_config = build_guardian_review_session_config( + + let spawn_config = build_guardian_review_session_config( turn.config.as_ref(), - live_network_config.clone(), + live_network_config, guardian_model.as_str(), guardian_reasoning_effort.clone(), - ); - let guardian_config = match guardian_config { - Ok(config) => config, + )?; + Ok(GuardianReviewSessionConfig { + spawn_config, + model: guardian_model, + reasoning_effort: guardian_reasoning_effort, + default_review_model_id: default_review_model_id.to_string(), + catalog_contains_auto_review: guardian_catalog_contains_auto_review, + model_overridden: guardian_review_model_overridden, + model_override: guardian_review_model_override, + }) +} + +/// Runs the guardian in a locked-down reusable review session. +/// +/// The guardian itself should not mutate state or trigger further approvals, so +/// it is pinned to a read-only sandbox with `approval_policy = never` and +/// nonessential agent features disabled. When the cached trunk session is idle, +/// later approvals append onto that same guardian conversation to preserve a +/// stable prompt-cache key. If the trunk is already busy, the review runs in an +/// ephemeral fork from the last committed trunk rollout so parallel approvals +/// do not block each other or mutate the cached thread. The trunk is recreated +/// when the effective review-session config changes, and any future compaction +/// must continue to preserve the guardian policy as exact top-level developer +/// context. It may still reuse the parent's managed-network allowlist for +/// read-only checks, but it intentionally runs without inherited exec-policy +/// rules. +async fn run_guardian_review_session_before_deadline( + session: Arc, + turn: Arc, + request: GuardianApprovalRequest, + retry_reason: Option, + schema: serde_json::Value, + external_cancel: Option, + deadline: Instant, +) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { + let session_config = match guardian_review_session_config(session.as_ref(), turn.as_ref()).await + { + Ok(session_config) => session_config, Err(err) => { return ( GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)), @@ -765,23 +784,22 @@ async fn run_guardian_review_session_before_deadline( ); } }; - let (session_outcome, session_analytics_result) = Box::pin( session .guardian_review_session .run_review(GuardianReviewSessionParams { parent_session: Arc::clone(&session), parent_turn: turn.clone(), - spawn_config: guardian_config, + spawn_config: session_config.spawn_config, request, retry_reason, schema, - model: guardian_model, - reasoning_effort: guardian_reasoning_effort, - guardian_default_review_model_id: default_review_model_id.to_string(), - guardian_catalog_contains_auto_review, - guardian_review_model_overridden, - guardian_review_model_override, + model: session_config.model, + reasoning_effort: session_config.reasoning_effort, + guardian_default_review_model_id: session_config.default_review_model_id, + guardian_catalog_contains_auto_review: session_config.catalog_contains_auto_review, + guardian_review_model_overridden: session_config.model_overridden, + guardian_review_model_override: session_config.model_override, reasoning_summary: turn.reasoning_summary, personality: turn.personality, external_cancel, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 7aa007891f..de56de41a6 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -27,6 +27,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; use codex_protocol::protocol::TokenUsage; +use futures::future::BoxFuture; use serde_json::Value; use tokio::sync::Mutex; use tokio::sync::Semaphore; @@ -56,6 +57,7 @@ use super::prompt::GuardianTranscriptCursor; use super::prompt::build_guardian_prompt_items_with_parent_turn; use super::prompt::guardian_policy_prompt; use super::prompt::guardian_policy_prompt_with_config; +use super::review::guardian_review_session_config; const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5); #[derive(Debug)] @@ -92,6 +94,7 @@ pub(crate) struct GuardianReviewSessionParams { #[derive(Default)] pub(crate) struct GuardianReviewSessionManager { state: Arc>, + cancellation_token: CancellationToken, } #[derive(Default)] @@ -289,6 +292,42 @@ impl Drop for EphemeralReviewCleanup { } impl GuardianReviewSessionManager { + pub(crate) fn initialize( + &self, + parent_session: Arc, + parent_turn: Arc, + ) -> BoxFuture<'_, anyhow::Result<()>> { + // Boxing breaks the Session::new -> Guardian -> Session::new future recursion. + Box::pin(async move { + let spawn_config = guardian_review_session_config(&parent_session, &parent_turn) + .await? + .spawn_config; + let reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( + &spawn_config, + parent_session.user_instructions().await, + ); + let spawn_cancel_token = self.cancellation_token.child_token(); + let spawn_cancel_guard = spawn_cancel_token.clone().drop_guard(); + let review_session = spawn_guardian_review_session( + &parent_session, + &parent_turn, + spawn_config, + reuse_key, + spawn_cancel_token.clone(), + /*fork_snapshot*/ None, + ) + .await?; + // A first review or shutdown may win while eager initialization is in flight; + // install only if neither has happened. + let mut state = self.state.lock().await; + if !spawn_cancel_token.is_cancelled() && state.trunk.is_none() { + state.trunk = Some(Arc::new(review_session)); + drop(spawn_cancel_guard.disarm()); + } + Ok(()) + }) + } + pub(crate) async fn trunk_rollout_path(&self) -> Option { let trunk = self.state.lock().await.trunk.clone()?; trunk.codex.session.ensure_rollout_materialized().await; @@ -302,6 +341,7 @@ impl GuardianReviewSessionManager { } pub(crate) async fn shutdown(&self) { + self.cancellation_token.cancel(); let (review_session, ephemeral_reviews) = { let mut state = self.state.lock().await; ( @@ -348,13 +388,14 @@ impl GuardianReviewSessionManager { } if state.trunk.is_none() { - let spawn_cancel_token = CancellationToken::new(); + let spawn_cancel_token = self.cancellation_token.child_token(); let review_session = match run_before_review_deadline_with_cancel( deadline, params.external_cancel.as_ref(), &spawn_cancel_token, Box::pin(spawn_guardian_review_session( - ¶ms, + ¶ms.parent_session, + ¶ms.parent_turn, params.spawn_config.clone(), next_reuse_key.clone(), spawn_cancel_token.clone(), @@ -556,7 +597,7 @@ impl GuardianReviewSessionManager { deadline: tokio::time::Instant, fork_snapshot: Option, ) -> (GuardianReviewSessionOutcome, GuardianReviewAnalyticsResult) { - let spawn_cancel_token = CancellationToken::new(); + let spawn_cancel_token = self.cancellation_token.child_token(); let mut fork_config = params.spawn_config.clone(); fork_config.ephemeral = true; let review_session = match run_before_review_deadline_with_cancel( @@ -564,7 +605,8 @@ impl GuardianReviewSessionManager { params.external_cancel.as_ref(), &spawn_cancel_token, Box::pin(spawn_guardian_review_session( - ¶ms, + ¶ms.parent_session, + ¶ms.parent_turn, fork_config, reuse_key, spawn_cancel_token.clone(), @@ -605,7 +647,8 @@ impl GuardianReviewSessionManager { } async fn spawn_guardian_review_session( - params: &GuardianReviewSessionParams, + parent_session: &Arc, + parent_turn: &Arc, spawn_config: Config, reuse_key: GuardianReviewSessionReuseKey, cancel_token: CancellationToken, @@ -621,10 +664,10 @@ async fn spawn_guardian_review_session( }; let codex = Box::pin(run_codex_thread_interactive( spawn_config, - params.parent_session.services.auth_manager.clone(), - params.parent_session.services.models_manager.clone(), - Arc::clone(¶ms.parent_session), - Arc::clone(¶ms.parent_turn), + parent_session.services.auth_manager.clone(), + parent_session.services.models_manager.clone(), + Arc::clone(parent_session), + Arc::clone(parent_turn), cancel_token.clone(), SubAgentSource::Other(GUARDIAN_REVIEWER_NAME.to_string()), initial_history, @@ -1546,6 +1589,7 @@ mod tests { trunk: Some(Arc::new(review_session)), ephemeral_reviews: Vec::new(), })), + ..Default::default() }; drop(tx_event); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 759d159cc2..b4740bea99 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1217,7 +1217,6 @@ impl Session { let mut state = sess.state.lock().await; state.queue_pending_session_start_source(session_start_source); } - Ok(sess) } .await; diff --git a/codex-rs/core/src/session_startup_prewarm.rs b/codex-rs/core/src/session_startup_prewarm.rs index 0474eb8704..270134981b 100644 --- a/codex-rs/core/src/session_startup_prewarm.rs +++ b/codex-rs/core/src/session_startup_prewarm.rs @@ -10,6 +10,7 @@ use tracing::instrument; use tracing::warn; use crate::client::ModelClientSession; +use crate::guardian::routes_approval_to_guardian; use crate::responses_metadata::CodexResponsesRequestKind; use crate::session::INITIAL_SUBMIT_ID; use crate::session::session::Session; @@ -242,6 +243,19 @@ async fn schedule_startup_prewarm_inner( prewarm_started_at.elapsed(), /*status*/ None, ); + if routes_approval_to_guardian(&startup_turn_context) { + let guardian_session = Arc::clone(&session); + let guardian_parent_turn = Arc::clone(&startup_turn_context); + drop(tokio::spawn(async move { + if let Err(err) = guardian_session + .guardian_review_session + .initialize(Arc::clone(&guardian_session), guardian_parent_turn) + .await + { + warn!("failed to initialize guardian review session: {err:#}"); + } + })); + } let startup_cancellation_token = CancellationToken::new(); let built_tools_started_at = Instant::now(); let startup_router = built_tools( @@ -295,6 +309,5 @@ async fn schedule_startup_prewarm_inner( websocket_warmup_started_at.elapsed(), /*status*/ None, ); - Ok(client_session) } diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index ae87350c39..c1da262033 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -17,6 +17,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::start_websocket_server; use core_test_support::skip_if_no_network; use core_test_support::skip_if_sandbox; use core_test_support::test_codex::local_selections; @@ -30,6 +31,85 @@ use std::os::unix::fs::PermissionsExt; use std::time::Duration; use tempfile::TempDir; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_session_prewarms_and_is_reused_for_first_review() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tool_args = json!({ + "cmd": "true", + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian approval routing.", + }) + .to_string(); + let server = start_websocket_server(vec![ + vec![vec![ev_response_created("warm-1"), ev_completed("warm-1")]], + vec![vec![ev_response_created("warm-2"), ev_completed("warm-2")]], + vec![vec![ + ev_response_created("approval-request"), + ev_function_call("approval-call", "exec_command", &tool_args), + ev_completed("approval-request"), + ]], + vec![vec![ + ev_response_created("guardian-review"), + ev_completed("guardian-review"), + ]], + ]) + .await; + let mut builder = test_codex().with_config(|config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.approvals_reviewer = ApprovalsReviewer::AutoReview; + }); + + let test = builder.build_with_websocket_server(&server).await?; + let (first, second) = tokio::time::timeout(Duration::from_secs(5), async { + tokio::join!( + server.wait_for_request(/*connection_index*/ 0, /*request_index*/ 0), + server.wait_for_request(/*connection_index*/ 1, /*request_index*/ 0) + ) + }) + .await?; + let prewarm_requests = [first.body_json(), second.body_json()]; + let guardian_prewarm = prewarm_requests + .iter() + .find(|request| { + request["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") + }) + .expect("guardian startup prewarm request"); + assert_eq!(guardian_prewarm["generate"].as_bool(), Some(false)); + let guardian_thread_id = guardian_prewarm["client_metadata"]["thread_id"] + .as_str() + .expect("guardian thread id"); + + test.codex + .submit( + vec![UserInput::Text { + text: "run a command that requires Guardian review".into(), + text_elements: Vec::new(), + }] + .into(), + ) + .await?; + let guardian_review = tokio::time::timeout( + Duration::from_secs(5), + server.wait_for_request(/*connection_index*/ 3, /*request_index*/ 0), + ) + .await? + .body_json(); + assert_eq!( + guardian_review["client_metadata"]["x-openai-subagent"].as_str(), + Some("guardian") + ); + assert_eq!( + guardian_review["client_metadata"]["thread_id"].as_str(), + Some(guardian_thread_id) + ); + assert_eq!(guardian_review.get("generate"), None); + + test.codex.shutdown_and_wait().await?; + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn guardian_review_session_does_not_inherit_legacy_notify() -> Result<()> { skip_if_no_network!(Ok(())); From e48ab86693c56ae500ff1e3ca08ec71272628170 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 22 Jun 2026 12:31:52 -0700 Subject: [PATCH 39/86] core: remove unused permissions cwd plumbing (#29468) ## Why `compile_scoped_filesystem_pattern()` accepted a `_policy_cwd` parameter even though scoped glob compilation no longer uses the policy working directory. Keeping that unused argument forced the surrounding permissions compilation path to keep forwarding `policy_cwd` through call sites that did not need it, making the API look more dependent on cwd resolution than it is. ## What changed Removed the unused cwd parameter from `compile_scoped_filesystem_pattern()` and the callers that only forwarded it: `compile_filesystem_permission()`, `compile_permission_profile()`, and `compile_permission_profile_selection()`. Workspace root resolution still keeps `policy_cwd`, because that path still resolves relative roots against the active policy cwd. Relevant code: [`codex-rs/core/src/config/permissions.rs`](https://github.com/openai/codex/blob/b8b9816102e064dae4488ec130cf560f63c1ab78/codex-rs/core/src/config/permissions.rs#L346). ## Verification - `just test -p codex-core config::permissions` - `just test -p codex-core` was also run after building `test_stdio_server`; it passed the touched permissions coverage but still reported unrelated existing failures in `cli_stream` and shell snapshot tests. --- codex-rs/core/src/config/mod.rs | 1 - codex-rs/core/src/config/permissions.rs | 11 ++--------- codex-rs/core/src/config/permissions_tests.rs | 2 -- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index dba933b111..17161e1a3c 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3207,7 +3207,6 @@ impl Config { effective_permission_selection.profiles.as_ref(), default_permissions, builtin_workspace_write_settings, - resolved_cwd.as_path(), &mut startup_warnings, )?; let mut configured_workspace_roots = compile_permission_profile_workspace_roots( diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index f683d9c7eb..9c6d3ed725 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -346,7 +346,6 @@ pub(crate) fn network_proxy_config_for_profile_selection( pub(crate) fn compile_permission_profile( permissions: &PermissionsToml, profile_name: &str, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { let profile = resolve_permission_profile(permissions, profile_name)?; @@ -383,7 +382,6 @@ pub(crate) fn compile_permission_profile( .extend(compile_filesystem_permission( path, permission, - policy_cwd, startup_warnings, )?); } @@ -412,7 +410,6 @@ pub(crate) fn compile_permission_profile_selection( permissions: Option<&PermissionsToml>, profile_name: &str, workspace_write: Option<&SandboxWorkspaceWrite>, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> { if let Some(permission_profile) = builtin_permission_profile(profile_name, workspace_write) { @@ -426,7 +423,7 @@ pub(crate) fn compile_permission_profile_selection( "default_permissions requires a `[permissions]` table", ) })?; - compile_permission_profile(permissions, profile_name, policy_cwd, startup_warnings) + compile_permission_profile(permissions, profile_name, startup_warnings) } pub(crate) fn compile_permission_profile_workspace_roots( @@ -524,7 +521,6 @@ fn compile_network_sandbox_policy( fn compile_filesystem_permission( path: &str, permission: &FilesystemPermissionToml, - policy_cwd: &Path, startup_warnings: &mut Vec, ) -> io::Result> { let mut entries = Vec::new(); @@ -548,9 +544,7 @@ fn compile_filesystem_permission( // exact-path parser so existing path semantics stay intact. let entry = FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { - pattern: compile_scoped_filesystem_pattern( - path, subpath, *access, policy_cwd, - )?, + pattern: compile_scoped_filesystem_pattern(path, subpath, *access)?, }, access: *access, }; @@ -643,7 +637,6 @@ fn compile_scoped_filesystem_pattern( path: &str, subpath: &str, access: FileSystemAccessMode, - _policy_cwd: &Path, ) -> io::Result { // Pattern entries currently mean deny-read only. Supporting broader access // modes here would imply glob-based read/write allow semantics that the diff --git a/codex-rs/core/src/config/permissions_tests.rs b/codex-rs/core/src/config/permissions_tests.rs index 88a757181e..2c1f649042 100644 --- a/codex-rs/core/src/config/permissions_tests.rs +++ b/codex-rs/core/src/config/permissions_tests.rs @@ -543,7 +543,6 @@ fn glob_scan_max_depth_must_be_positive() { #[test] fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> { - let cwd = TempDir::new()?; let mut startup_warnings = Vec::new(); let (file_system_policy, _) = compile_permission_profile( &PermissionsToml { @@ -568,7 +567,6 @@ fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> )]), }, "workspace", - cwd.path(), &mut startup_warnings, )?; From 1659c4a6291ae956f020c9c7354ff3cc3d050c48 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 13:03:11 -0700 Subject: [PATCH 40/86] PAC 2 - Add shared auth system proxy contract (#26707) ## Summary Stacked on #26706. Adds the shared auth/system-proxy contract that later platform resolver PRs plug into. This PR moves Codex-owned auth and startup HTTP clients through a common route-aware boundary, but does not yet add Windows or macOS system proxy resolution. The default path remains unchanged when `respect_system_proxy` is absent or disabled. ## Implementation - Adds `codex-client/src/outbound_proxy.rs` with the shared route-selection model: - `OutboundProxyConfig`; - `ClientRouteClass`; - `RouteFailureClass`; - `build_reqwest_client_for_route`. - Preserves the existing reqwest/default-client behavior when no route config is supplied. - Uses the fixed MVP routing policy when route config is supplied: platform system/PAC/WPAD discovery, then explicit env proxy variables, then direct connection. - Keeps platform-specific system discovery behind the shared client boundary. This PR provides the contract and fallback behavior; later resolver PRs plug in Windows and macOS discovery. - Adds `login::AuthRouteConfig` so auth call sites depend on a small policy type instead of platform resolver details. - Maps the resolved `Config.respect_system_proxy` boolean into `AuthRouteConfig` for auth-owned clients. - Wires the route config through browser login, device-code login, access-token login, login status, logout/revoke, token refresh, API-key exchange, app-server account login, TUI/app startup, cloud-config bootstrap, cloud tasks, plugin auth, and exec startup config loading. ## End-user behavior - No behavior changes by default. - When `respect_system_proxy = true`, auth-owned clients opt into the shared route-aware client path. - On platforms without a resolver implementation in this PR, system discovery is unavailable and the route-aware path falls back to explicit env proxy handling, then direct connection. - Custom CA handling remains separate from proxy route selection and still runs through the shared client builder. - No proxy URLs, PAC contents, or resolved platform details are exposed through the public config surface introduced here. ## Tests Adds or updates coverage for: - preserving default auth-client fallback behavior when no route config is provided; - injected environment-proxy fallback without mutating process environment; - existing login-server E2E flows using explicit `auth_route_config: None` to guard unchanged default behavior; - updated auth manager, login, logout, cloud-config, startup, and plugin-auth call sites passing route config explicitly. --- .../src/transport/remote_control/tests.rs | 3 + .../remote_control/tests/clients_tests.rs | 2 + .../remote_control/tests/pairing_tests.rs | 2 + .../src/transport/remote_control/websocket.rs | 3 + .../request_processors/account_processor.rs | 1 + codex-rs/cli/src/login.rs | 21 +- codex-rs/cli/src/main.rs | 10 +- codex-rs/cli/src/plugin_cmd.rs | 2 + codex-rs/cloud-config/src/bundle_loader.rs | 3 + codex-rs/cloud-config/src/service_tests.rs | 7 + codex-rs/cloud-tasks/src/util.rs | 1 + codex-rs/codex-client/src/lib.rs | 6 + codex-rs/codex-client/src/outbound_proxy.rs | 322 ++++++++++++++++++ .../codex-client/src/outbound_proxy_tests.rs | 133 ++++++++ codex-rs/core/src/config/mod.rs | 19 ++ codex-rs/core/tests/suite/client.rs | 1 + codex-rs/exec/src/lib.rs | 12 + codex-rs/login/src/auth/agent_identity.rs | 43 ++- codex-rs/login/src/auth/auth_tests.rs | 45 +++ .../login/src/auth/bedrock_api_key_tests.rs | 3 + codex-rs/login/src/auth/default_client.rs | 61 +++- codex-rs/login/src/auth/manager.rs | 94 ++++- .../login/src/auth/personal_access_token.rs | 21 +- .../src/auth/personal_access_token_tests.rs | 7 +- codex-rs/login/src/auth/revoke.rs | 6 +- codex-rs/login/src/device_code_auth.rs | 9 +- codex-rs/login/src/lib.rs | 2 + codex-rs/login/src/outbound_proxy.rs | 22 ++ codex-rs/login/src/server.rs | 36 +- codex-rs/login/tests/suite/auth_refresh.rs | 1 + .../login/tests/suite/device_code_login.rs | 3 + .../login/tests/suite/login_server_e2e.rs | 9 + codex-rs/login/tests/suite/logout.rs | 4 + codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/tui/src/lib.rs | 13 + codex-rs/tui/src/onboarding/auth.rs | 1 + codex-rs/tui/src/session_archive_commands.rs | 10 + 37 files changed, 885 insertions(+), 54 deletions(-) create mode 100644 codex-rs/codex-client/src/outbound_proxy.rs create mode 100644 codex-rs/codex-client/src/outbound_proxy_tests.rs create mode 100644 codex-rs/login/src/outbound_proxy.rs diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 6c453e6f9d..44299c4b57 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -1041,6 +1041,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let (transport_event_tx, _transport_event_rx) = @@ -1870,6 +1871,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let expected_server_name = gethostname().to_string_lossy().trim().to_string(); @@ -1966,6 +1968,7 @@ async fn persisted_enable_does_not_follow_auth_to_an_account_without_a_preferenc /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_control_target = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs index 6354e435c9..21ec02ab06 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/clients_tests.rs @@ -185,6 +185,7 @@ async fn list_remote_control_clients_recovers_auth_after_unauthorized() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -270,6 +271,7 @@ async fn list_remote_control_clients_retries_unauthorized_only_once() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs index 190526d720..380f13984e 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests/pairing_tests.rs @@ -539,6 +539,7 @@ async fn remote_control_handle_recovers_auth_before_refreshing_pairing() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut fresh_auth = remote_control_auth_dot_json(Some("account_id")); @@ -808,6 +809,7 @@ async fn remote_control_handle_discards_pairing_response_after_auth_change() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let remote_handle = diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index 239d5d9ea7..b9f1c71752 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -2214,6 +2214,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2312,6 +2313,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); @@ -2435,6 +2437,7 @@ mod tests { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let mut auth_recovery = auth_manager.unauthorized_recovery(); diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 4c8538e2b1..6aa624153d 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -359,6 +359,7 @@ impl AccountRequestProcessor { config.forced_chatgpt_workspace_id.clone(), config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) }; #[cfg(debug_assertions)] diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index dc3d0af87d..5691c3cd1f 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -11,6 +11,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_core::config::Config; use codex_login::AuthKeyringBackendKind; +use codex_login::AuthRouteConfig; use codex_login::CLIENT_ID; use codex_login::CodexAuth; use codex_login::ServerOptions; @@ -119,11 +120,13 @@ async fn clear_existing_auth_before_login( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) { if let Err(err) = logout_with_revoke( codex_home, auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ) .await { @@ -136,11 +139,13 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> std::io::Result<()> { clear_existing_auth_before_login( &codex_home, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config.as_ref(), ) .await; @@ -150,6 +155,7 @@ pub async fn login_with_chatgpt( forced_chatgpt_workspace_id, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, ); let server = run_login_server(opts)?; @@ -169,12 +175,12 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); - match login_with_chatgpt( config.codex_home.to_path_buf(), forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await { @@ -232,6 +238,7 @@ pub async fn run_login_with_access_token( std::process::exit(1); } + let auth_route_config = config.auth_route_config(); match login_with_access_token( &config.codex_home, &access_token, @@ -239,6 +246,7 @@ pub async fn run_login_with_access_token( config.forced_chatgpt_workspace_id.as_deref(), Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -307,10 +315,12 @@ pub async fn run_login_with_device_code( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); @@ -320,6 +330,7 @@ pub async fn run_login_with_device_code( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -352,10 +363,12 @@ pub async fn run_login_with_device_code_fallback_to_browser( eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } + let auth_route_config = config.auth_route_config(); clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await; @@ -366,6 +379,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( forced_chatgpt_workspace_id, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config, ); if let Some(iss) = issuer_base_url { opts.issuer = iss; @@ -409,12 +423,14 @@ pub async fn run_login_with_device_code_fallback_to_browser( pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_route_config = config.auth_route_config(); match CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -459,11 +475,13 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { pub async fn run_logout(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; + let auth_route_config = config.auth_route_config(); match logout_with_revoke( &config.codex_home, config.cli_auth_credentials_store_mode, config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await { @@ -536,6 +554,7 @@ mod tests { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 9d278c2122..8fd1358efd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1738,9 +1738,13 @@ async fn load_exec_server_remote_auth_provider( let agent_identity_jwt = read_codex_access_token_from_env().ok_or_else(|| { anyhow::anyhow!("CODEX_ACCESS_TOKEN is required when --use-agent-identity-auth is set") })?; - let auth = - CodexAuth::from_agent_identity_jwt(&agent_identity_jwt, Some(&config.chatgpt_base_url)) - .await?; + let auth_route_config = config.auth_route_config(); + let auth = CodexAuth::from_agent_identity_jwt( + &agent_identity_jwt, + Some(&config.chatgpt_base_url), + auth_route_config.as_ref(), + ) + .await?; return Ok(codex_model_provider::auth_provider_from_auth(&auth)); } diff --git a/codex-rs/cli/src/plugin_cmd.rs b/codex-rs/cli/src/plugin_cmd.rs index 1fc670c871..433714a00c 100644 --- a/codex-rs/cli/src/plugin_cmd.rs +++ b/codex-rs/cli/src/plugin_cmd.rs @@ -566,11 +566,13 @@ pub(crate) async fn load_cli_auth_mode(config: &Config) -> Option { return Some(CodexAuth::from_api_key(&api_key).api_auth_mode()); } + let auth_route_config = config.auth_route_config(); CodexAuth::from_auth_storage( &config.codex_home, config.cli_auth_credentials_store_mode, Some(&config.chatgpt_base_url), config.auth_keyring_backend_kind(), + auth_route_config.as_ref(), ) .await .ok() diff --git a/codex-rs/cloud-config/src/bundle_loader.rs b/codex-rs/cloud-config/src/bundle_loader.rs index 5ec1ab260f..e4266c6195 100644 --- a/codex-rs/cloud-config/src/bundle_loader.rs +++ b/codex-rs/cloud-config/src/bundle_loader.rs @@ -7,6 +7,7 @@ use codex_config::CloudConfigBundleLoader; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; +use codex_login::AuthRouteConfig; use std::path::PathBuf; use std::sync::Arc; use std::sync::Mutex; @@ -58,6 +59,7 @@ pub async fn cloud_config_bundle_loader_for_storage( credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, chatgpt_base_url: String, + auth_route_config: Option, ) -> CloudConfigBundleLoader { let auth_manager = AuthManager::shared( codex_home.clone(), @@ -66,6 +68,7 @@ pub async fn cloud_config_bundle_loader_for_storage( /*forced_chatgpt_workspace_id*/ None, Some(chatgpt_base_url.clone()), keyring_backend_kind, + auth_route_config, ) .await; cloud_config_bundle_loader(auth_manager, chatgpt_base_url, codex_home) diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 96fed2f589..47b49d43bb 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -53,6 +53,7 @@ async fn auth_manager_with_api_key() -> Arc { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -83,6 +84,7 @@ async fn auth_manager_with_plan_and_identity( /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ) @@ -108,6 +110,7 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc { task_id: Some("task-123".to_string()), }, "https://auth.openai.com/api/accounts", + /*auth_route_config*/ None, ) .await .expect("agent identity record should be complete"), @@ -686,6 +689,7 @@ async fn get_bundle_recovers_after_unauthorized_reload() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -742,6 +746,7 @@ async fn get_bundle_recovers_after_unauthorized_reload_updates_cache_identity() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -806,6 +811,7 @@ async fn get_bundle_surfaces_auth_recovery_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -872,6 +878,7 @@ async fn get_bundle_unauthorized_without_recovery_uses_generic_message() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 65db955f15..2a836f8579 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -52,6 +52,7 @@ pub async fn load_auth_manager(chatgpt_base_url: Option) -> Option) -> fmt::Result { + f.write_str(match self { + Self::Auth => "auth", + Self::Api => "api", + Self::WebSocket => "wss", + Self::Other => "other", + }) + } +} + +/// Coarse failure class for route selection errors. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteFailureClass { + ProxyResolutionUnavailable, + ConnectTimeout, + ProxyAuthenticationRequired, + TlsError, + InvalidProxyConfig, + UnsupportedProxyScheme, + ResolverError, +} + +impl fmt::Display for RouteFailureClass { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::ProxyResolutionUnavailable => "proxy_resolution_unavailable", + Self::ConnectTimeout => "connect_timeout", + Self::ProxyAuthenticationRequired => "proxy_407", + Self::TlsError => "tls_error", + Self::InvalidProxyConfig => "invalid_proxy_config", + Self::UnsupportedProxyScheme => "unsupported_proxy_scheme", + Self::ResolverError => "resolver_error", + }) + } +} + +/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing. +/// Resolved endpoints and platform details remain internal to the client builder. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OutboundProxyConfig; + +impl OutboundProxyConfig { + pub const fn respect_system_proxy() -> Self { + Self + } +} + +/// Error while building a resolver-aware reqwest client. +#[derive(Debug, Error)] +pub enum BuildRouteAwareHttpClientError { + #[error(transparent)] + CustomCa(#[from] BuildCustomCaTransportError), + + #[error("Failed to configure outbound proxy selected for {route_class}")] + InvalidProxyConfig { route_class: ClientRouteClass }, +} + +impl From for io::Error { + fn from(error: BuildRouteAwareHttpClientError) -> Self { + match error { + BuildRouteAwareHttpClientError::CustomCa(error) => error.into(), + BuildRouteAwareHttpClientError::InvalidProxyConfig { .. } => io::Error::other(error), + } + } +} + +/// Builds a reqwest client with conservative route selection and shared CA handling. +/// +/// Unavailable platform resolution falls back to environment proxies and then direct. Errors after +/// a route is selected are returned without trying another route. +pub fn build_reqwest_client_for_route( + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + let builder = + configure_proxy_for_route(&ProcessEnv, builder, request_url, route_class, config)?; + build_reqwest_client_with_custom_ca(builder).map_err(Into::into) +} + +fn configure_proxy_for_route( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + config: Option<&OutboundProxyConfig>, +) -> Result { + if config.is_none() { + return Ok(builder); + } + let origin = RequestOrigin::parse(request_url); + + let Some(origin) = origin.as_ref() else { + return configure_env_proxy_handling(env, builder, /*origin*/ None, route_class); + }; + + match resolve_system_proxy(request_url, origin) { + SystemProxyDecision::Direct => Ok(builder.no_proxy()), + SystemProxyDecision::Proxy { url } => { + configure_concrete_proxy(builder, route_class, &url, /*no_proxy*/ None) + } + SystemProxyDecision::Unavailable { .. } => { + configure_env_proxy_handling(env, builder, Some(origin), route_class) + } + } +} + +fn configure_concrete_proxy( + builder: reqwest::ClientBuilder, + route_class: ClientRouteClass, + proxy_url: &str, + no_proxy: Option, +) -> Result { + let proxy = match reqwest::Proxy::all(proxy_url) { + Ok(proxy) => proxy, + Err(_source) => { + return Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { route_class }); + } + }; + Ok(builder.proxy(proxy.no_proxy(no_proxy))) +} + +fn configure_env_proxy_handling( + env: &dyn EnvSource, + builder: reqwest::ClientBuilder, + origin: Option<&RequestOrigin>, + route_class: ClientRouteClass, +) -> Result { + if let Some(origin) = origin { + let proxy_url = match origin.scheme.as_str() { + "https" => { + proxy_env_value(env, "HTTPS_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + "http" => { + proxy_env_value(env, "HTTP_PROXY").or_else(|| proxy_env_value(env, "ALL_PROXY")) + } + _ => proxy_env_value(env, "ALL_PROXY"), + }; + if let Some(proxy_url) = proxy_url { + let no_proxy = proxy_env_value(env, "NO_PROXY") + .and_then(|value| reqwest::NoProxy::from_string(&value)); + return configure_concrete_proxy(builder, route_class, &proxy_url, no_proxy); + } + } + Ok(builder.no_proxy()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow(dead_code)] +struct RequestOrigin { + scheme: String, + host: String, + port: u16, +} + +impl RequestOrigin { + fn parse(request_url: &str) -> Option { + let uri = request_url.parse::().ok()?; + let scheme = uri.scheme_str()?.to_ascii_lowercase(); + let host = uri.host()?.trim_matches(['[', ']']).to_ascii_lowercase(); + let port = uri.port_u16().or(match scheme.as_str() { + "http" => Some(80), + "https" => Some(443), + _ => None, + })?; + Some(Self { scheme, host, port }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[allow( + dead_code, + reason = "Direct and Proxy are constructed by platform resolvers added in later PRs" +)] +enum SystemProxyDecision { + Direct, + Proxy { url: String }, + Unavailable { failure: RouteFailureClass }, +} + +fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + if let Some(decision) = cached_system_proxy_decision(request_url) { + return decision; + } + + let decision = resolve_platform_system_proxy(request_url, origin); + cache_system_proxy_decision(request_url, decision.clone()); + decision +} + +fn resolve_platform_system_proxy( + _request_url: &str, + _origin: &RequestOrigin, +) -> SystemProxyDecision { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } +} + +#[derive(Debug, Clone)] +struct CachedSystemProxyDecision { + decision: SystemProxyDecision, + expires_at: Instant, +} + +static SYSTEM_PROXY_CACHE: OnceLock>> = + OnceLock::new(); + +fn cached_system_proxy_decision(request_url: &str) -> Option { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let mut cache = cache.lock().ok()?; + let cached = cache.get(request_url)?; + if cached.expires_at > Instant::now() { + return Some(cached.decision.clone()); + } + cache.remove(request_url); + None +} + +fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) { + let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(mut cache) = cache.lock() { + insert_system_proxy_cache_entry(&mut cache, request_url, decision, Instant::now()); + } +} + +fn insert_system_proxy_cache_entry( + cache: &mut HashMap, + request_url: &str, + decision: SystemProxyDecision, + now: Instant, +) { + let ttl = match &decision { + SystemProxyDecision::Direct | SystemProxyDecision::Proxy { .. } => { + SYSTEM_PROXY_SUCCESS_CACHE_TTL + } + SystemProxyDecision::Unavailable { .. } => SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL, + }; + + cache.retain(|_, cached| cached.expires_at > now); + if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES + && !cache.contains_key(request_url) + && let Some(request_url_to_evict) = cache + .iter() + .min_by_key(|(_, cached)| cached.expires_at) + .map(|(request_url, _)| request_url.clone()) + { + cache.remove(&request_url_to_evict); + } + cache.insert( + request_url.to_string(), + CachedSystemProxyDecision { + decision, + expires_at: now + ttl, + }, + ); +} + +trait EnvSource { + fn var(&self, key: &str) -> Option; +} + +struct ProcessEnv; + +impl EnvSource for ProcessEnv { + fn var(&self, key: &str) -> Option { + std::env::var(key).ok() + } +} + +fn proxy_env_value(env: &dyn EnvSource, upper: &str) -> Option { + let lower = upper.to_ascii_lowercase(); + env.var(upper) + .or_else(|| env.var(&lower)) + .filter(|value| !value.is_empty()) +} + +#[cfg(test)] +#[path = "outbound_proxy_tests.rs"] +mod tests; diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs new file mode 100644 index 0000000000..9c713d28f1 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -0,0 +1,133 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::io::Read; +use std::io::Write; + +struct MapEnv { + values: HashMap, +} + +impl EnvSource for MapEnv { + fn var(&self, key: &str) -> Option { + self.values.get(key).cloned() + } +} + +#[test] +fn proxy_env_value_matches_reqwest_casing_precedence() { + let env = MapEnv { + values: HashMap::from([ + ("HTTPS_PROXY".to_string(), "upper".to_string()), + ("https_proxy".to_string(), "lower".to_string()), + ("http_proxy".to_string(), "lower-only".to_string()), + ("ALL_PROXY".to_string(), String::new()), + ("all_proxy".to_string(), "masked".to_string()), + ]), + }; + + assert_eq!( + proxy_env_value(&env, "HTTPS_PROXY"), + Some("upper".to_string()) + ); + assert_eq!( + proxy_env_value(&env, "HTTP_PROXY"), + Some("lower-only".to_string()) + ); + assert_eq!(proxy_env_value(&env, "ALL_PROXY"), None); +} + +#[test] +fn environment_fallback_reads_injected_proxy_environment() { + let env = MapEnv { + values: HashMap::from([("HTTPS_PROXY".to_string(), "://invalid".to_string())]), + }; + let origin = RequestOrigin::parse("https://auth.openai.com/oauth/token").expect("valid URL"); + let result = configure_env_proxy_handling( + &env, + reqwest::Client::builder(), + Some(&origin), + ClientRouteClass::Auth, + ); + + assert!(matches!( + result, + Err(BuildRouteAwareHttpClientError::InvalidProxyConfig { + route_class: ClientRouteClass::Auth, + }) + )); +} + +#[tokio::test] +async fn enabled_environment_proxy_routes_request_through_proxy() { + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).expect("local proxy listener should bind"); + let proxy_addr = listener + .local_addr() + .expect("local proxy listener should have an address"); + let proxy_thread = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("proxy should accept a request"); + let mut buffer = [0_u8; 4096]; + let size = stream.read(&mut buffer).expect("proxy should read request"); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .expect("proxy should write response"); + String::from_utf8_lossy(&buffer[..size]).into_owned() + }); + let env = MapEnv { + values: HashMap::from([("HTTP_PROXY".to_string(), format!("http://{proxy_addr}"))]), + }; + let request_url = "http://enabled-proxy.test/proxy-check"; + let config = OutboundProxyConfig::respect_system_proxy(); + let builder = configure_proxy_for_route( + &env, + reqwest::Client::builder().timeout(Duration::from_secs(2)), + request_url, + ClientRouteClass::Auth, + Some(&config), + ) + .expect("enabled proxy route should configure"); + + let response = builder + .build() + .expect("proxy client should build") + .get(request_url) + .send() + .await + .expect("request should use local proxy"); + let proxy_request = proxy_thread.join().expect("proxy thread should finish"); + + assert_eq!(response.status(), reqwest::StatusCode::OK); + assert_eq!( + proxy_request.lines().next(), + Some("GET http://enabled-proxy.test/proxy-check HTTP/1.1") + ); +} + +#[test] +fn unavailable_system_proxy_decision_is_cached() { + let request_url = "https://unavailable-cache.test/oauth/token"; + let decision = SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + + cache_system_proxy_decision(request_url, decision.clone()); + + assert_eq!(cached_system_proxy_decision(request_url), Some(decision)); +} + +#[test] +fn system_proxy_cache_is_bounded() { + let mut cache = HashMap::new(); + let now = Instant::now(); + + for index in 0..=SYSTEM_PROXY_CACHE_MAX_ENTRIES { + insert_system_proxy_cache_entry( + &mut cache, + &format!("https://bounded-cache.test/{index}"), + SystemProxyDecision::Direct, + now, + ); + } + + assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES); +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 17161e1a3c..19d4bb44c1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -72,6 +72,7 @@ use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; +use codex_login::AuthRouteConfig; use codex_mcp::McpConfig; use codex_mcp::McpPluginAttribution; use codex_mcp::McpServerRegistration; @@ -1201,6 +1202,10 @@ impl AuthManagerConfig for Config { fn chatgpt_base_url(&self) -> String { self.chatgpt_base_url.clone() } + + fn auth_route_config(&self) -> Option { + Config::auth_route_config(self) + } } #[derive(Clone, Default)] @@ -1454,6 +1459,11 @@ impl Config { } } + pub fn auth_route_config(&self) -> Option { + self.respect_system_proxy + .then(AuthRouteConfig::respect_system_proxy) + } + /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { PluginsConfigInput::new( @@ -2737,6 +2747,15 @@ pub fn resolve_bootstrap_respect_system_proxy( Ok(features.get().enabled(Feature::RespectSystemProxy)) } +/// Resolves auth route settings for the initial cloud-config bootstrap. +pub fn resolve_bootstrap_auth_route_config( + cfg: &ConfigToml, + feature_requirements: Option<&Sourced>, +) -> std::io::Result> { + resolve_bootstrap_respect_system_proxy(cfg, feature_requirements) + .map(|enabled| enabled.then(AuthRouteConfig::respect_system_proxy)) +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cbd014c3f7..d0e68c8a95 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1385,6 +1385,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("Failed to load CodexAuth") diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index a04e258552..08c905c001 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -66,6 +66,7 @@ use codex_core::config::ConfigTomlLoadResult; use codex_core::config::find_codex_home; use codex_core::config::load_config_toml_with_layer_stack; use codex_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use codex_core::config::resolve_bootstrap_auth_route_config; use codex_core::config::resolve_oss_provider; use codex_core::config::resolve_profile_v2_config_path; use codex_core::find_thread_meta_by_name_str; @@ -349,6 +350,14 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -357,6 +366,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; let run_cli_overrides = cli_kv_overrides.clone(); @@ -468,6 +478,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); + let auth_route_config = config.auth_route_config(); if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, @@ -475,6 +486,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + auth_route_config, }) .await { diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index 370af883c0..a164c7e3a5 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -3,6 +3,9 @@ use std::sync::Arc; use codex_agent_identity::AgentIdentityKey; use codex_agent_identity::ChatGptEnvironment; +use codex_agent_identity::agent_identity_jwks_url; +use codex_agent_identity::agent_registration_url; +use codex_agent_identity::agent_task_registration_url; use codex_agent_identity::build_abom; use codex_agent_identity::decode_agent_identity_jwt; use codex_agent_identity::fetch_agent_identity_jwks; @@ -15,7 +18,8 @@ use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::protocol::SessionSource; use thiserror::Error; -use crate::default_client::build_reqwest_client; +use crate::default_client::build_default_auth_reqwest_client; +use crate::outbound_proxy::AuthRouteConfig; use super::storage::AgentIdentityAuthRecord; @@ -96,13 +100,18 @@ impl AgentIdentityAuth { pub async fn from_record( mut record: AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { public_key_ssh_from_private_key_pkcs8_base64(&record.agent_private_key) .map_err(std::io::Error::other)?; if record_needs_task_registration(&record) { record.task_id = Some( - register_task_for_record_with_retries(&record, agent_identity_authapi_base_url) - .await?, + register_task_for_record_with_retries( + &record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, ); } Ok(Self { @@ -114,9 +123,10 @@ impl AgentIdentityAuth { jwt: &str, chatgpt_base_url: &str, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { - let record = verified_record_from_jwt(jwt, chatgpt_base_url).await?; - Self::from_record(record, agent_identity_authapi_base_url).await + let record = verified_record_from_jwt(jwt, chatgpt_base_url, auth_route_config).await?; + Self::from_record(record, agent_identity_authapi_base_url, auth_route_config).await } #[cfg(test)] @@ -163,9 +173,11 @@ pub(super) async fn register_managed_chatgpt_agent_identity( binding: ManagedChatGptAgentIdentityBinding, agent_identity_authapi_base_url: &str, session_source: SessionSource, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let key_material = generate_agent_key_material().map_err(std::io::Error::other)?; - let client = build_reqwest_client(); + let registration_url = agent_registration_url(agent_identity_authapi_base_url); + let client = build_default_auth_reqwest_client(®istration_url, auth_route_config)?; let runtime_id = retry_registration(|| async { register_agent_identity( &client, @@ -200,7 +212,7 @@ pub(super) async fn register_managed_chatgpt_agent_identity( chatgpt_account_is_fedramp: binding.chatgpt_account_is_fedramp, task_id: None, }; - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) + AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url, auth_route_config) .await .map_err(|err| classify_bootstrap_error("agent task registration", err)) } @@ -208,9 +220,12 @@ pub(super) async fn register_managed_chatgpt_agent_identity( pub(super) async fn verified_record_from_jwt( jwt: &str, chatgpt_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { AgentIdentityAuthRecord::from_agent_identity_jwt(jwt)?; - let jwks = fetch_agent_identity_jwks(&build_reqwest_client(), chatgpt_base_url) + let jwks_url = agent_identity_jwks_url(chatgpt_base_url); + let client = build_default_auth_reqwest_client(&jwks_url, auth_route_config)?; + let jwks = fetch_agent_identity_jwks(&client, chatgpt_base_url) .await .map_err(std::io::Error::other)?; let claims = decode_agent_identity_jwt(jwt, Some(&jwks)).map_err(std::io::Error::other)?; @@ -285,19 +300,24 @@ where async fn register_task_for_record_with_retries( record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { + let task_registration_url = + agent_task_registration_url(agent_identity_authapi_base_url, &record.agent_runtime_id); + let client = build_default_auth_reqwest_client(&task_registration_url, auth_route_config)?; retry_registration(|| async { - register_task_for_record(record, agent_identity_authapi_base_url).await + register_task_for_record(&client, record, agent_identity_authapi_base_url).await }) .await } async fn register_task_for_record( + client: &reqwest::Client, record: &AgentIdentityAuthRecord, agent_identity_authapi_base_url: &str, ) -> std::io::Result { register_agent_task( - &build_reqwest_client(), + client, agent_identity_authapi_base_url, key_for_record(record), ) @@ -370,6 +390,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -414,6 +435,7 @@ mod tests { &jwt, &format!("{}/backend-api", server.uri()), &server.uri(), + /*auth_route_config*/ None, ) .await?; @@ -456,6 +478,7 @@ mod tests { let auth = AgentIdentityAuth::from_record( agent_identity_record_with_generated_key(), &server.uri(), + /*auth_route_config*/ None, ) .await?; diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index b52f03b706..fbb6aac8ea 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -122,6 +122,7 @@ async fn login_with_access_token_writes_agent_identity_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("login_with_access_token should succeed"); @@ -181,6 +182,7 @@ async fn stored_agent_identity_jwt_keeps_auth_json_unchanged() -> anyhow::Result Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -226,6 +228,7 @@ async fn login_with_access_token_writes_only_personal_access_token() { Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -278,6 +281,7 @@ async fn login_with_access_token_rejects_personal_access_token_workspace_mismatc Some(&allowed_workspaces), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("personal access token workspace mismatch should fail"); @@ -310,6 +314,7 @@ async fn login_with_access_token_rejects_invalid_personal_access_token() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid personal access token should fail"); @@ -333,6 +338,7 @@ async fn login_with_access_token_rejects_invalid_jwt() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("invalid access token should fail"); @@ -364,6 +370,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -373,6 +380,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::JwtOnly, /*agent_identity_authapi_base_url*/ None, /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -403,6 +411,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -412,6 +421,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -442,6 +452,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should reload"); @@ -450,6 +461,7 @@ async fn chatgpt_auth_registers_agent_identity_when_enabled() -> anyhow::Result< AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -482,6 +494,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -510,6 +523,7 @@ async fn chatgpt_auth_retries_transient_agent_identity_registration() -> anyhow: AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await? @@ -546,6 +560,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -563,6 +578,7 @@ async fn chatgpt_auth_registration_retry_exhaustion_is_fallback_eligible() -> an AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -605,6 +621,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -625,6 +642,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -659,6 +677,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await? .expect("auth should load"); @@ -676,6 +695,7 @@ async fn chatgpt_auth_non_retryable_registration_error_is_hard_failure() -> anyh AgentIdentityAuthPolicy::ChatGptAuth, Some(&server.uri()), /*forced_chatgpt_workspace_id*/ None, + /*auth_route_config*/ None, SessionSource::Cli, ) .await @@ -717,6 +737,7 @@ async fn agent_identity_jwt_task_registration_retry_exhaustion_is_strict() -> an &agent_identity, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect_err("agent identity jwt task retry exhaustion should fail"); @@ -747,6 +768,7 @@ async fn login_with_access_token_rejects_unsigned_jwt() { /*forced_chatgpt_workspace_id*/ None, Some(&chatgpt_base_url), AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect_err("unsigned access token should fail"); @@ -768,6 +790,7 @@ async fn missing_auth_json_returns_none() { AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("call should succeed"); @@ -797,6 +820,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -858,6 +882,7 @@ async fn loads_api_key_from_auth_json() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .unwrap() @@ -908,6 +933,7 @@ async fn unauthorized_recovery_reports_mode_and_step_names() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; let managed = UnauthorizedRecovery { @@ -952,6 +978,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -972,6 +999,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("updated auth should parse"); @@ -1277,6 +1305,7 @@ async fn build_config( forced_login_method, forced_chatgpt_workspace_id, chatgpt_base_url: None, + auth_route_config: None, } } @@ -1359,6 +1388,7 @@ async fn load_auth_reads_access_token_from_env() { Some(&chatgpt_base_url), AuthKeyringBackendKind::Direct, Some(&authapi_base_url), + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1406,6 +1436,7 @@ async fn load_auth_reads_personal_access_token_from_env() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1459,6 +1490,7 @@ async fn auth_manager_rejects_env_personal_access_token_workspace_mismatch() { Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1498,6 +1530,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1509,6 +1542,7 @@ async fn auth_manager_rejects_stored_personal_access_token_workspace_mismatch() Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -1542,6 +1576,7 @@ async fn personal_access_token_does_not_offer_unauthorized_recovery() { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await, ); @@ -1574,6 +1609,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("env auth should load") @@ -1670,6 +1706,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("personal access token login should succeed"); @@ -1681,6 +1718,7 @@ async fn enforce_login_restrictions_logs_out_for_personal_access_token_workspace forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: None, + auth_route_config: None, }; let err = super::enforce_login_restrictions(&config) @@ -1804,6 +1842,7 @@ async fn enforce_login_restrictions_logs_out_for_agent_identity_workspace_mismat forced_login_method: None, forced_chatgpt_workspace_id: Some(vec![WORKSPACE_ID_ALLOWED.to_string()]), chatgpt_base_url: Some(chatgpt_base_url), + auth_route_config: None, }; let err = super::enforce_login_restrictions_with_agent_identity_authapi_base_url( @@ -2056,6 +2095,7 @@ async fn assert_agent_identity_plan_alias( &jwt, Some(&chatgpt_base_url), &authapi_base_url, + /*auth_route_config*/ None, ) .await .expect("agent identity auth"); @@ -2087,6 +2127,7 @@ async fn plan_type_maps_known_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2118,6 +2159,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2152,6 +2194,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2186,6 +2229,7 @@ async fn plan_type_maps_unknown_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") @@ -2217,6 +2261,7 @@ async fn missing_plan_type_maps_to_unknown() { /*chatgpt_base_url*/ None, AuthKeyringBackendKind::Direct, /*agent_identity_authapi_base_url*/ None, + /*auth_route_config*/ None, ) .await .expect("load auth") diff --git a/codex-rs/login/src/auth/bedrock_api_key_tests.rs b/codex-rs/login/src/auth/bedrock_api_key_tests.rs index 691d8bbc5d..7db2672ce3 100644 --- a/codex-rs/login/src/auth/bedrock_api_key_tests.rs +++ b/codex-rs/login/src/auth/bedrock_api_key_tests.rs @@ -63,6 +63,7 @@ async fn login_with_bedrock_api_key_replaces_openai_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -111,6 +112,7 @@ async fn logout_removes_bedrock_auth() -> anyhow::Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; @@ -135,6 +137,7 @@ async fn bedrock_only_auth_storage_creates_primary_auth() -> anyhow::Result<()> /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index ee51edf9b5..f72727bfca 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -5,8 +5,11 @@ //! workspace. use codex_client::BuildCustomCaTransportError; +use codex_client::BuildRouteAwareHttpClientError; +use codex_client::ClientRouteClass; use codex_client::CodexHttpClient; pub use codex_client::CodexRequestBuilder; +use codex_client::build_reqwest_client_for_route; use codex_client::build_reqwest_client_with_custom_ca; use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; @@ -17,6 +20,8 @@ use std::sync::LazyLock; use std::sync::Mutex; use std::sync::RwLock; +use crate::outbound_proxy::AuthRouteConfig; + /// Set this to add a suffix to the User-Agent string. /// /// It is not ideal that we're using a global singleton for this. @@ -189,6 +194,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { } /// Create an HTTP client with default `originator` and `User-Agent` headers set. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. pub fn create_client() -> CodexHttpClient { let inner = build_reqwest_client(); CodexHttpClient::new(inner) @@ -200,6 +208,10 @@ pub fn create_client() -> CodexHttpClient { /// policy, then layers in shared custom CA handling from `CODEX_CA_CERTIFICATE` / /// `SSL_CERT_FILE`. The function remains infallible for compatibility with existing call sites, so /// a custom-CA or builder failure is logged and falls back to `reqwest::Client::new()`. +/// +/// This supported default path preserves reqwest's existing proxy behavior and does not opt into +/// Codex's route-aware system/PAC resolution. Auth callers with route settings must use +/// `build_default_auth_reqwest_client` or `create_default_auth_client`. pub fn build_reqwest_client() -> reqwest::Client { try_build_reqwest_client().unwrap_or_else(|error| { tracing::warn!(error = %error, "failed to build default reqwest client"); @@ -220,13 +232,58 @@ pub fn build_reqwest_client() -> reqwest::Client { /// Callers that need a structured CA-loading failure instead of the legacy logged fallback can use /// this method directly. pub fn try_build_reqwest_client() -> Result { + build_reqwest_client_with_custom_ca(default_reqwest_client_builder()) +} + +fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { builder = builder.no_proxy(); } - builder = with_chatgpt_cloudflare_cookie_store(builder); + with_chatgpt_cloudflare_cookie_store(builder) +} - build_reqwest_client_with_custom_ca(builder) +/// Builds a raw reqwest client for an auth endpoint without Codex default headers. +pub(crate) fn build_raw_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_reqwest_client_for_route( + reqwest::Client::builder(), + endpoint, + ClientRouteClass::Auth, + auth_route_config.map(AuthRouteConfig::route_config), + ) +} + +/// Builds the default Codex reqwest client for an auth endpoint. +pub(crate) fn build_default_auth_reqwest_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + let Some(route_config) = auth_route_config.map(AuthRouteConfig::route_config) else { + return Ok(build_reqwest_client()); + }; + + if is_sandboxed() { + // Preserve the sandbox's existing no-proxy policy; sandboxed command egress is routed + // separately through network-proxy. + return Ok(build_reqwest_client()); + } + build_reqwest_client_for_route( + default_reqwest_client_builder(), + endpoint, + ClientRouteClass::Auth, + Some(route_config), + ) +} + +/// Builds the default Codex HTTP client wrapper for an auth endpoint. +pub(crate) fn create_default_auth_client( + endpoint: &str, + auth_route_config: Option<&AuthRouteConfig>, +) -> Result { + build_default_auth_reqwest_client(endpoint, auth_route_config).map(CodexHttpClient::new) } pub fn default_headers() -> HeaderMap { diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 138bdad5b9..a762bd5466 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -49,6 +49,8 @@ use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; use crate::auth::util::try_parse_error_message; use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; use crate::token_data::parse_chatgpt_jwt_claims; use crate::token_data::parse_jwt_expiration; @@ -237,6 +239,7 @@ impl CodexAuth { chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_mode = auth_dot_json.resolved_mode(); if auth_mode == ApiAuthMode::ApiKey { @@ -263,14 +266,18 @@ impl CodexAuth { &jwt, &base_url, agent_identity_authapi_base_url, + auth_route_config, ) .await?; return Ok(Self::AgentIdentity(auth)); } AgentIdentityStorage::Record(record) => { - let auth = - AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?; return Ok(Self::AgentIdentity(auth)); } } @@ -281,7 +288,8 @@ impl CodexAuth { "personal access token auth is missing a personal access token.", )); }; - return Self::from_personal_access_token(personal_access_token).await; + return Self::from_personal_access_token(personal_access_token, auth_route_config) + .await; } if auth_mode == ApiAuthMode::BedrockApiKey { let Some(auth) = auth_dot_json.bedrock_api_key else { @@ -293,9 +301,10 @@ impl CodexAuth { } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); + let client = create_default_auth_client(&refresh_token_endpoint(), auth_route_config)?; let state = ChatgptAuthState { auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), - client: create_client(), + client, }; match auth_mode { @@ -324,6 +333,7 @@ impl CodexAuth { auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url).ok(); @@ -335,6 +345,7 @@ impl CodexAuth { chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config, ) .await } @@ -342,12 +353,14 @@ impl CodexAuth { pub async fn from_agent_identity_jwt( jwt: &str, chatgpt_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url)?; Self::from_agent_identity_jwt_with_authapi_base_url( jwt, chatgpt_base_url, &agent_identity_authapi_base_url, + auth_route_config, ) .await } @@ -356,19 +369,29 @@ impl CodexAuth { jwt: &str, chatgpt_base_url: Option<&str>, agent_identity_authapi_base_url: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let base_url = chatgpt_base_url .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); Ok(Self::AgentIdentity( - AgentIdentityAuth::from_jwt(jwt, &base_url, agent_identity_authapi_base_url).await?, + AgentIdentityAuth::from_jwt( + jwt, + &base_url, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await?, )) } - pub async fn from_personal_access_token(access_token: &str) -> std::io::Result { + pub async fn from_personal_access_token( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { Ok(Self::PersonalAccessToken( - PersonalAccessTokenAuth::load(access_token).await?, + PersonalAccessTokenAuth::load(access_token, auth_route_config).await?, )) } @@ -568,6 +591,7 @@ impl CodexAuth { policy: AgentIdentityAuthPolicy, agent_identity_authapi_base_url: Option<&str>, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result> { match self { @@ -583,6 +607,7 @@ impl CodexAuth { self.ensure_managed_chatgpt_agent_identity( require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, forced_chatgpt_workspace_id, + auth_route_config, session_source, ) .await @@ -595,6 +620,7 @@ impl CodexAuth { &self, agent_identity_authapi_base_url: &str, forced_chatgpt_workspace_id: Option>, + auth_route_config: Option<&AuthRouteConfig>, session_source: SessionSource, ) -> std::io::Result { let binding = @@ -607,9 +633,13 @@ impl CodexAuth { && record_matches_managed_chatgpt_binding(&record, &binding) { let should_persist = record_needs_task_registration(&record); - let auth = AgentIdentityAuth::from_record(record, agent_identity_authapi_base_url) - .await - .map_err(|err| classify_bootstrap_error("agent task registration", err))?; + let auth = AgentIdentityAuth::from_record( + record, + agent_identity_authapi_base_url, + auth_route_config, + ) + .await + .map_err(|err| classify_bootstrap_error("agent task registration", err))?; if should_persist { self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; } @@ -620,6 +650,7 @@ impl CodexAuth { binding, agent_identity_authapi_base_url, session_source, + auth_route_config, ) .await?; self.persist_managed_chatgpt_agent_identity_record(auth.record().clone())?; @@ -789,6 +820,7 @@ pub async fn logout_with_revoke( codex_home: &Path, auth_credentials_store_mode: AuthCredentialsStoreMode, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result { let auth_dot_json = match load_auth_dot_json( codex_home, @@ -801,7 +833,7 @@ pub async fn logout_with_revoke( None } }; - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref(), auth_route_config).await { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } logout_all_stores( @@ -843,10 +875,11 @@ pub async fn login_with_access_token( forced_chatgpt_workspace_id: Option<&[String]>, chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result<()> { let auth_dot_json = match classify_codex_access_token(access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; AuthDotJson { // Infer PAT auth from the credential field so older Codex builds can still @@ -865,7 +898,7 @@ pub async fn login_with_access_token( .unwrap_or(ChatGptEnvironment::default().chatgpt_base_url()) .trim_end_matches('/') .to_string(); - verified_record_from_jwt(jwt, &base_url).await?; + verified_record_from_jwt(jwt, &base_url, auth_route_config).await?; AuthDotJson { auth_mode: Some(ApiAuthMode::AgentIdentity), openai_api_key: None, @@ -954,8 +987,10 @@ pub struct AuthConfig { pub forced_login_method: Option, pub chatgpt_base_url: Option, pub forced_chatgpt_workspace_id: Option>, + pub auth_route_config: Option, } +/// Enforces configured login restrictions using auth-owned HTTP settings. pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(config.chatgpt_base_url.as_deref()).ok(); @@ -978,6 +1013,7 @@ async fn enforce_login_restrictions_with_agent_identity_authapi_base_url( config.chatgpt_base_url.as_deref(), config.keyring_backend_kind, agent_identity_authapi_base_url, + config.auth_route_config.as_ref(), ) .await? else { @@ -1113,6 +1149,7 @@ fn logout_all_stores( Ok(removed_ephemeral || removed_managed) } +#[allow(clippy::too_many_arguments)] async fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, @@ -1121,6 +1158,7 @@ async fn load_auth( chatgpt_base_url: Option<&str>, keyring_backend_kind: AuthKeyringBackendKind, agent_identity_authapi_base_url: Option<&str>, + auth_route_config: Option<&AuthRouteConfig>, ) -> std::io::Result> { // API key via env var takes precedence over any other auth method. if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { @@ -1142,6 +1180,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1153,7 +1192,7 @@ async fn load_auth( if let Some(access_token) = read_codex_access_token_from_env() { return match classify_codex_access_token(&access_token) { CodexAccessToken::PersonalAccessToken(access_token) => { - let auth = PersonalAccessTokenAuth::load(access_token).await?; + let auth = PersonalAccessTokenAuth::load(access_token, auth_route_config).await?; ensure_personal_access_token_workspace_allowed(forced_chatgpt_workspace_id, &auth)?; Ok(Some(CodexAuth::PersonalAccessToken(auth))) } @@ -1162,6 +1201,7 @@ async fn load_auth( jwt, chatgpt_base_url, require_agent_identity_authapi_base_url(agent_identity_authapi_base_url)?, + auth_route_config, ) } .await @@ -1192,6 +1232,7 @@ async fn load_auth( chatgpt_base_url, keyring_backend_kind, agent_identity_authapi_base_url, + auth_route_config, ) .await?; if let CodexAuth::PersonalAccessToken(auth) = &auth { @@ -1237,7 +1278,6 @@ async fn request_chatgpt_token_refresh( grant_type: "refresh_token", refresh_token, }; - let endpoint = refresh_token_endpoint(); // Use shared client factory to include standard headers @@ -1702,6 +1742,7 @@ pub struct AuthManager { refresh_lock: Semaphore, agent_identity_lock: Semaphore, external_auth: RwLock>>, + auth_route_config: Option, } /// Configuration view required to construct a shared [`AuthManager`]. @@ -1725,6 +1766,9 @@ pub trait AuthManagerConfig { /// Returns the ChatGPT backend base URL used for first-party backend authorization. fn chatgpt_base_url(&self) -> String; + + /// Returns route-selection settings for auth-owned clients. + fn auth_route_config(&self) -> Option; } impl Debug for AuthManager { @@ -1743,6 +1787,7 @@ impl Debug for AuthManager { &self.forced_chatgpt_workspace_id, ) .field("chatgpt_base_url", &self.chatgpt_base_url) + .field("auth_route_config", &self.auth_route_config) .field("has_external_auth", &self.has_external_auth()) .finish_non_exhaustive() } @@ -1764,6 +1809,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { let agent_identity_authapi_base_url = agent_identity_authapi_base_url(chatgpt_base_url.as_deref()).ok(); @@ -1775,6 +1821,7 @@ impl AuthManager { chatgpt_base_url.as_deref(), keyring_backend_kind, agent_identity_authapi_base_url.as_deref(), + auth_route_config.as_ref(), ) .await .ok() @@ -1796,6 +1843,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config, } } @@ -1820,6 +1868,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1843,6 +1892,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1874,6 +1924,7 @@ impl AuthManager { refresh_lock: Semaphore::new(/*permits*/ 1), agent_identity_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), + auth_route_config: None, }) } @@ -1897,6 +1948,7 @@ impl AuthManager { external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc )), + auth_route_config: None, }) } @@ -1958,6 +2010,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await; @@ -1966,6 +2019,7 @@ impl AuthManager { policy, self.agent_identity_authapi_base_url.as_deref(), self.forced_chatgpt_workspace_id(), + self.auth_route_config.as_ref(), session_source, ) .await @@ -2074,6 +2128,7 @@ impl AuthManager { self.chatgpt_base_url.as_deref(), self.keyring_backend_kind, self.agent_identity_authapi_base_url.as_deref(), + self.auth_route_config.as_ref(), ) .await .ok() @@ -2149,6 +2204,7 @@ impl AuthManager { forced_chatgpt_workspace_id: Option>, chatgpt_base_url: Option, keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Arc { Arc::new( Self::new( @@ -2158,6 +2214,7 @@ impl AuthManager { forced_chatgpt_workspace_id, chatgpt_base_url, keyring_backend_kind, + auth_route_config, ) .await, ) @@ -2175,6 +2232,7 @@ impl AuthManager { config.forced_chatgpt_workspace_id(), Some(config.chatgpt_base_url()), config.auth_keyring_backend_kind(), + config.auth_route_config(), ) .await } @@ -2328,7 +2386,9 @@ impl AuthManager { let auth_dot_json = self .auth_cached() .and_then(|auth| auth.get_current_auth_json()); - if let Err(err) = revoke_auth_tokens(auth_dot_json.as_ref()).await { + if let Err(err) = + revoke_auth_tokens(auth_dot_json.as_ref(), self.auth_route_config.as_ref()).await + { tracing::warn!("failed to revoke auth tokens during logout: {err}"); } let result = logout_all_stores( diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index b99092f51f..5c9fcc3bdd 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -5,7 +5,8 @@ use serde::Deserialize; use std::env; use std::fmt; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; const PROD_AUTHAPI_BASE_URL: &str = "https://auth.openai.com/api/accounts"; const CODEX_AUTHAPI_BASE_URL_ENV_VAR: &str = "CODEX_AUTHAPI_BASE_URL"; @@ -36,13 +37,18 @@ impl fmt::Debug for PersonalAccessTokenAuth { } impl PersonalAccessTokenAuth { - pub(super) async fn load(access_token: &str) -> std::io::Result { + pub(super) async fn load( + access_token: &str, + auth_route_config: Option<&AuthRouteConfig>, + ) -> std::io::Result { let authapi_base_url = env::var(CODEX_AUTHAPI_BASE_URL_ENV_VAR) .ok() .map(|base_url| base_url.trim().trim_end_matches('/').to_string()) .filter(|base_url| !base_url.is_empty()) .unwrap_or_else(|| PROD_AUTHAPI_BASE_URL.to_string()); - hydrate_personal_access_token(&create_client(), &authapi_base_url, access_token).await + let endpoint = whoami_endpoint(&authapi_base_url); + let client = create_default_auth_client(&endpoint, auth_route_config)?; + hydrate_personal_access_token(&client, &endpoint, access_token).await } pub fn access_token(&self) -> &str { @@ -72,12 +78,11 @@ impl PersonalAccessTokenAuth { async fn hydrate_personal_access_token( client: &CodexHttpClient, - authapi_base_url: &str, + endpoint: &str, access_token: &str, ) -> std::io::Result { - let endpoint = format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')); let response = client - .get(&endpoint) + .get(endpoint) .bearer_auth(access_token) .send() .await @@ -107,6 +112,10 @@ async fn hydrate_personal_access_token( }) } +fn whoami_endpoint(authapi_base_url: &str) -> String { + format!("{}{WHOAMI_PATH}", authapi_base_url.trim_end_matches('/')) +} + #[cfg(test)] #[path = "personal_access_token_tests.rs"] mod tests; diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs index ac6ee12265..4d1b5e1ef7 100644 --- a/codex-rs/login/src/auth/personal_access_token_tests.rs +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::default_client::create_client; use pretty_assertions::assert_eq; use serde_json::json; use wiremock::Mock; @@ -29,7 +30,8 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { .mount(&server) .await; - let auth = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await .expect("personal access token hydration should succeed"); @@ -59,7 +61,8 @@ async fn hydrate_rejects_missing_email() { .mount(&server) .await; - let err = hydrate_personal_access_token(&create_client(), &server.uri(), "at-example") + let endpoint = whoami_endpoint(&server.uri()); + let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await .expect_err("personal access token hydration should reject missing email"); diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 22de353d3a..0f7e313c1c 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -16,7 +16,8 @@ use super::manager::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; use super::manager::oauth_client_id; use super::storage::AuthDotJson; use super::util::try_parse_error_message; -use crate::default_client::create_client; +use crate::default_client::create_default_auth_client; +use crate::outbound_proxy::AuthRouteConfig; use crate::token_data::TokenData; const REVOKE_HTTP_TIMEOUT: Duration = Duration::from_secs(10); @@ -53,13 +54,14 @@ struct RevokeTokenRequest<'a> { pub(super) async fn revoke_auth_tokens( auth_dot_json: Option<&AuthDotJson>, + auth_route_config: Option<&AuthRouteConfig>, ) -> Result<(), std::io::Error> { let Some((token, kind)) = auth_dot_json.and_then(revocable_token) else { return Ok(()); }; - let client = create_client(); let endpoint = revoke_token_endpoint(); + let client = create_default_auth_client(&endpoint, auth_route_config)?; revoke_oauth_token(&client, endpoint.as_str(), token, kind, REVOKE_HTTP_TIMEOUT).await } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 6f18c5984c..70e6dff225 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -6,9 +6,9 @@ use serde::de::{self}; use std::time::Duration; use std::time::Instant; +use crate::default_client::build_raw_auth_reqwest_client; use crate::pkce::PkceCodes; use crate::server::ServerOptions; -use codex_client::build_reqwest_client_with_custom_ca; use std::io; const ANSI_BLUE: &str = "\x1b[94m"; @@ -157,8 +157,10 @@ fn print_device_code_prompt(verification_url: &str, code: &str) { } pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); + // The route selected for the issuer is reused for all device-auth endpoint paths; the endpoint + // paths are not resolved separately. + let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; @@ -174,8 +176,8 @@ pub async fn complete_device_code_login( opts: ServerOptions, device_code: DeviceCode, ) -> std::io::Result<()> { - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let base_url = opts.issuer.trim_end_matches('/'); + let client = build_raw_auth_reqwest_client(base_url, opts.auth_route_config.as_ref())?; let api_base_url = format!("{base_url}/api/accounts"); let code_resp = poll_for_token( @@ -199,6 +201,7 @@ pub async fn complete_device_code_login( &redirect_uri, &pkce, &code_resp.authorization_code, + opts.auth_route_config.as_ref(), ) .await .map_err(|err| std::io::Error::other(format!("device code exchange failed: {err}")))?; diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 2b8e48b4a5..44c2f1c4d3 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -3,6 +3,7 @@ pub mod auth_env_telemetry; pub mod token_data; mod device_code_auth; +mod outbound_proxy; mod pkce; mod server; @@ -53,4 +54,5 @@ pub use auth::read_openai_api_key_from_env; pub use auth::save_auth; pub use auth_env_telemetry::AuthEnvTelemetry; pub use auth_env_telemetry::collect_auth_env_telemetry; +pub use outbound_proxy::AuthRouteConfig; pub use token_data::TokenData; diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs new file mode 100644 index 0000000000..bf7dfbeb4d --- /dev/null +++ b/codex-rs/login/src/outbound_proxy.rs @@ -0,0 +1,22 @@ +use codex_client::OutboundProxyConfig; + +/// Auth-layer adapter around client-owned proxy policy. +/// +/// `AuthConfig` carries this value while endpoint resolution and platform details remain in the +/// client layer. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthRouteConfig { + route_config: OutboundProxyConfig, +} + +impl AuthRouteConfig { + pub fn respect_system_proxy() -> Self { + Self { + route_config: OutboundProxyConfig::respect_system_proxy(), + } + } + + pub(crate) fn route_config(&self) -> &OutboundProxyConfig { + &self.route_config + } +} diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 67d5e8c663..c450da0f68 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -27,7 +27,9 @@ use std::time::Duration; use crate::auth::AuthDotJson; use crate::auth::AuthKeyringBackendKind; use crate::auth::save_auth; +use crate::default_client::build_raw_auth_reqwest_client; use crate::default_client::originator; +use crate::outbound_proxy::AuthRouteConfig; use crate::pkce::PkceCodes; use crate::pkce::generate_pkce; use crate::token_data::TokenData; @@ -35,7 +37,6 @@ use crate::token_data::parse_chatgpt_jwt_claims; use base64::Engine; use chrono::Utc; use codex_app_server_protocol::AuthMode; -use codex_client::build_reqwest_client_with_custom_ca; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_template::Template; use rand::RngCore; @@ -71,6 +72,7 @@ pub struct ServerOptions { pub codex_streamlined_login: bool, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, + pub auth_route_config: Option, } impl ServerOptions { @@ -81,6 +83,7 @@ impl ServerOptions { forced_chatgpt_workspace_id: Option>, cli_auth_credentials_store_mode: AuthCredentialsStoreMode, auth_keyring_backend_kind: AuthKeyringBackendKind, + auth_route_config: Option, ) -> Self { Self { codex_home, @@ -93,6 +96,7 @@ impl ServerOptions { codex_streamlined_login: false, cli_auth_credentials_store_mode, auth_keyring_backend_kind, + auth_route_config, } } } @@ -336,8 +340,15 @@ async fn process_request( } }; - match exchange_code_for_tokens(&opts.issuer, &opts.client_id, redirect_uri, pkce, &code) - .await + match exchange_code_for_tokens( + &opts.issuer, + &opts.client_id, + redirect_uri, + pkce, + &code, + opts.auth_route_config.as_ref(), + ) + .await { Ok(tokens) => { if let Err(message) = ensure_workspace_allowed( @@ -353,9 +364,14 @@ async fn process_request( ); } // Obtain API key via token-exchange and persist - let api_key = obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token) - .await - .ok(); + let api_key = obtain_api_key( + &opts.issuer, + &opts.client_id, + &tokens.id_token, + opts.auth_route_config.as_ref(), + ) + .await + .ok(); if let Err(err) = persist_tokens_async( &opts.codex_home, api_key.clone(), @@ -719,6 +735,7 @@ pub(crate) async fn exchange_code_for_tokens( redirect_uri: &str, pkce: &PkceCodes, code: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { #[derive(serde::Deserialize)] struct TokenResponse { @@ -727,7 +744,9 @@ pub(crate) async fn exchange_code_for_tokens( refresh_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; + // The route selected for the issuer is reused for token exchange; the token endpoint path is + // not resolved separately. + let client = build_raw_auth_reqwest_client(issuer.trim_end_matches('/'), auth_route_config)?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); info!( issuer = %sanitize_url_for_logging(issuer), @@ -1129,14 +1148,15 @@ pub(crate) async fn obtain_api_key( issuer: &str, client_id: &str, id_token: &str, + auth_route_config: Option<&AuthRouteConfig>, ) -> io::Result { // Token exchange for an API key access token #[derive(serde::Deserialize)] struct ExchangeResp { access_token: String, } - let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?; let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/')); + let client = build_raw_auth_reqwest_client(&token_endpoint, auth_route_config)?; let resp = client .post(token_endpoint) .header("Content-Type", "application/x-www-form-urlencoded") diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index af892d76a0..9c5eb936b1 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -1220,6 +1220,7 @@ impl RefreshTokenTestContext { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 78adc84c24..83cae8af5f 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -112,6 +112,7 @@ fn server_opts( /*forced_chatgpt_workspace_id*/ None, cli_auth_credentials_store_mode, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -277,6 +278,7 @@ async fn device_code_login_integration_persists_without_api_key_on_exchange_fail /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -332,6 +334,7 @@ async fn device_code_login_integration_handles_error_payload() -> anyhow::Result /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index 3d7c5a180b..6f7b4e4f83 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -122,6 +122,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -185,6 +186,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { let opts = ServerOptions { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -226,6 +228,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< let opts = ServerOptions { codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -268,6 +271,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -329,6 +333,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -398,6 +403,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< let opts = ServerOptions { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -507,6 +513,7 @@ async fn falls_back_to_registered_fallback_port_when_default_port_is_in_use() -> /*forced_chatgpt_workspace_id*/ None, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ); opts.issuer = issuer; opts.open_browser = false; @@ -545,6 +552,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let first_opts = ServerOptions { codex_home: first_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), port: 0, @@ -567,6 +575,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { let second_opts = ServerOptions { codex_home: second_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, + auth_route_config: None, client_id: codex_login::CLIENT_ID.to_string(), issuer, port: login_port, diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index 3c47bc88b8..e8f9981a8a 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -61,6 +61,7 @@ async fn logout_with_revoke_revokes_refresh_token_then_removes_auth() -> Result< codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -119,6 +120,7 @@ async fn logout_with_revoke_uses_stored_auth_when_access_token_env_is_set() -> R codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -161,6 +163,7 @@ async fn logout_with_revoke_removes_auth_when_revoke_fails() -> Result<()> { codex_home.path(), AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await?; @@ -204,6 +207,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { /*forced_chatgpt_workspace_id*/ None, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await; save_auth( diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index f7006ca905..eb9c5a8932 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -232,6 +232,7 @@ c2ln", AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, AuthKeyringBackendKind::default(), + /*auth_route_config*/ None, ) .await .expect("auth should load") diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c5cf1e1853..290430604d 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -10,6 +10,7 @@ use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::ConfigTomlLoadResult; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use crate::legacy_core::format_exec_policy_error_with_source; @@ -957,6 +958,14 @@ pub async fn run_main( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + bootstrap_config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, @@ -965,6 +974,7 @@ pub async fn run_main( .unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; @@ -1154,6 +1164,7 @@ pub async fn run_main( } if !app_server_target.uses_remote_workspace() { + let auth_route_config = config.auth_route_config(); #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&AuthConfig { codex_home: config.codex_home.to_path_buf(), @@ -1162,6 +1173,7 @@ pub async fn run_main( forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), chatgpt_base_url: Some(config.chatgpt_base_url.clone()), + auth_route_config, }) .await { @@ -1429,6 +1441,7 @@ async fn run_ratatui_app( initial_config.cli_auth_credentials_store_mode, initial_config.auth_keyring_backend_kind(), initial_config.chatgpt_base_url.clone(), + initial_config.auth_route_config(), ) .await; } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 0170ae49d4..8ba409f7c9 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1040,6 +1040,7 @@ mod tests { AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), "https://chatgpt.com/backend-api/".to_string(), + /*auth_route_config*/ None, ) .await, feedback: codex_feedback::CodexFeedback::new(), diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 852aaad5ce..56bcfae6ec 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -13,6 +13,7 @@ use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; use crate::legacy_core::config::load_config_toml_with_layer_stack; use crate::legacy_core::config::resolve_bootstrap_auth_keyring_backend_kind; +use crate::legacy_core::config::resolve_bootstrap_auth_route_config; use crate::legacy_core::config::resolve_oss_provider; use crate::legacy_core::config::resolve_profile_v2_config_path; use codex_app_server_protocol::Thread as AppServerThread; @@ -329,12 +330,21 @@ async fn start_app_server_for_archive_command( .chatgpt_base_url .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); + let auth_route_config = resolve_bootstrap_auth_route_config( + config_toml, + bootstrap_config + .config_layer_stack + .requirements() + .feature_requirements + .as_ref(), + )?; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), resolve_bootstrap_auth_keyring_backend_kind(&bootstrap_config)?, chatgpt_base_url, + auth_route_config, ) .await; From 5a67d898a58cebc075ffea2643009217ed3e76f1 Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Mon, 22 Jun 2026 13:19:40 -0700 Subject: [PATCH 41/86] Allow ChatGPT accounts without email (#28991) # Summary Codex required every ChatGPT account to have an email address. A service-account personal access token can return valid account metadata without one, so PAT login failed while decoding the metadata response. This change makes email optional in the account metadata type that owns it and preserves that absence through authentication, provider account state, the app-server API, generated clients, and TUI bootstrap. Existing accounts with email addresses keep the same behavior. ## Behavior-changing call sites | Call site | Behavior after this change | | --- | --- | | `login/src/auth/personal_access_token.rs` | PAT metadata accepts a missing or null email and retains `None`. | | `agent-identity/src/lib.rs` | Agent Identity JWT claims accept an omitted email. | | `login/src/auth/storage.rs` and `login/src/auth/agent_identity.rs` | Stored and managed Agent Identity records carry `Option`. Deserialization maps the legacy empty-string sentinel to `None`. | | `login/src/auth/manager.rs` | `get_account_email` returns the stored option, and managed identity bootstrap no longer converts `None` to an empty string. | | `model-provider/src/provider.rs` and `protocol/src/account.rs` | A ChatGPT provider account requires a plan type but may carry no email. | | `app-server-protocol/src/protocol/v2/account.rs` | `account/read` keeps the `email` field on the wire and returns `null` when the account has no email. Generated TypeScript and JSON schemas describe a required, nullable field. | | `sdk/python/src/openai_codex/generated/v2_all.py` | The generated Python `ChatgptAccount` model accepts `None` for email. | | `tui/src/app_server_session.rs` | Email-less ChatGPT accounts bootstrap normally, keep external feedback routing, omit account-email telemetry, and display the plan in account status. | ## Design decisions - Missing email remains `None` at every layer. The code never uses an empty string as a substitute. - The app-server response includes `"email": null` instead of omitting the field. Clients retain a stable response shape. - Plan type remains required for provider account state. This change relaxes only the email assumption. ## Testing Tests: affected test targets compile, scoped Clippy and formatting pass, a focused TUI snapshot covers plan-only account status, real before/after PAT login smoke covers metadata without email, app-server smoke covers `account/read` with `email: null`, and a regression smoke covers an existing email-bearing PAT. Unit tests run in CI. ## Evidence Visual smoke evidence will be attached here. --- codex-rs/Cargo.lock | 1 + codex-rs/agent-identity/src/lib.rs | 28 ++++++- .../codex_app_server_protocol.schemas.json | 5 +- .../codex_app_server_protocol.v2.schemas.json | 5 +- .../schema/json/v2/GetAccountResponse.json | 5 +- .../schema/typescript/v2/Account.ts | 2 +- .../src/protocol/common.rs | 15 +++- .../src/protocol/v2/account.rs | 12 ++- codex-rs/app-server/README.md | 2 + codex-rs/app-server/tests/suite/auth.rs | 36 ++++++++- codex-rs/app-server/tests/suite/v2/account.rs | 53 +++++++++++- .../suite/v2/rate_limit_reset_credits.rs | 11 ++- codex-rs/cloud-config/src/service_tests.rs | 2 +- codex-rs/login/src/auth/agent_identity.rs | 8 +- codex-rs/login/src/auth/auth_tests.rs | 4 +- codex-rs/login/src/auth/manager.rs | 6 +- .../login/src/auth/personal_access_token.rs | 6 +- .../src/auth/personal_access_token_tests.rs | 23 ++++-- codex-rs/login/src/auth/storage.rs | 26 +++++- codex-rs/login/src/auth/storage_tests.rs | 81 ++++++++++++++++++- codex-rs/model-provider/src/provider.rs | 25 +++--- codex-rs/protocol/src/account.rs | 2 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app_server_session.rs | 9 ++- ...shot_shows_chatgpt_plan_without_email.snap | 20 +++++ codex-rs/tui/src/status/tests.rs | 65 +++++++++++++++ sdk/python/scripts/update_sdk_artifacts.py | 53 ++++++++++++ .../src/openai_codex/generated/v2_all.py | 2 +- .../test_artifact_workflow_and_binaries.py | 38 +++++++++ 29 files changed, 485 insertions(+), 61 deletions(-) create mode 100644 codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f35402efd0..ae50cb5c85 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4016,6 +4016,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "app_test_support", "arboard", "assert_matches", "base64 0.22.1", diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index 0e29a38700..267fdf53d8 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -119,7 +119,7 @@ pub struct AgentIdentityJwtClaims { pub agent_private_key: String, pub account_id: String, pub chatgpt_user_id: String, - pub email: String, + pub email: Option, pub plan_type: AuthPlanType, pub chatgpt_account_is_fedramp: bool, } @@ -667,13 +667,33 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, } ); } + #[test] + fn decode_agent_identity_jwt_accepts_missing_email() { + let jwt = jwt_with_payload(serde_json::json!({ + "iss": AGENT_IDENTITY_JWT_ISSUER, + "aud": AGENT_IDENTITY_JWT_AUDIENCE, + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + + let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode"); + + assert_eq!(claims.email, None); + } + #[test] fn decode_agent_identity_jwt_maps_raw_plan_aliases() { let jwt = jwt_with_payload(serde_json::json!({ @@ -707,7 +727,7 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; @@ -739,7 +759,7 @@ mod tests { agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 3a2af11db3..f44e2f37ae 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5900,7 +5900,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/v2/PlanType" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 286fc0e2f6..5a8214b3a3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -26,7 +26,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 62b520de41..ca641799ed 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -22,7 +22,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts index 09f74469b7..1b7953e5df 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -4,4 +4,4 @@ import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource"; import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9bcc6a86d0..71773f2fc8 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2854,7 +2854,7 @@ mod tests { ); let chatgpt = v2::Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Plus, }; assert_eq!( @@ -2866,6 +2866,19 @@ mod tests { serde_json::to_value(&chatgpt)?, ); + let chatgpt_without_email = v2::Account::Chatgpt { + email: None, + plan_type: PlanType::Pro, + }; + assert_eq!( + json!({ + "type": "chatgpt", + "email": null, + "planType": "pro", + }), + serde_json::to_value(&chatgpt_without_email)?, + ); + let codex_managed_bedrock = v2::Account::AmazonBedrock { credential_source: AmazonBedrockCredentialSource::CodexManaged, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 348d6810fc..635d001bba 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -25,7 +25,11 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, + Chatgpt { + #[schemars(required, schema_with = "nullable_string_schema")] + email: Option, + plan_type: PlanType, + }, #[serde(rename = "amazonBedrock", rename_all = "camelCase")] #[ts(rename = "amazonBedrock", rename_all = "camelCase")] @@ -35,6 +39,12 @@ pub enum Account { }, } +fn nullable_string_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + generator.subschema_for::>() +} + fn default_bedrock_credential_source() -> AmazonBedrockCredentialSource { AmazonBedrockCredentialSource::AwsManaged } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e70db8bba1..83eec5379c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1941,6 +1941,7 @@ Response examples: { "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) { "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": null, "planType": "enterprise" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "codexManaged" }, "requiresOpenaiAuth": false } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "awsManaged" }, "requiresOpenaiAuth": false } } ``` @@ -1948,6 +1949,7 @@ Response examples: Field notes: - `refreshToken` (bool): set `true` to force a token refresh. +- `email` is `null` when the ChatGPT account does not have an email address. - `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. - Amazon Bedrock reports `credentialSource: "codexManaged"` when it uses a Bedrock API key managed by Codex. Otherwise it reports `credentialSource: "awsManaged"` for the external AWS credential path. This identifies the selected credential source; it does not validate that the AWS credential chain can resolve credentials. diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 6e88866c7d..e49e841f65 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -5,7 +5,10 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use chrono::Duration; use chrono::Utc; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; @@ -14,6 +17,7 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -162,7 +166,7 @@ async fn get_auth_status_with_api_key() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { +async fn personal_access_token_without_email_supports_auth_status_and_account_read() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; @@ -171,7 +175,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> .and(path("/v1/user-auth-credential/whoami")) .and(header("Authorization", "Bearer at-test-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "email": "user@example.com", + "email": null, "chatgpt_user_id": "user-123", "chatgpt_account_id": "account-123", "chatgpt_plan_type": "pro", @@ -215,6 +219,34 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> } ); + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + response + .result + .get("account") + .and_then(|account| account.get("email")), + Some(&serde_json::Value::Null), + ); + assert_eq!( + to_response::(response)?, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + server.verify().await; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 06874c4230..3cd98e2e52 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -320,7 +320,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -388,7 +388,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1817,7 +1817,7 @@ async fn get_account_with_chatgpt() -> Result<()> { let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1826,6 +1826,51 @@ async fn get_account_with_chatgpt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn get_account_with_chatgpt_without_email() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(response)?; + + assert_eq!( + received, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + Ok(()) +} + #[tokio::test] async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> { let codex_home = TempDir::new()?; @@ -1944,7 +1989,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Unknown, }), requires_openai_auth: true, diff --git a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs index d45dcdc3a5..86ea09504c 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs @@ -204,12 +204,11 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> { "rate limit reset consume timed out" ); - let account_error = read_error_response(&mut mcp, account_id).await?; - assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - account_error.error.message, - "email and plan type are required for chatgpt authentication" - ); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(account_id)), + ) + .await??; Ok(()) } diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 47b49d43bb..dfd4fe50ce 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -104,7 +104,7 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc { agent_private_key: key_material.private_key_pkcs8_base64, account_id: "account-12345".to_string(), chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Business, chatgpt_account_is_fedramp: false, task_id: Some("task-123".to_string()), diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index a164c7e3a5..79ee2e7f54 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -90,7 +90,7 @@ pub struct AgentIdentityAuth { pub(super) struct ManagedChatGptAgentIdentityBinding { pub(super) account_id: String, pub(super) chatgpt_user_id: String, - pub(super) email: String, + pub(super) email: Option, pub(super) plan_type: AccountPlanType, pub(super) chatgpt_account_is_fedramp: bool, pub(super) access_token: String, @@ -156,8 +156,8 @@ impl AgentIdentityAuth { &self.record.chatgpt_user_id } - pub fn email(&self) -> &str { - &self.record.email + pub fn email(&self) -> Option<&str> { + self.record.email.as_deref() } pub fn plan_type(&self) -> AccountPlanType { @@ -363,7 +363,7 @@ mod tests { agent_private_key: private_key, account_id: "account-1".to_string(), chatgpt_user_id: "user-1".to_string(), - email: "agent@example.com".to_string(), + email: Some("agent@example.com".to_string()), plan_type: AccountPlanType::Plus, chatgpt_account_is_fedramp: false, task_id: None, diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index fbb6aac8ea..bcce562e69 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -607,7 +607,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible() )?; let mut record = agent_identity_record("account-123"); record.chatgpt_user_id = "user-12345".to_string(); - record.email = "user@example.com".to_string(); + record.email = Some("user@example.com".to_string()); let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); let auth_path = get_auth_file(codex_home.path()); let mut auth_json = storage.try_read_auth_json(&auth_path)?; @@ -1926,7 +1926,7 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord { agent_private_key: key_material.private_key_pkcs8_base64, account_id: account_id.to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, task_id: None, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a762bd5466..26580a672d 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -506,8 +506,8 @@ impl CodexAuth { /// Returns `None` if Codex backend auth does not expose an account email. pub fn get_account_email(&self) -> Option { match self { - Self::AgentIdentity(auth) => Some(auth.email().to_string()), - Self::PersonalAccessToken(auth) => Some(auth.email().to_string()), + Self::AgentIdentity(auth) => auth.email().map(str::to_string), + Self::PersonalAccessToken(auth) => auth.email().map(str::to_string), _ => self.get_current_token_data().and_then(|t| t.id_token.email), } } @@ -723,7 +723,7 @@ impl ManagedChatGptAgentIdentityBinding { Some(Self { account_id, chatgpt_user_id, - email: token_data.id_token.email.clone().unwrap_or_default(), + email: token_data.id_token.email.clone(), plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown), chatgpt_account_is_fedramp: auth.is_fedramp_account(), access_token: token_data.access_token, diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index 5c9fcc3bdd..ea75648d1b 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -14,7 +14,7 @@ const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami"; #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] struct PersonalAccessTokenMetadata { - email: String, + email: Option, chatgpt_user_id: String, chatgpt_account_id: String, chatgpt_plan_type: String, @@ -63,8 +63,8 @@ impl PersonalAccessTokenAuth { &self.metadata.chatgpt_user_id } - pub fn email(&self) -> &str { - &self.metadata.email + pub fn email(&self) -> Option<&str> { + self.metadata.email.as_deref() } pub fn plan_type(&self) -> AccountPlanType { diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs index 4d1b5e1ef7..b05edb068b 100644 --- a/codex-rs/login/src/auth/personal_access_token_tests.rs +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -40,7 +40,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { PersonalAccessTokenAuth { access_token: "at-example".to_string(), metadata: PersonalAccessTokenMetadata { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), chatgpt_user_id: "user-123".to_string(), chatgpt_account_id: "account-123".to_string(), chatgpt_plan_type: "enterprise".to_string(), @@ -52,7 +52,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() { } #[tokio::test] -async fn hydrate_rejects_missing_email() { +async fn hydrate_preserves_missing_email() { let server = MockServer::start().await; Mock::given(method("GET")) .and(path(WHOAMI_PATH)) @@ -62,13 +62,22 @@ async fn hydrate_rejects_missing_email() { .await; let endpoint = whoami_endpoint(&server.uri()); - let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") + let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example") .await - .expect_err("personal access token hydration should reject missing email"); + .expect("personal access token hydration should accept missing email"); - assert!( - err.to_string() - .contains("failed to decode personal access token metadata") + assert_eq!( + auth, + PersonalAccessTokenAuth { + access_token: "at-example".to_string(), + metadata: PersonalAccessTokenMetadata { + email: None, + chatgpt_user_id: "user-123".to_string(), + chatgpt_account_id: "account-123".to_string(), + chatgpt_plan_type: "enterprise".to_string(), + chatgpt_account_is_fedramp: true, + }, + } ); server.verify().await; } diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 19ba018f30..b5ae190551 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -92,13 +92,37 @@ pub struct AgentIdentityAuthRecord { pub agent_private_key: String, pub account_id: String, pub chatgpt_user_id: String, - pub email: String, + #[serde( + default, + deserialize_with = "deserialize_optional_non_empty_string", + serialize_with = "serialize_optional_string_as_empty" + )] + pub email: Option, pub plan_type: AccountPlanType, pub chatgpt_account_is_fedramp: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub task_id: Option, } +fn deserialize_optional_non_empty_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty())) +} + +fn serialize_optional_string_as_empty( + value: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + value.as_deref().unwrap_or_default().serialize(serializer) +} + impl AgentIdentityAuthRecord { pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result { let claims = diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index db51a7746e..647af79b65 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -102,7 +102,7 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re agent_private_key: "private-key".to_string(), account_id: "account-id".to_string(), chatgpt_user_id: "user-id".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, chatgpt_account_is_fedramp: false, task_id: Some("task-id".to_string()), @@ -124,6 +124,85 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re Ok(()) } +#[tokio::test] +async fn file_storage_loads_empty_agent_identity_email_as_none() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_file = get_auth_file(codex_home.path()); + std::fs::write( + &auth_file, + serde_json::to_string_pretty(&json!({ + "auth_mode": "chatgpt", + "agent_identity": { + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "email": "", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + }, + }))?, + )?; + + let loaded = storage.load()?; + + assert_eq!( + loaded, + Some(AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: None, + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: None, + })), + personal_access_token: None, + bedrock_api_key: None, + }) + ); + Ok(()) +} + +#[tokio::test] +async fn file_storage_writes_missing_agent_identity_email_as_empty_string() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityStorage::Record(AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: None, + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + task_id: None, + })), + personal_access_token: None, + bedrock_api_key: None, + }; + + storage.save(&auth_dot_json)?; + + let auth_file = get_auth_file(codex_home.path()); + let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(auth_file)?)?; + assert_eq!(saved["agent_identity"]["email"], ""); + assert_eq!(storage.load()?, Some(auth_dot_json)); + Ok(()) +} + #[tokio::test] async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> { let codex_home = tempdir()?; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 91a1c06a57..3bd5031362 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -60,10 +60,7 @@ impl fmt::Display for ProviderAccountError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingChatgptAccountDetails => { - write!( - f, - "email and plan type are required for chatgpt authentication" - ) + write!(f, "plan type is required for chatgpt authentication") } Self::UnsupportedBedrockApiKeyAuth => { write!( @@ -261,12 +258,9 @@ impl ModelProvider for ConfiguredModelProvider { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); - match (email, plan_type) { - (Some(email), Some(plan_type)) => { - Ok(ProviderAccount::Chatgpt { email, plan_type }) - } - _ => Err(ProviderAccountError::MissingChatgptAccountDetails), - } + plan_type + .map(|plan_type| ProviderAccount::Chatgpt { email, plan_type }) + .ok_or(ProviderAccountError::MissingChatgptAccountDetails) } }) .transpose()? @@ -313,6 +307,7 @@ mod tests { use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::WireApi; use codex_models_manager::manager::RefreshStrategy; + use codex_protocol::account::PlanType; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -518,7 +513,7 @@ mod tests { } #[test] - fn openai_provider_rejects_chatgpt_account_state_without_email() { + fn openai_provider_returns_chatgpt_account_state_without_email() { let provider = create_model_provider( ModelProviderInfo::create_openai_provider(/*base_url*/ None), Some(AuthManager::from_auth_for_testing( @@ -528,7 +523,13 @@ mod tests { assert_eq!( provider.account_state(), - Err(ProviderAccountError::MissingChatgptAccountDetails) + Ok(ProviderAccountState { + account: Some(ProviderAccount::Chatgpt { + email: None, + plan_type: PlanType::Unknown, + }), + requires_openai_auth: true, + }) ); } diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index 11286eddc8..dfcac51d30 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -35,7 +35,7 @@ pub enum PlanType { pub enum ProviderAccount { ApiKey, Chatgpt { - email: String, + email: Option, plan_type: PlanType, }, AmazonBedrock { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2a10f00b42..6c0b83a72e 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -148,6 +148,7 @@ arboard = { workspace = true } [dev-dependencies] +app_test_support = { workspace = true } codex-cli = { workspace = true } codex-mcp = { workspace = true } core_test_support = { workspace = true } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9ed78fed58..527cde57ed 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -311,16 +311,19 @@ impl AppServerSession { false, ), Some(Account::Chatgpt { email, plan_type }) => { - let feedback_audience = if email.ends_with("@openai.com") { + let feedback_audience = if email + .as_deref() + .is_some_and(|email| email.ends_with("@openai.com")) + { FeedbackAudience::OpenAiEmployee } else { FeedbackAudience::External }; ( - Some(email.clone()), + email.clone(), Some(TelemetryAuthMode::Chatgpt), Some(StatusAccountDisplay::ChatGpt { - email: Some(email), + email, plan: Some(plan_type_display_name(plan_type)), }), Some(plan_type), diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap new file mode 100644 index 0000000000..4ac5239b94 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ Account: Enterprise │ +│ │ +│ Limits: data not available yet │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 4ab7a2cfa2..50ef2c5000 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -17,6 +17,9 @@ use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::TimeZone; @@ -27,6 +30,7 @@ use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::test_support::construct_model_info_offline_for_tests; @@ -318,6 +322,67 @@ async fn status_snapshot_includes_reasoning_details() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_shows_chatgpt_plan_without_email() { + let temp_home = TempDir::new().expect("temp home"); + write_models_cache(temp_home.path()).expect("write models cache"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + write_chatgpt_auth( + temp_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("enterprise"), + AuthCredentialsStoreMode::File, + ) + .expect("write email-less ChatGPT auth"); + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("start embedded app server"); + let bootstrap = app_server + .bootstrap(&config) + .await + .expect("bootstrap app server session"); + app_server.shutdown().await.expect("shut down app server"); + let account_display = bootstrap + .status_account_display + .expect("bootstrap should return ChatGPT account display"); + assert_eq!( + account_display, + StatusAccountDisplay::ChatGpt { + email: None, + plan: Some("Enterprise".to_string()), + } + ); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + + let composite = new_status_output( + &config, + Some(&account_display), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let sanitized = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 80))).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_permissions_non_default_workspace_write_uses_workspace_label() { let temp_home = TempDir::new().expect("temp home"); diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index a4af5d605e..f3b97c6b6f 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -503,6 +503,33 @@ def _annotate_schema(value: Any, base: str | None = None) -> None: _annotate_schema(child, base) +def _make_chatgpt_account_email_nullable(schema: dict[str, Any]) -> None: + definitions = schema.get("definitions") + if not isinstance(definitions, dict): + raise RuntimeError("Schema bundle is missing definitions") + + account = definitions.get("Account") + if not isinstance(account, dict): + raise RuntimeError("Schema bundle is missing the Account definition") + + for variant in account.get("oneOf", []): + if not isinstance(variant, dict): + continue + properties = variant.get("properties") + if not isinstance(properties, dict): + continue + account_type = properties.get("type") + if not isinstance(account_type, dict) or account_type.get("enum") != ["chatgpt"]: + continue + email = properties.get("email") + if not isinstance(email, dict): + raise RuntimeError("ChatGPT account schema is missing email") + email["type"] = ["string", "null"] + return + + raise RuntimeError("Schema bundle is missing the ChatGPT account variant") + + def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: """Generate app-server schemas by invoking the installed pinned runtime binary.""" codex_path = pinned_runtime_codex_path() @@ -525,6 +552,7 @@ def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: def _normalized_schema_bundle_text(schema_dir: Path) -> str: """Normalize the schema bundle before feeding it to the Python type generator.""" schema = json.loads(schema_bundle_path(schema_dir).read_text()) + _make_chatgpt_account_email_nullable(schema) definitions = schema.get("definitions", {}) if isinstance(definitions, dict): for definition in definitions.values(): @@ -580,9 +608,34 @@ def generate_v2_all(schema_dir: Path) -> None: ], cwd=sdk_root(), ) + _require_nullable_chatgpt_account_email(out_path) _normalize_generated_timestamps(out_path) +def _require_nullable_chatgpt_account_email(out_path: Path) -> None: + """Preserve required-but-nullable email semantics in the generated SDK model.""" + source = out_path.read_text() + class_start = source.find("class ChatgptAccount(BaseModel):") + if class_start == -1: + raise RuntimeError("Generated SDK is missing ChatgptAccount") + class_end = source.find("\n\nclass ", class_start) + if class_end == -1: + class_end = len(source) + + class_source = source[class_start:class_end] + nullable_with_default = " email: str | None = None" + if class_source.count(nullable_with_default) != 1: + raise RuntimeError( + "Generated ChatgptAccount email did not have the expected nullable shape" + ) + class_source = class_source.replace( + nullable_with_default, + " email: str | None", + 1, + ) + out_path.write_text(source[:class_start] + class_source + source[class_end:]) + + def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]: """Map each server notification method to its generated payload model class.""" server_notifications = json.loads((schema_dir / "ServerNotification.json").read_text()) diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 15ede1801c..c024a2c8c0 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -4654,7 +4654,7 @@ class ChatgptAccount(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - email: str + email: str | None plan_type: Annotated[PlanType, Field(alias="planType")] type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")] diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 5242600357..b2cfda53b5 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -10,6 +10,7 @@ from pathlib import Path import pytest import tomllib +from pydantic import ValidationError ROOT = Path(__file__).resolve().parents[1] @@ -306,6 +307,32 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( ] +def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None: + script = _load_update_script_module() + schema = { + "definitions": { + "Account": { + "oneOf": [ + { + "properties": { + "email": {"type": "string"}, + "type": {"enum": ["chatgpt"], "type": "string"}, + }, + "required": ["email", "type"], + "type": "object", + } + ] + } + } + } + + script._make_chatgpt_account_email_nullable(schema) + + chatgpt_account = schema["definitions"]["Account"]["oneOf"][0] + assert chatgpt_account["properties"]["email"]["type"] == ["string", "null"] + assert "email" in chatgpt_account["required"] + + def test_python_codegen_schema_annotation_adds_stable_variant_titles( tmp_path: Path, ) -> None: @@ -350,6 +377,17 @@ def test_generate_v2_all_uses_titles_for_generated_names() -> None: assert "ruff-format" in source +def test_generated_chatgpt_account_email_is_required_nullable() -> None: + from openai_codex.generated.v2_all import ChatgptAccount + + account = ChatgptAccount.model_validate({"email": None, "planType": "pro", "type": "chatgpt"}) + assert account.email is None + assert ChatgptAccount.model_fields["email"].is_required() + + with pytest.raises(ValidationError): + ChatgptAccount.model_validate({"planType": "pro", "type": "chatgpt"}) + + def test_runtime_package_template_has_no_checked_in_binaries() -> None: runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin" assert sorted( From bd5bd953fb2a5d610a30d112eafe20b644924085 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 13:25:48 -0700 Subject: [PATCH 42/86] [codex] configure rollout budget reminder thresholds (#29423) ## Summary Instead of: reminder_interval_tokens = 65_536 allow users to configure explicit remaining-token reminder thresholds: reminder_at_remaining_tokens = [65_536, 32_768, 16_384, 8_192, 4_096, 2_048, 1_024, 512] ## Validation - CARGO_INCREMENTAL=0 just test -p codex-core rollout_budget: 9 passed - just fix -p codex-core - just fmt --- codex-rs/core/config.schema.json | 11 ++++--- codex-rs/core/src/config/config_tests.rs | 4 +-- codex-rs/core/src/config/mod.rs | 26 ++++++++++----- codex-rs/core/src/rollout_budget.rs | 14 +++++--- codex-rs/core/src/session/config_lock.rs | 4 +-- codex-rs/core/src/thread_manager.rs | 2 +- codex-rs/core/tests/suite/rollout_budget.rs | 36 +++++++++++---------- codex-rs/features/src/feature_configs.rs | 4 +-- 8 files changed, 60 insertions(+), 41 deletions(-) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b6736c9407..4615f4e858 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2674,10 +2674,13 @@ "minimum": 0.0, "type": "number" }, - "reminder_interval_tokens": { - "format": "int64", - "minimum": 1.0, - "type": "integer" + "reminder_at_remaining_tokens": { + "description": "Remaining weighted-token values that trigger reminders when crossed.", + "items": { + "format": "int64", + "type": "integer" + }, + "type": "array" }, "sampling_token_weight": { "format": "double", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index be332cd603..e0dc04a48e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -552,7 +552,7 @@ async fn load_config_resolves_rollout_budget() -> std::io::Result<()> { [features.rollout_budget] enabled = true limit_tokens = 100000 -reminder_interval_tokens = 10000 +reminder_at_remaining_tokens = [50000, 25000, 10000] sampling_token_weight = 1.0 prefill_token_weight = 0.1 "#, @@ -571,7 +571,7 @@ prefill_token_weight = 0.1 config.rollout_budget, Some(RolloutBudgetConfig { limit_tokens: 100_000, - reminder_interval_tokens: 10_000, + reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, prefill_token_weight: 0.1, }) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 19d4bb44c1..371257d185 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1098,10 +1098,10 @@ impl Default for TokenBudgetConfig { } } -#[derive(Debug, Clone, Copy, PartialEq, Serialize)] +#[derive(Debug, Clone, PartialEq, Serialize)] pub struct RolloutBudgetConfig { pub limit_tokens: i64, - pub reminder_interval_tokens: i64, + pub reminder_at_remaining_tokens: Vec, pub sampling_token_weight: f64, pub prefill_token_weight: f64, } @@ -2606,13 +2606,23 @@ fn resolve_rollout_budget_config( "features.rollout_budget.limit_tokens must be positive", )); } - let reminder_interval_tokens = config - .reminder_interval_tokens - .unwrap_or_else(|| (limit_tokens / 10).max(1)); - if reminder_interval_tokens <= 0 { + let reminder_at_remaining_tokens = + config + .reminder_at_remaining_tokens + .clone() + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "features.rollout_budget.reminder_at_remaining_tokens is required when rollout_budget is enabled", + ) + })?; + if reminder_at_remaining_tokens + .iter() + .any(|&tokens| tokens <= 0 || tokens >= limit_tokens) + { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, - "features.rollout_budget.reminder_interval_tokens must be positive", + "features.rollout_budget.reminder_at_remaining_tokens must contain only positive values below limit_tokens", )); } let sampling_token_weight = config.sampling_token_weight.unwrap_or(1.0); @@ -2630,7 +2640,7 @@ fn resolve_rollout_budget_config( } Ok(Some(RolloutBudgetConfig { limit_tokens, - reminder_interval_tokens, + reminder_at_remaining_tokens, sampling_token_weight, prefill_token_weight, })) diff --git a/codex-rs/core/src/rollout_budget.rs b/codex-rs/core/src/rollout_budget.rs index b8901a9e36..334e9e73a5 100644 --- a/codex-rs/core/src/rollout_budget.rs +++ b/codex-rs/core/src/rollout_budget.rs @@ -57,18 +57,22 @@ impl RolloutBudget { window_id: &str, ) -> Option { let state = self.lock()?; - let reminder_index = (state.weighted_tokens_used - / state.config.reminder_interval_tokens as f64) + let remaining_tokens = (state.config.limit_tokens as f64 - state.weighted_tokens_used) + .max(0.0) .floor() as i64; + let reminder_index = state + .config + .reminder_at_remaining_tokens + .iter() + .filter(|&&threshold| remaining_tokens <= threshold) + .count() as i64; if state.deliveries.get(&thread_id).is_some_and(|delivery| { delivery.window_id.as_str() == window_id && delivery.reminder_index >= reminder_index }) { return None; } Some(RolloutBudgetReminder { - remaining_tokens: (state.config.limit_tokens as f64 - state.weighted_tokens_used) - .max(0.0) - .floor() as i64, + remaining_tokens, reminder_index, }) } diff --git a/codex-rs/core/src/session/config_lock.rs b/codex-rs/core/src/session/config_lock.rs index e91c4f4895..035f7bce4d 100644 --- a/codex-rs/core/src/session/config_lock.rs +++ b/codex-rs/core/src/session/config_lock.rs @@ -255,7 +255,7 @@ mod tests { .expect("token_budget should be enableable in tests"); config.rollout_budget = Some(crate::config::RolloutBudgetConfig { limit_tokens: 100_000, - reminder_interval_tokens: 10_000, + reminder_at_remaining_tokens: vec![50_000, 25_000, 10_000], sampling_token_weight: 1.0, prefill_token_weight: 0.25, }); @@ -348,7 +348,7 @@ mod tests { Some(FeatureToml::Config(RolloutBudgetConfigToml { enabled: Some(true), limit_tokens: Some(100_000), - reminder_interval_tokens: Some(10_000), + reminder_at_remaining_tokens: Some(vec![50_000, 25_000, 10_000]), sampling_token_weight: Some(1.0), prefill_token_weight: Some(0.25), })) diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2cce3bb8a8..deba2864b6 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1024,7 +1024,7 @@ impl ThreadManager { } fn agent_control_for_config(&self, config: &Config) -> AgentControl { - AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget) + AgentControl::new(Arc::downgrade(&self.state), config.rollout_budget.clone()) } #[cfg(test)] diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index 61c6dfe424..feafdc51ef 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -25,12 +25,14 @@ use std::time::Duration; use test_case::test_case; use tokio::time::timeout; -const ROLLOUT_BUDGET: RolloutBudgetConfig = RolloutBudgetConfig { - limit_tokens: 100, - reminder_interval_tokens: 25, - sampling_token_weight: 1.0, - prefill_token_weight: 1.0, -}; +fn rollout_budget() -> RolloutBudgetConfig { + RolloutBudgetConfig { + limit_tokens: 100, + reminder_at_remaining_tokens: vec![75, 50, 25], + sampling_token_weight: 1.0, + prefill_token_weight: 1.0, + } +} fn rollout_budget_texts(request: &ResponsesRequest) -> Vec { request @@ -51,7 +53,7 @@ fn wire_request_contains(request: &wiremock::Request, text: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> { +async fn adds_weighted_initial_and_threshold_reminders() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -83,7 +85,7 @@ async fn adds_weighted_initial_and_periodic_reminders() -> Result<()> { config.rollout_budget = Some(RolloutBudgetConfig { sampling_token_weight: 2.0, prefill_token_weight: 0.5, - ..ROLLOUT_BUDGET + ..rollout_budget() }); }) .build(&server) @@ -171,7 +173,7 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { .features .enable(Feature::MultiAgentV2) .expect("test config should allow multi-agent v2"); - config.rollout_budget = Some(ROLLOUT_BUDGET); + config.rollout_budget = Some(rollout_budget()); }) .build(&server) .await?; @@ -218,8 +220,8 @@ async fn exhausted_budget_aborts_current_and_later_turns() -> Result<()> { .with_config(|config| { config.rollout_budget = Some(RolloutBudgetConfig { limit_tokens: 30, - reminder_interval_tokens: 10, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![20, 10], + ..rollout_budget() }); }) .build(&server) @@ -293,8 +295,8 @@ async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: b config.model_provider = model_provider; config.rollout_budget = Some(RolloutBudgetConfig { limit_tokens: 10, - reminder_interval_tokens: 5, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![5], + ..rollout_budget() }); if remote_v2 { config @@ -354,8 +356,8 @@ async fn restates_the_current_remainder_after_compaction() -> Result<()> { .with_config(move |config| { config.model_provider = model_provider; config.rollout_budget = Some(RolloutBudgetConfig { - reminder_interval_tokens: 50, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() }); }) .build(&server) @@ -409,8 +411,8 @@ async fn restates_the_current_remainder_after_rollback() -> Result<()> { let test = test_codex() .with_config(|config| { config.rollout_budget = Some(RolloutBudgetConfig { - reminder_interval_tokens: 50, - ..ROLLOUT_BUDGET + reminder_at_remaining_tokens: vec![50], + ..rollout_budget() }); }) .build(&server) diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index a76aaaa3ce..35b669b0a0 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -109,8 +109,8 @@ pub struct RolloutBudgetConfigToml { #[schemars(range(min = 1))] pub limit_tokens: Option, #[serde(skip_serializing_if = "Option::is_none")] - #[schemars(range(min = 1))] - pub reminder_interval_tokens: Option, + /// Remaining weighted-token values that trigger reminders when crossed. + pub reminder_at_remaining_tokens: Option>, #[serde(skip_serializing_if = "Option::is_none")] #[schemars(range(min = 0.0))] pub sampling_token_weight: Option, From ced3e4b9a7576fe08950965f2584d4d0c0d96937 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 22 Jun 2026 13:48:09 -0700 Subject: [PATCH 43/86] permission profiles: expose availability to clients (#26678) ## Why `permissionProfile/list` currently advertises every built-in and configured profile even when effective enterprise requirements prevent selecting it. That forces each client to reconstruct policy from lower-level requirement fields, which is easy to miss and difficult to keep consistent. The catalog should remain complete so clients can explain that an option was disabled by an administrator, while also reporting whether each profile is selectable. ## What - Add an `allowed` field to each permission profile summary. - Build a shared catalog from the effective config and current requirements, including `allowed_sandbox_modes`, `allowed_permissions`, and filesystem restrictions. - Use the shared catalog in app-server and the TUI so disallowed profiles remain visible but cannot be selected. - Use the canonical `:danger-full-access` profile ID in the TUI. - Update the app-server schemas, API documentation, behavioral tests, and TUI snapshots. ## Scope This PR targets `main` directly and is independent of #24852. It preserves the current behavior where built-in profiles are constrained by sandbox-mode requirements and `allowed_permissions` applies to configured profiles. ## Testing - `just test -p codex-core permission_profile_catalog_marks_profiles_disallowed_by_requirements` - `just test -p codex-app-server permission_profile_list` - `just test -p codex-app-server-protocol` - `just test -p codex-tui profile_permissions` - `just fix -p codex-core` - `just fix -p codex-app-server-protocol` - `just fix -p codex-app-server` - `just fix -p codex-tui` - `just fmt` --------- Co-authored-by: Codex Co-authored-by: Joey Trasatti --- .../codex_app_server_protocol.schemas.json | 5 + .../codex_app_server_protocol.v2.schemas.json | 5 + .../v2/PermissionProfileListResponse.json | 5 + .../typescript/v2/PermissionProfileSummary.ts | 6 +- .../src/protocol/v2/permissions.rs | 2 + codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 3 - .../request_processors/catalog_processor.rs | 32 +--- .../tests/suite/v2/permission_profile_list.rs | 13 ++ .../core/src/config/config_loader_tests.rs | 70 +++++++++ codex-rs/core/src/config/config_tests.rs | 3 +- codex-rs/core/src/config/mod.rs | 61 ++++---- .../src/config/permission_profile_catalog.rs | 140 ++++++++++++++++++ .../tui/src/chatwidget/permissions_menu.rs | 11 +- ..._selection_popup_with_custom_profiles.snap | 27 ++-- ...ion_popup_with_disallowed_full_access.snap | 20 +++ .../tui/src/chatwidget/tests/permissions.rs | 37 ++++- 17 files changed, 357 insertions(+), 85 deletions(-) create mode 100644 codex-rs/core/src/config/permission_profile_catalog.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f44e2f37ae..b2be46f1f3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13045,6 +13045,10 @@ }, "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -13058,6 +13062,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 5a8214b3a3..98b0d42976 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9449,6 +9449,10 @@ }, "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -9462,6 +9466,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json index 4d5a47f8d5..1027b9c5a0 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PermissionProfileListResponse.json @@ -3,6 +3,10 @@ "definitions": { "PermissionProfileSummary": { "properties": { + "allowed": { + "description": "Whether the effective requirements allow selecting this profile.", + "type": "boolean" + }, "description": { "description": "Optional user-facing description for display in clients.", "type": [ @@ -16,6 +20,7 @@ } }, "required": [ + "allowed", "id" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts index 9d02fd776b..5796d30eed 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSummary.ts @@ -10,4 +10,8 @@ id: string, /** * Optional user-facing description for display in clients. */ -description: string | null, }; +description: string | null, +/** + * Whether the effective requirements allow selecting this profile. + */ +allowed: boolean, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 7434fe8653..932e44e267 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -378,6 +378,8 @@ pub struct PermissionProfileSummary { pub id: String, /// Optional user-facing description for display in clients. pub description: Option, + /// Whether the effective requirements allow selecting this profile. + pub allowed: bool, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 83eec5379c..287b4f89ce 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -202,7 +202,7 @@ Example with notification opt-out: - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with model-advertised string reasoning effort options in the catalog's intended progression order, `additionalSpeedTiers`, `serviceTiers`, optional `defaultServiceTier`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. Clients should preserve the `supportedReasoningEfforts` array order rather than deriving order from the effort names. - `modelProvider/capabilities/read` — read provider-level capabilities for the currently configured model provider. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. Pass `threadId` when showing feature state for an existing loaded thread so `enabled` is computed from that thread's refreshed config, including project-local config for the thread's cwd; if omitted, the server uses its default config resolution context. For non-beta flags, `displayName`/`description`/`announcement` are `null`. -- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. +- `permissionProfile/list` — beta; list available permission profile ids with optional display `description` text and an `allowed` flag reflecting effective requirements, using cursor pagination. Pass `cwd` when the caller needs project-local `[permissions.]` entries to be included in the current catalog view. - `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for currently supported feature keys. For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. Invalid keys will be ignored. - `environment/add` — experimental; add or replace a named remote environment by `environmentId` and `execServerUrl` for later selection by `thread/start` or `turn/start`; optional `connectTimeoutMs` overrides the WebSocket connection timeout; returns `{}` and does not change the default environment. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). Built-in presets do not select a model; the Plan preset selects medium reasoning effort. This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 2972d05f09..35df59d5cc 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -396,9 +396,6 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY; -use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort; #[cfg(test)] diff --git a/codex-rs/app-server/src/request_processors/catalog_processor.rs b/codex-rs/app-server/src/request_processors/catalog_processor.rs index f61d0ed4b6..9542340200 100644 --- a/codex-rs/app-server/src/request_processors/catalog_processor.rs +++ b/codex-rs/app-server/src/request_processors/catalog_processor.rs @@ -1,5 +1,5 @@ use super::*; -use codex_config::config_toml::ConfigToml; +use codex_core::config::permission_profile_catalog; use futures::StreamExt; #[derive(Clone)] @@ -434,35 +434,15 @@ impl CatalogRequestProcessor { .await .map_err(|err| internal_error(format!("failed to reload config: {err}")))?, }; - let effective_config: ConfigToml = config_layer_stack - .effective_config() - .try_into() - .map_err(|err| internal_error(format!("failed to read effective config: {err}")))?; - let mut profiles = vec![ - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), - description: None, - }, - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), - description: None, - }, - PermissionProfileSummary { - id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), - description: None, - }, - ]; - let mut configured_profiles = effective_config - .permissions + let profiles = permission_profile_catalog(&config_layer_stack) + .map_err(|err| internal_error(format!("failed to resolve permission profiles: {err}")))? .into_iter() - .flat_map(|permissions| permissions.entries) - .map(|(id, profile)| PermissionProfileSummary { - id, + .map(|profile| PermissionProfileSummary { + id: profile.id, description: profile.description, + allowed: profile.allowed, }) .collect::>(); - configured_profiles.sort_by(|left, right| left.id.cmp(&right.id)); - profiles.extend(configured_profiles); let total = profiles.len(); let effective_limit = limit.unwrap_or(total as u32).max(1) as usize; let effective_limit = effective_limit.min(total); diff --git a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs index 63e92af578..5d02cafa0a 100644 --- a/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs +++ b/codex-rs/app-server/tests/suite/v2/permission_profile_list.rs @@ -60,22 +60,27 @@ description = "Inspect without writes." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: "audit".to_string(), description: Some("Inspect without writes.".to_string()), + allowed: true, }, PermissionProfileSummary { id: "dev".to_string(), description: Some("Day-to-day coding work.".to_string()), + allowed: true, }, ], next_cursor: None, @@ -126,14 +131,17 @@ description = "Project-scoped profile." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, ], next_cursor: Some("3".to_string()), @@ -155,6 +163,7 @@ description = "Project-scoped profile." data: vec![PermissionProfileSummary { id: "project".to_string(), description: Some("Project-scoped profile.".to_string()), + allowed: true, }], next_cursor: None, } @@ -200,18 +209,22 @@ description = "Project-scoped profile." PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_READ_ONLY.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_WORKSPACE.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS.to_string(), description: None, + allowed: true, }, PermissionProfileSummary { id: "project".to_string(), description: Some("Project-scoped profile.".to_string()), + allowed: true, }, ], next_cursor: None, diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index c01c555519..1a9245b1aa 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -1,6 +1,8 @@ use crate::config::ConfigBuilder; use crate::config::ConfigOverrides; use crate::config::ConstraintError; +use crate::config::PermissionProfileCatalogEntry; +use crate::config::permission_profile_catalog; use codex_app_server_protocol::ConfigLayerSource; use codex_config::CONFIG_TOML_FILE; use codex_config::CloudConfigBundleLoadError; @@ -1733,6 +1735,74 @@ managed-standard = true Ok(()) } +#[tokio::test] +async fn permission_profile_catalog_marks_profiles_disallowed_by_requirements() -> anyhow::Result<()> +{ + let tmp = tempdir()?; + let codex_home = tmp.path().join("home"); + tokio::fs::create_dir_all(&codex_home).await?; + let requirements_path = tmp.path().join("requirements.toml"); + tokio::fs::write( + &requirements_path, + r#" +allowed_sandbox_modes = ["read-only", "workspace-write"] +default_permissions = "managed-standard" + +[allowed_permission_profiles] +managed-standard = true + +[permissions.managed-standard] +extends = ":workspace" + +[permissions.managed-disabled] +extends = ":workspace" +"#, + ) + .await?; + + let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?; + let mut overrides = LoaderOverrides::without_managed_config_for_tests(); + overrides.system_requirements_path = Some(requirements_path); + let config = ConfigBuilder::default() + .codex_home(codex_home) + .fallback_cwd(Some(cwd.to_path_buf())) + .loader_overrides(overrides) + .build() + .await?; + + assert_eq!( + permission_profile_catalog(&config.config_layer_stack)?, + vec![ + PermissionProfileCatalogEntry { + id: ":read-only".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: ":workspace".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: ":danger-full-access".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: "managed-disabled".to_string(), + description: None, + allowed: false, + }, + PermissionProfileCatalogEntry { + id: "managed-standard".to_string(), + description: None, + allowed: true, + }, + ] + ); + Ok(()) +} + #[tokio::test] async fn system_requirements_preserve_allowed_configured_permission_default() -> anyhow::Result<()> { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index e0dc04a48e..2d27d8d603 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3192,9 +3192,10 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( config.custom_permission_profiles, - vec![CustomPermissionProfileSummary { + vec![PermissionProfileCatalogEntry { id: "dev".to_string(), description: Some("Workspace access.".to_string()), + allowed: true, }] ); assert!( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 371257d185..95807c7884 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -148,6 +148,7 @@ pub mod edit; mod managed_features; mod network_proxy_spec; mod otel; +mod permission_profile_catalog; mod permissions; mod resolved_permission_profile; #[cfg(test)] @@ -164,6 +165,11 @@ pub use codex_sandboxing::system_bwrap_warning; pub use managed_features::ManagedFeatures; pub use network_proxy_spec::NetworkProxySpec; pub use network_proxy_spec::StartedNetworkProxy; +pub use permission_profile_catalog::PermissionProfileCatalogEntry; +pub use permission_profile_catalog::permission_profile_catalog; +use permission_profile_catalog::permission_profile_catalog_from_permissions; +use permission_profile_catalog::permission_profile_is_allowed; +use permission_profile_catalog::validate_permission_profile_for_deny_read; pub(crate) use permissions::is_builtin_permission_profile_name; pub(crate) use permissions::reject_unknown_builtin_permission_profile; pub(crate) use permissions::resolve_permission_profile; @@ -645,7 +651,7 @@ pub struct Config { pub explicit_permission_profile_mode: bool, /// User-defined permission profiles available from effective config. - pub custom_permission_profiles: Vec, + pub custom_permission_profiles: Vec, /// Configures who approval requests are routed to for review once they have /// been escalated. This does not disable separate safety checks such as @@ -2150,12 +2156,6 @@ pub struct AgentRoleConfig { pub nickname_candidates: Option>, } -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CustomPermissionProfileSummary { - pub id: String, - pub description: Option, -} - fn resolve_tool_suggest_config( config_toml: &ConfigToml, config_layer_stack: &ConfigLayerStack, @@ -3132,19 +3132,13 @@ impl Config { permission_config_syntax, Some(PermissionConfigSyntax::Profiles) ); - let custom_permission_profiles = cfg - .permissions - .as_ref() - .map_or_else(Vec::new, |permissions| { - permissions - .entries - .iter() - .map(|(id, profile)| CustomPermissionProfileSummary { - id: id.clone(), - description: profile.description.clone(), - }) - .collect() - }); + let custom_permission_profiles = permission_profile_catalog_from_permissions( + &config_layer_stack, + effective_permission_selection.profiles.as_ref(), + )? + .into_iter() + .filter(|profile| !is_builtin_permission_profile_name(&profile.id)) + .collect(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && effective_permission_selection.selected_profile_id.is_none(); let should_seed_legacy_workspace_roots = effective_permission_selection @@ -3656,20 +3650,10 @@ impl Config { constrained_permission_profile .value .add_validator(move |permission_profile| { - let mode = sandbox_mode_requirement_for_permission_profile(permission_profile); - match mode { - SandboxModeRequirement::ReadOnly - | SandboxModeRequirement::WorkspaceWrite => Ok(()), - SandboxModeRequirement::DangerFullAccess - | SandboxModeRequirement::ExternalSandbox => { - Err(ConstraintError::InvalidValue { - field_name: "sandbox_mode", - candidate: format!("{mode:?}"), - allowed: "[read-only, workspace-write]".to_string(), - requirement_source: requirement_source.clone(), - }) - } - } + validate_permission_profile_for_deny_read( + permission_profile, + &requirement_source, + ) }) .map_err(std::io::Error::from)?; } @@ -4122,6 +4106,15 @@ impl Config { pub fn bundled_skills_enabled(&self) -> bool { crate::skills::service::bundled_skills_enabled_from_stack(&self.config_layer_stack) } + + /// Returns whether effective requirements allow selecting a concrete profile. + pub fn permission_profile_allowed( + &self, + profile_id: &str, + permission_profile: &PermissionProfile, + ) -> bool { + permission_profile_is_allowed(&self.config_layer_stack, profile_id, permission_profile) + } } fn guardian_policy_config_from_requirements( diff --git a/codex-rs/core/src/config/permission_profile_catalog.rs b/codex-rs/core/src/config/permission_profile_catalog.rs new file mode 100644 index 0000000000..51b34c2640 --- /dev/null +++ b/codex-rs/core/src/config/permission_profile_catalog.rs @@ -0,0 +1,140 @@ +use codex_config::ConfigLayerStack; +use codex_config::RequirementSource; +use codex_config::SandboxModeRequirement; +use codex_config::Sourced; +use codex_config::permissions_toml::PermissionsToml; +use codex_config::sandbox_mode_requirement_for_permission_profile; +use codex_protocol::models::PermissionProfile; + +use super::ConstraintError; +use super::ConstraintResult; +use super::is_permission_allowed; +use super::merge_managed_permission_profiles; +use super::permissions::BUILT_IN_DANGER_FULL_ACCESS_PROFILE; +use super::permissions::BUILT_IN_READ_ONLY_PROFILE; +use super::permissions::BUILT_IN_WORKSPACE_PROFILE; +use super::permissions::compile_permission_profile_selection; +use super::permissions::validate_user_permission_profile_names; +use super::validate_required_permission_profile_catalog; + +/// A permission profile exposed to clients together with its effective availability. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PermissionProfileCatalogEntry { + pub id: String, + pub description: Option, + pub allowed: bool, +} + +/// Builds the effective permission profile catalog for a config layer stack. +pub fn permission_profile_catalog( + config_layer_stack: &ConfigLayerStack, +) -> std::io::Result> { + let permissions = config_layer_stack + .effective_config() + .get("permissions") + .cloned() + .map(toml::Value::try_into::) + .transpose() + .map_err(std::io::Error::other)?; + let requirements_toml = config_layer_stack.requirements_toml(); + let permissions = merge_managed_permission_profiles(permissions.as_ref(), requirements_toml)?; + + permission_profile_catalog_from_permissions(config_layer_stack, permissions.as_ref()) +} + +pub(super) fn permission_profile_catalog_from_permissions( + config_layer_stack: &ConfigLayerStack, + permissions: Option<&PermissionsToml>, +) -> std::io::Result> { + let requirements_toml = config_layer_stack.requirements_toml(); + validate_user_permission_profile_names(permissions)?; + validate_required_permission_profile_catalog(requirements_toml, permissions)?; + + let mut catalog = [ + (BUILT_IN_READ_ONLY_PROFILE, PermissionProfile::read_only()), + ( + BUILT_IN_WORKSPACE_PROFILE, + PermissionProfile::workspace_write(), + ), + ( + BUILT_IN_DANGER_FULL_ACCESS_PROFILE, + PermissionProfile::Disabled, + ), + ] + .into_iter() + .map(|(id, permission_profile)| PermissionProfileCatalogEntry { + id: id.to_string(), + description: None, + allowed: permission_profile_is_allowed(config_layer_stack, id, &permission_profile), + }) + .collect::>(); + + if let Some(permissions) = permissions { + catalog.extend(permissions.entries.iter().map(|(id, profile)| { + let mut warnings = Vec::new(); + let allowed = compile_permission_profile_selection( + Some(permissions), + id, + /*workspace_write*/ None, + &mut warnings, + ) + .map(|(file_system, network)| { + PermissionProfile::from_runtime_permissions(&file_system, network) + }) + .is_ok_and(|permission_profile| { + permission_profile_is_allowed(config_layer_stack, id, &permission_profile) + }); + PermissionProfileCatalogEntry { + id: id.clone(), + description: profile.description.clone(), + allowed, + } + })); + } + + Ok(catalog) +} + +pub(super) fn permission_profile_is_allowed( + config_layer_stack: &ConfigLayerStack, + profile_id: &str, + permission_profile: &PermissionProfile, +) -> bool { + let allowed_by_id = config_layer_stack + .requirements_toml() + .allowed_permission_profiles + .as_ref() + .is_none_or(|allowed| is_permission_allowed(allowed, profile_id)); + let allowed_by_sandbox_mode = config_layer_stack + .requirements() + .permission_profile + .can_set(permission_profile) + .is_ok(); + let allowed_by_filesystem = config_layer_stack + .requirements() + .filesystem + .as_ref() + .is_none_or(|Sourced { value, source }| { + value.deny_read.is_empty() + || validate_permission_profile_for_deny_read(permission_profile, source).is_ok() + }); + allowed_by_id && allowed_by_sandbox_mode && allowed_by_filesystem +} + +pub(super) fn validate_permission_profile_for_deny_read( + permission_profile: &PermissionProfile, + requirement_source: &RequirementSource, +) -> ConstraintResult<()> { + let mode = sandbox_mode_requirement_for_permission_profile(permission_profile); + match mode { + SandboxModeRequirement::ReadOnly | SandboxModeRequirement::WorkspaceWrite => Ok(()), + SandboxModeRequirement::DangerFullAccess | SandboxModeRequirement::ExternalSandbox => { + Err(ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: format!("{mode:?}"), + allowed: "[read-only, workspace-write]".to_string(), + requirement_source: requirement_source.clone(), + }) + } + } +} diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index c9cb3b108f..e26e8deccc 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -48,7 +48,7 @@ impl ChatWidget { } items.push(self.builtin_permission_mode_selection_item( full_access, - ":danger-no-sandbox", + ":danger-full-access", full_access.description.to_string(), AskForApproval::from(full_access.approval), ApprovalsReviewer::User, @@ -73,6 +73,7 @@ impl ChatWidget { .as_deref() .unwrap_or("Configured permission profile."), active_profile_id.as_deref(), + profile.allowed, ) }), ); @@ -141,6 +142,12 @@ impl ChatWidget { .can_set_permission_profile(&preset.permission_profile) .err() .map(|err| err.to_string()) + }) + .or_else(|| { + (!self + .config + .permission_profile_allowed(id, &preset.permission_profile)) + .then(|| "Disabled by requirements.".to_string()) }), ..Default::default() } @@ -151,6 +158,7 @@ impl ChatWidget { id: &str, description: &str, active_profile_id: Option<&str>, + allowed: bool, ) -> SelectionItem { let id_for_action = id.to_string(); let selection = PermissionProfileSelection { @@ -165,6 +173,7 @@ impl ChatWidget { is_current: active_profile_id == Some(id), actions: Self::permission_profile_selection_actions(selection), dismiss_on_select: true, + disabled_reason: (!allowed).then(|| "Disabled by requirements.".to_string()), ..Default::default() } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap index dac63d4e32..008669d3d3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_custom_profiles.snap @@ -4,18 +4,19 @@ expression: "render_bottom_popup(&chat, 80)" --- Update Model Permissions - 1. Ask for approval Codex can read and edit files in the current - workspace, and run commands. Approval is required - to access the internet or edit other files. - 2. Approve for me Only ask for actions detected as potentially - unsafe. - 3. Full Access Codex can edit files outside this workspace and - access the internet without asking for approval. - Exercise caution when using. - 4. Read Only Codex can read files in the current workspace. - Approval is required to edit files or access the - internet. -› 5. locked-down (current) Inspect and patch only approved workspace files. - 6. web-enabled Workspace profile with network access. + 1. Ask for approval Codex can read and edit files in the current + workspace, and run commands. Approval is required + to access the internet or edit other files. + 2. Approve for me Only ask for actions detected as potentially + unsafe. + 3. Full Access Codex can edit files outside this workspace and + access the internet without asking for approval. + Exercise caution when using. + 4. Read Only Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. +› 5. locked-down (current) Inspect and patch only approved workspace files. + web-enabled (disabled) Workspace profile with network access. (disabled: + Disabled by requirements.) Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap new file mode 100644 index 0000000000..1d2a74e956 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__profile_permissions_selection_popup_with_disallowed_full_access.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/chatwidget/tests/permissions.rs +expression: "render_bottom_popup(&chat, 80)" +--- + Update Model Permissions + + 1. Ask for approval Codex can read and edit files in the current + workspace, and run commands. Approval is required + to access the internet or edit other files. + 2. Approve for me Only ask for actions detected as potentially + unsafe. + Full Access (disabled) Codex can edit files outside this workspace and + access the internet without asking for approval. + Exercise caution when using. (disabled: Disabled + by requirements.) +› 3. Read Only (current) Codex can read files in the current workspace. + Approval is required to edit files or access the + internet. + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a9f6f7abe1..849da3bacf 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,5 +1,5 @@ use super::*; -use crate::legacy_core::config::CustomPermissionProfileSummary; +use crate::legacy_core::config::PermissionProfileCatalogEntry; use codex_protocol::models::ActivePermissionProfile; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; @@ -57,6 +57,10 @@ fn windows_sandbox_requirements_stack( }), ..Default::default() }; + requirements_stack(requirements_toml) +} + +fn requirements_stack(requirements_toml: codex_config::ConfigRequirementsToml) -> ConfigLayerStack { let mut requirements_with_sources = codex_config::ConfigRequirementsWithSources::default(); requirements_with_sources .merge_unset_fields(RequirementSource::Unknown, requirements_toml.clone()); @@ -104,18 +108,40 @@ async fn profile_permissions_selection_popup_snapshot() { ); } +#[tokio::test] +async fn profile_permissions_selection_popup_with_disallowed_full_access_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.config.explicit_permission_profile_mode = true; + chat.config.config_layer_stack = requirements_stack(codex_config::ConfigRequirementsToml { + allowed_sandbox_modes: Some(vec![ + codex_config::SandboxModeRequirement::ReadOnly, + codex_config::SandboxModeRequirement::WorkspaceWrite, + ]), + ..Default::default() + }); + + chat.open_permissions_popup(); + + assert_chatwidget_snapshot!( + "profile_permissions_selection_popup_with_disallowed_full_access", + render_bottom_popup(&chat, /*width*/ 80) + ); +} + #[tokio::test] async fn profile_permissions_selection_popup_with_custom_profiles_snapshot() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.explicit_permission_profile_mode = true; chat.config.custom_permission_profiles = vec![ - CustomPermissionProfileSummary { + PermissionProfileCatalogEntry { id: "locked-down".to_string(), description: Some("Inspect and patch only approved workspace files.".to_string()), + allowed: true, }, - CustomPermissionProfileSummary { + PermissionProfileCatalogEntry { id: "web-enabled".to_string(), description: Some("Workspace profile with network access.".to_string()), + allowed: false, }, ]; chat.config @@ -170,9 +196,10 @@ async fn profile_permissions_selection_emits_named_profile_event_only() { async fn profile_permissions_selection_emits_active_custom_profile() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.explicit_permission_profile_mode = true; - chat.config.custom_permission_profiles = vec![CustomPermissionProfileSummary { + chat.config.custom_permission_profiles = vec![PermissionProfileCatalogEntry { id: "locked-down".to_string(), description: None, + allowed: true, }]; chat.config .permissions @@ -256,7 +283,7 @@ async fn profile_permissions_full_access_opens_confirmation() { display_label, }), } if preset.id == "full-access" - && profile_id == ":danger-no-sandbox" + && profile_id == ":danger-full-access" && display_label == "Full Access" )); } From cb255c52e93bab74d35be7a093d0770a8dd66772 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 22 Jun 2026 13:55:32 -0700 Subject: [PATCH 44/86] [codex] handle request_user_input in app-server test client (#29476) ## Why `codex-app-server-test-client` previously treated `item/tool/requestUserInput` as an unsupported server request and terminated the connection. That made it impossible to use the client for end-to-end testing of interactive turns: an operator could observe the request, but could not answer it and confirm that the same turn resumed. ## What changed - Handle `ToolRequestUserInput` server requests in the test client's central request dispatcher. - Render numbered terminal choices, accept exact option labels, support free-form `Other` and text-only questions, and collect multiple answers. - Send a protocol-native `ToolRequestUserInputResponse` and continue streaming the active turn. - Fail clearly when interactive input is requested without a terminal. - Document the interactive behavior and add focused tests for option selection, free-form answers, multiple questions, and invalid-selection retries. ## Testing - `just test -p codex-app-server-test-client` - `just bazel-lock-check` - Manually exercised the app-server flow, selected `TUI`, observed `serverRequest/resolved`, and verified that the same turn completed with the selected answer. --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-test-client/Cargo.toml | 3 + codex-rs/app-server-test-client/README.md | 4 + codex-rs/app-server-test-client/src/lib.rs | 5 + .../src/request_user_input.rs | 148 ++++++++++++++++++ .../src/request_user_input_tests.rs | 125 +++++++++++++++ 6 files changed, 286 insertions(+) create mode 100644 codex-rs/app-server-test-client/src/request_user_input.rs create mode 100644 codex-rs/app-server-test-client/src/request_user_input_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index ae50cb5c85..9cbd3f31a3 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2136,6 +2136,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-utils-cli", + "pretty_assertions", "serde", "serde_json", "tokio", diff --git a/codex-rs/app-server-test-client/Cargo.toml b/codex-rs/app-server-test-client/Cargo.toml index 631b40207f..901afb70d0 100644 --- a/codex-rs/app-server-test-client/Cargo.toml +++ b/codex-rs/app-server-test-client/Cargo.toml @@ -26,3 +26,6 @@ uuid = { workspace = true, features = ["v4"] } [lib] doctest = false + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/app-server-test-client/README.md b/codex-rs/app-server-test-client/README.md index 5499706b93..44687672a0 100644 --- a/codex-rs/app-server-test-client/README.md +++ b/codex-rs/app-server-test-client/README.md @@ -18,6 +18,10 @@ cargo run -p codex-app-server-test-client -- \ cargo run -p codex-app-server-test-client -- model-list ``` +`send-message` and `send-message-v2` handle `request_user_input` server requests interactively. +When Codex asks a question, choose a numbered option (or `o` for a free-form answer when offered) +and the client will send the response and continue streaming the same turn. + ## Testing Plugin Analytics The `plugin-analytics-smoke` command exercises `plugin/installed`, plugin diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index 856d75585e..8e46308575 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -91,6 +91,7 @@ mod loopback_responses_server; mod plugin_analytics_capture; mod plugin_analytics_mutation_smoke; mod plugin_analytics_smoke; +mod request_user_input; const NOTIFICATIONS_TO_OPT_OUT: &[&str] = &[ // v2 item deltas. @@ -2040,6 +2041,10 @@ impl CodexClient { ServerRequest::FileChangeRequestApproval { request_id, params } => { self.approve_file_change_request(request_id, params)?; } + ServerRequest::ToolRequestUserInput { request_id, params } => { + let response = request_user_input::prompt_for_answers(¶ms)?; + self.send_server_request_response(request_id, &response)?; + } other => { bail!("received unsupported server request: {other:?}"); } diff --git a/codex-rs/app-server-test-client/src/request_user_input.rs b/codex-rs/app-server-test-client/src/request_user_input.rs new file mode 100644 index 0000000000..0302f33600 --- /dev/null +++ b/codex-rs/app-server-test-client/src/request_user_input.rs @@ -0,0 +1,148 @@ +use std::collections::HashMap; +use std::io; +use std::io::BufRead; +use std::io::IsTerminal; +use std::io::Write; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use codex_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputResponse; + +pub(super) fn prompt_for_answers( + params: &ToolRequestUserInputParams, +) -> Result { + let stdin = io::stdin(); + if !stdin.is_terminal() { + bail!("request_user_input requires an interactive stdin terminal"); + } + + let stdout = io::stdout(); + prompt_for_answers_with(&mut stdin.lock(), &mut stdout.lock(), params) +} + +fn prompt_for_answers_with( + input: &mut impl BufRead, + output: &mut impl Write, + params: &ToolRequestUserInputParams, +) -> Result { + writeln!( + output, + "\n[request_user_input for thread {}, turn {}]", + params.thread_id, params.turn_id + )?; + if let Some(auto_resolution_ms) = params.auto_resolution_ms { + writeln!( + output, + "The app-server may auto-resolve this request after {auto_resolution_ms} ms." + )?; + } + + let mut answers = HashMap::new(); + for question in ¶ms.questions { + writeln!(output, "\n{}: {}", question.header, question.question)?; + let options = question + .options + .as_deref() + .filter(|options| !options.is_empty()); + let answer_values = if let Some(options) = options { + for (index, option) in options.iter().enumerate() { + writeln!( + output, + " {}. {} - {}", + index + 1, + option.label, + option.description + )?; + } + if question.is_other { + writeln!(output, " o. Other (free-form)")?; + } + + loop { + if question.is_other { + write!(output, "Choose 1-{} or o: ", options.len())?; + } else { + write!(output, "Choose 1-{}: ", options.len())?; + } + output.flush()?; + + let mut line = String::new(); + if input + .read_line(&mut line) + .context("failed to read request_user_input selection")? + == 0 + { + bail!("stdin closed while waiting for request_user_input selection"); + } + let selection = line.trim(); + + if let Ok(index) = selection.parse::() + && let Some(option) = index.checked_sub(1).and_then(|index| options.get(index)) + { + break vec![option.label.clone()]; + } + + if let Some(option) = options + .iter() + .find(|option| option.label.eq_ignore_ascii_case(selection)) + { + break vec![option.label.clone()]; + } + + if question.is_other && selection.eq_ignore_ascii_case("o") { + write!(output, "Other: ")?; + output.flush()?; + line.clear(); + if input + .read_line(&mut line) + .context("failed to read request_user_input free-form answer")? + == 0 + { + bail!("stdin closed while waiting for request_user_input free-form answer"); + } + let answer = line.trim(); + if !answer.is_empty() { + break vec![format!("user_note: {answer}")]; + } + } + + writeln!(output, "Invalid selection; try again.")?; + } + } else { + loop { + write!(output, "Answer: ")?; + output.flush()?; + + let mut line = String::new(); + if input + .read_line(&mut line) + .context("failed to read request_user_input answer")? + == 0 + { + bail!("stdin closed while waiting for request_user_input answer"); + } + let answer = line.trim(); + if !answer.is_empty() { + break vec![format!("user_note: {answer}")]; + } + writeln!(output, "Answer cannot be empty; try again.")?; + } + }; + + answers.insert( + question.id.clone(), + ToolRequestUserInputAnswer { + answers: answer_values, + }, + ); + } + + Ok(ToolRequestUserInputResponse { answers }) +} + +#[cfg(test)] +#[path = "request_user_input_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-test-client/src/request_user_input_tests.rs b/codex-rs/app-server-test-client/src/request_user_input_tests.rs new file mode 100644 index 0000000000..d8b2a4699d --- /dev/null +++ b/codex-rs/app-server-test-client/src/request_user_input_tests.rs @@ -0,0 +1,125 @@ +use std::collections::HashMap; +use std::io::Cursor; + +use codex_app_server_protocol::ToolRequestUserInputAnswer; +use codex_app_server_protocol::ToolRequestUserInputOption; +use codex_app_server_protocol::ToolRequestUserInputParams; +use codex_app_server_protocol::ToolRequestUserInputQuestion; +use codex_app_server_protocol::ToolRequestUserInputResponse; +use pretty_assertions::assert_eq; + +use super::prompt_for_answers_with; + +#[test] +fn collects_option_and_free_form_answers() { + let params = ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + questions: vec![ + ToolRequestUserInputQuestion { + id: "target".to_string(), + header: "Target".to_string(), + question: "Which target?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ + ToolRequestUserInputOption { + label: "Core".to_string(), + description: "Inspect core".to_string(), + }, + ToolRequestUserInputOption { + label: "TUI".to_string(), + description: "Inspect TUI".to_string(), + }, + ]), + }, + ToolRequestUserInputQuestion { + id: "details".to_string(), + header: "Details".to_string(), + question: "Anything else?".to_string(), + is_other: true, + is_secret: false, + options: None, + }, + ], + auto_resolution_ms: Some(60_000), + }; + let mut input = Cursor::new(b"2\ninclude snapshots\n"); + let mut output = Vec::new(); + + let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap(); + + assert_eq!( + response, + ToolRequestUserInputResponse { + answers: HashMap::from([ + ( + "target".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["TUI".to_string()], + }, + ), + ( + "details".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["user_note: include snapshots".to_string()], + }, + ), + ]), + } + ); + assert_eq!( + String::from_utf8(output).unwrap(), + concat!( + "\n[request_user_input for thread thread-1, turn turn-1]\n", + "The app-server may auto-resolve this request after 60000 ms.\n", + "\nTarget: Which target?\n", + " 1. Core - Inspect core\n", + " 2. TUI - Inspect TUI\n", + " o. Other (free-form)\n", + "Choose 1-2 or o: ", + "\nDetails: Anything else?\n", + "Answer: ", + ) + ); +} + +#[test] +fn retries_invalid_selection_and_collects_other_answer() { + let params = ToolRequestUserInputParams { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "item-1".to_string(), + questions: vec![ToolRequestUserInputQuestion { + id: "target".to_string(), + header: "Target".to_string(), + question: "Which target?".to_string(), + is_other: true, + is_secret: false, + options: Some(vec![ToolRequestUserInputOption { + label: "Core".to_string(), + description: "Inspect core".to_string(), + }]), + }], + auto_resolution_ms: None, + }; + let mut input = Cursor::new(b"9\no\nSDK wrapper\n"); + let mut output = Vec::new(); + + let response = prompt_for_answers_with(&mut input, &mut output, ¶ms).unwrap(); + + assert_eq!( + response, + ToolRequestUserInputResponse { + answers: HashMap::from([( + "target".to_string(), + ToolRequestUserInputAnswer { + answers: vec!["user_note: SDK wrapper".to_string()], + }, + )]), + } + ); + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Invalid selection; try again.")); +} From 6db576895a808608d65578f9d704971601c23c9b Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 22 Jun 2026 14:05:50 -0700 Subject: [PATCH 45/86] fix(config): address permission profile review follow-ups (#29479) ## Summary - rename `Config::permission_profile_allowed` to `is_permission_profile_allowed` - use `BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS` in the TUI and its assertion - follow up on the late review comments from #26678 The previous `:danger-no-sandbox` value was an invalid built-in profile ID. #26678 corrected it to `:danger-full-access`; this PR centralizes the value to prevent future drift. ## Testing - Not run per request; `cargo fmt` only Co-authored-by: Codex --- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/tui/src/chatwidget/permissions_menu.rs | 5 +++-- codex-rs/tui/src/chatwidget/tests/permissions.rs | 3 ++- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 95807c7884..953d0af1a1 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -4108,7 +4108,7 @@ impl Config { } /// Returns whether effective requirements allow selecting a concrete profile. - pub fn permission_profile_allowed( + pub fn is_permission_profile_allowed( &self, profile_id: &str, permission_profile: &PermissionProfile, diff --git a/codex-rs/tui/src/chatwidget/permissions_menu.rs b/codex-rs/tui/src/chatwidget/permissions_menu.rs index e26e8deccc..c97721819e 100644 --- a/codex-rs/tui/src/chatwidget/permissions_menu.rs +++ b/codex-rs/tui/src/chatwidget/permissions_menu.rs @@ -1,4 +1,5 @@ use super::*; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; impl ChatWidget { pub(super) fn open_permission_profiles_popup(&mut self) { @@ -48,7 +49,7 @@ impl ChatWidget { } items.push(self.builtin_permission_mode_selection_item( full_access, - ":danger-full-access", + BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS, full_access.description.to_string(), AskForApproval::from(full_access.approval), ApprovalsReviewer::User, @@ -146,7 +147,7 @@ impl ChatWidget { .or_else(|| { (!self .config - .permission_profile_allowed(id, &preset.permission_profile)) + .is_permission_profile_allowed(id, &preset.permission_profile)) .then(|| "Disabled by requirements.".to_string()) }), ..Default::default() diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 849da3bacf..ebac595671 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -1,6 +1,7 @@ use super::*; use crate::legacy_core::config::PermissionProfileCatalogEntry; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; @@ -283,7 +284,7 @@ async fn profile_permissions_full_access_opens_confirmation() { display_label, }), } if preset.id == "full-access" - && profile_id == ":danger-full-access" + && profile_id == BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS && display_label == "Full Access" )); } From 527ccb4a5df8a75d6aeb4c6ae4341f99a1205a1e Mon Sep 17 00:00:00 2001 From: Winston Howes Date: Mon, 22 Jun 2026 14:16:48 -0700 Subject: [PATCH 46/86] Honor startup custom CA bundles with managed MITM (#29014) ## Why When Codex starts with a custom CA override such as `SSL_CERT_FILE=/path/to/corp-ca.pem codex`, `rustls-native-certs` treats that override as a replacement for the platform trust store. The managed proxy then rewrites child CA variables to its generated bundle, so the custom root or the ordinary platform roots can be lost. The proxy's upstream TLS connector must trust the same roots or private and corporate upstream certificates still fail after interception. ## What - load platform-native roots without consulting inherited CA override variables - append certificates from the existing curated startup CA file variables and `SSL_CERT_DIR` - share those platform and startup roots with the MITM upstream rustls connector - exclude the Codex managed MITM CA from upstream trust - normalize OpenSSL `TRUSTED CERTIFICATE` blocks while dropping trailing trust metadata - skip an inherited current Codex-managed bundle so nested launches do not duplicate it - append the Codex managed MITM CA to the child-facing bundle - copy certificate material only, so a private key or unrelated text colocated in a startup file is never exposed through the public bundle This is intentionally limited to CA paths present when Codex starts. It does not parse inline shell assignments or add per-command bundle materialization. This changes only `codex-network-proxy` and dependency metadata; it does not touch `codex-core` or sandbox orchestration. ## Validation - `just test -p codex-network-proxy` - includes an end-to-end upstream TLS test using a server trusted only by the startup custom CA - `just fix -p codex-network-proxy` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 2 + codex-rs/network-proxy/Cargo.toml | 6 + codex-rs/network-proxy/src/certs.rs | 277 +++++++++++++++++-- codex-rs/network-proxy/src/lib.rs | 1 + codex-rs/network-proxy/src/mitm.rs | 12 +- codex-rs/network-proxy/src/native_certs.rs | 260 +++++++++++++++++ codex-rs/network-proxy/src/proxy.rs | 5 +- codex-rs/network-proxy/src/upstream.rs | 34 ++- codex-rs/network-proxy/src/upstream_tests.rs | 111 ++++++++ 9 files changed, 678 insertions(+), 30 deletions(-) create mode 100644 codex-rs/network-proxy/src/native_certs.rs create mode 100644 codex-rs/network-proxy/src/upstream_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9cbd3f31a3..03ed82f1bf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3530,6 +3530,8 @@ dependencies = [ "rama-tls-rustls", "rama-unix", "rustls-native-certs", + "schannel", + "security-framework 3.5.1", "serde", "serde_json", "sha2 0.10.9", diff --git a/codex-rs/network-proxy/Cargo.toml b/codex-rs/network-proxy/Cargo.toml index cd32ff92b2..cc6a8de710 100644 --- a/codex-rs/network-proxy/Cargo.toml +++ b/codex-rs/network-proxy/Cargo.toml @@ -44,3 +44,9 @@ tempfile = { workspace = true } [target.'cfg(target_family = "unix")'.dependencies] rama-unix = { version = "=0.3.0-alpha.4" } + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3" + +[target.'cfg(windows)'.dependencies] +schannel = "0.1" diff --git a/codex-rs/network-proxy/src/certs.rs b/codex-rs/network-proxy/src/certs.rs index 001469aa9a..2ff7cc03b4 100644 --- a/codex-rs/network-proxy/src/certs.rs +++ b/codex-rs/network-proxy/src/certs.rs @@ -23,6 +23,7 @@ use rama_tls_rustls::server::TlsAcceptorData; use sha2::Digest as _; use sha2::Sha256; use std::collections::HashMap; +use std::collections::HashSet; use std::fs; use std::fs::File; use std::fs::OpenOptions; @@ -30,6 +31,7 @@ use std::io::Write; use std::net::IpAddr; use std::path::Path; use std::path::PathBuf; +use std::sync::Arc; use std::time::SystemTime; use std::time::UNIX_EPOCH; use tracing::info; @@ -101,6 +103,7 @@ const MANAGED_MITM_CA_DIR: &str = "proxy"; const MANAGED_MITM_CA_CERT: &str = "ca.pem"; const MANAGED_MITM_CA_KEY: &str = "ca.key"; const MANAGED_MITM_CA_TRUST_BUNDLE_PREFIX: &str = "ca-bundle"; +pub(crate) const SSL_CERT_DIR_ENV_KEY: &str = "SSL_CERT_DIR"; // Best-effort compatibility set for common child toolchains that accept a CA bundle path. // This is intentionally curated rather than pretending to cover every TLS client. @@ -117,6 +120,14 @@ pub const CUSTOM_CA_ENV_KEYS: [&str; 10] = [ "NPM_CONFIG_CAFILE", ]; +pub(crate) fn ca_env_from_process() -> HashMap<&'static str, String> { + CUSTOM_CA_ENV_KEYS + .into_iter() + .chain([SSL_CERT_DIR_ENV_KEY]) + .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) + .collect() +} + /// Immutable managed MITM CA bundle path plus startup TLS env values. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct ManagedMitmCaTrustBundle { @@ -146,15 +157,13 @@ fn managed_ca_trust_bundle_for_cert_path( cert_path: &Path, env: &HashMap<&'static str, String>, ) -> Result { - let startup_env_values = CUSTOM_CA_ENV_KEYS - .into_iter() - .filter_map(|key| { - env.get(key) - .filter(|value| !value.is_empty()) - .map(|value| (key, value.clone())) - }) - .collect(); - let trust_bundle = build_managed_ca_trust_bundle(cert_path)?; + let startup_env_values = startup_ca_file_env_values(env); + let startup_cert_dir = env + .get(SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty()) + .map(String::as_str); + let trust_bundle = + build_managed_ca_trust_bundle(cert_path, &startup_env_values, startup_cert_dir)?; let path = persist_managed_ca_trust_bundle(cert_path, &trust_bundle)?; Ok(ManagedMitmCaTrustBundle { @@ -163,21 +172,188 @@ fn managed_ca_trust_bundle_for_cert_path( }) } -fn build_managed_ca_trust_bundle(managed_ca_cert_path: &Path) -> Result { +pub(crate) fn upstream_tls_root_store( + env: &HashMap<&'static str, String>, +) -> Result> { + let (managed_ca_cert_path, _) = managed_ca_paths()?; + upstream_tls_root_store_for_cert_path(&managed_ca_cert_path, env) +} + +pub(crate) fn upstream_tls_root_store_for_cert_path( + managed_ca_cert_path: &Path, + env: &HashMap<&'static str, String>, +) -> Result> { + let startup_env_values = startup_ca_file_env_values(env); + let startup_cert_dir = env + .get(SSL_CERT_DIR_ENV_KEY) + .filter(|value| !value.is_empty()) + .map(String::as_str); + let certificates = load_platform_and_startup_root_certificates( + managed_ca_cert_path, + &startup_env_values, + startup_cert_dir, + )?; + let mut roots = rustls::RootCertStore::empty(); + let (_, ignored) = roots.add_parsable_certificates(certificates); + if ignored > 0 { + warn!( + ignored_root_count = ignored, + "ignored invalid platform or startup roots for MITM upstream TLS" + ); + } + Ok(Arc::new(roots)) +} + +fn startup_ca_file_env_values( + env: &HashMap<&'static str, String>, +) -> HashMap<&'static str, String> { + CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| { + env.get(key) + .filter(|value| !value.is_empty()) + .map(|value| (key, value.clone())) + }) + .collect() +} + +fn build_managed_ca_trust_bundle( + managed_ca_cert_path: &Path, + startup_env_values: &HashMap<&'static str, String>, + startup_cert_dir: Option<&str>, +) -> Result { let mut trust_bundle = String::new(); + for cert in load_platform_and_startup_root_certificates( + managed_ca_cert_path, + startup_env_values, + startup_cert_dir, + )? { + push_certificate_pem(&mut trust_bundle, cert.as_ref()); + } + append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; + Ok(trust_bundle) +} + +fn load_platform_and_startup_root_certificates( + managed_ca_cert_path: &Path, + startup_env_values: &HashMap<&'static str, String>, + startup_cert_dir: Option<&str>, +) -> Result>> { + let managed_ca_cert = fs::read(managed_ca_cert_path).with_context(|| { + format!( + "failed to read managed MITM CA certificate: {}", + managed_ca_cert_path.display() + ) + })?; + let managed_ca_cert = CertificateDer::from_pem_slice(&managed_ca_cert) + .context("failed to parse managed MITM CA certificate")?; let rustls_native_certs::CertificateResult { certs, errors, .. } = - rustls_native_certs::load_native_certs(); + crate::native_certs::load_platform_native_certs(); if !errors.is_empty() { warn!( native_root_error_count = errors.len(), "encountered errors while loading native root certificates for MITM trust bundle" ); } - for cert in certs { - push_certificate_pem(&mut trust_bundle, cert.as_ref()); + let mut certificates = certs; + let mut appended_startup_paths = HashSet::new(); + for path in CUSTOM_CA_ENV_KEYS + .into_iter() + .filter_map(|key| startup_env_values.get(key)) + .map(PathBuf::from) + { + if path != managed_ca_cert_path + && !is_current_generated_trust_bundle_path(&path, managed_ca_cert_path) + && appended_startup_paths.insert(path.clone()) + { + certificates.extend(read_ca_certificates(&path)?); + } } - append_pem_file(&mut trust_bundle, managed_ca_cert_path)?; - Ok(trust_bundle) + if let Some(startup_cert_dir) = startup_cert_dir { + for path in std::env::split_paths(startup_cert_dir) { + if appended_startup_paths.insert(path.clone()) { + certificates.extend(load_ca_directory_certificates(&path)); + } + } + } + let mut seen = HashSet::new(); + certificates.retain(|cert| cert != &managed_ca_cert && seen.insert(cert.as_ref().to_vec())); + Ok(certificates) +} + +fn read_ca_certificates(path: &Path) -> Result>> { + let pem = fs::read(path) + .with_context(|| format!("failed to read startup CA bundle: {}", path.display()))?; + let pem = String::from_utf8_lossy(&pem); + let contains_trusted_certificates = pem.contains("TRUSTED CERTIFICATE"); + let normalized_pem = pem + .replace("BEGIN TRUSTED CERTIFICATE", "BEGIN CERTIFICATE") + .replace("END TRUSTED CERTIFICATE", "END CERTIFICATE"); + let certs = CertificateDer::pem_slice_iter(normalized_pem.as_bytes()) + .collect::, _>>() + .with_context(|| format!("failed to parse startup CA bundle: {}", path.display()))?; + if certs.is_empty() { + return Err(anyhow!( + "startup CA bundle contained no certificates: {}", + path.display() + )); + } + certs + .into_iter() + .map(|cert| { + let cert = if contains_trusted_certificates { + first_der_item(cert.as_ref()).ok_or_else(|| { + anyhow!( + "startup CA bundle contained an invalid trusted certificate: {}", + path.display() + ) + })? + } else { + cert.as_ref() + }; + Ok(CertificateDer::from(cert.to_vec())) + }) + .collect() +} + +fn load_ca_directory_certificates(path: &Path) -> Vec> { + let rustls_native_certs::CertificateResult { certs, errors, .. } = + rustls_native_certs::load_certs_from_paths(None, Some(path)); + if !errors.is_empty() { + warn!( + ca_path = %path.display(), + ca_error_count = errors.len(), + "encountered errors while loading startup CA directory" + ); + } + certs +} + +fn first_der_item(der: &[u8]) -> Option<&[u8]> { + der_item_length(der).map(|length| &der[..length]) +} + +fn der_item_length(der: &[u8]) -> Option { + let &length_octet = der.get(1)?; + if length_octet & 0x80 == 0 { + return Some(2 + usize::from(length_octet)).filter(|length| *length <= der.len()); + } + + let length_octets = usize::from(length_octet & 0x7f); + if length_octets == 0 { + return None; + } + + let length_end = 2usize.checked_add(length_octets)?; + let mut content_length = 0usize; + for &byte in der.get(2..length_end)? { + content_length = content_length + .checked_mul(256)? + .checked_add(usize::from(byte))?; + } + length_end + .checked_add(content_length) + .filter(|length| *length <= der.len()) } fn is_current_generated_trust_bundle_path(path: &Path, managed_ca_cert_path: &Path) -> bool { @@ -508,17 +684,80 @@ mod tests { } #[test] - fn managed_ca_trust_bundle_records_startup_ca_env_values() { + fn managed_ca_trust_bundle_appends_startup_file_and_directory_certificates() { let dir = tempdir().unwrap(); let managed_ca_cert_path = dir.path().join("ca.pem"); - fs::write(&managed_ca_cert_path, "managed ca\n").unwrap(); - let env = HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]); + let startup_ca_bundle_path = dir.path().join("startup-ca.pem"); + let startup_ca_dir = dir.path().join("startup-certs"); + let (managed_ca_cert, _) = generate_ca().unwrap(); + let (startup_ca_cert, startup_ca_key) = generate_ca().unwrap(); + let (directory_ca_cert, _) = generate_ca().unwrap(); + let mut trusted_ca_der = CertificateDer::from_pem_slice(startup_ca_cert.as_bytes()) + .unwrap() + .as_ref() + .to_vec(); + trusted_ca_der.extend_from_slice(&[0x30, 0x00]); + let mut trusted_ca_cert = String::new(); + push_certificate_pem(&mut trusted_ca_cert, &trusted_ca_der); + let trusted_ca_cert = trusted_ca_cert.replace("CERTIFICATE", "TRUSTED CERTIFICATE"); + fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap(); + fs::write( + &startup_ca_bundle_path, + format!("{trusted_ca_cert}{startup_ca_key}"), + ) + .unwrap(); + fs::create_dir(&startup_ca_dir).unwrap(); + fs::write(startup_ca_dir.join("directory-ca.pem"), &directory_ca_cert).unwrap(); + let startup_ca_bundle_path = startup_ca_bundle_path.display().to_string(); + let env = HashMap::from([ + ("SSL_CERT_FILE", startup_ca_bundle_path.clone()), + (SSL_CERT_DIR_ENV_KEY, startup_ca_dir.display().to_string()), + ]); + let trust_bundle = managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); assert_eq!( trust_bundle.startup_env_values, - HashMap::from([("SSL_CERT_FILE", "/tmp/startup-ca.pem".to_string())]) + HashMap::from([("SSL_CERT_FILE", startup_ca_bundle_path)]) ); + let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap(); + let baseline_certs = CertificateDer::pem_slice_iter(baseline_bundle.as_bytes()) + .collect::, _>>() + .unwrap(); + let expected_certs = [&startup_ca_cert, &directory_ca_cert, &managed_ca_cert] + .map(|cert| CertificateDer::from_pem_slice(cert.as_bytes()).unwrap()); + + assert!( + expected_certs + .iter() + .all(|cert| baseline_certs.contains(cert)) + ); + assert!(!baseline_bundle.contains(&startup_ca_key)); + assert!(!baseline_bundle.contains("TRUSTED CERTIFICATE")); + } + + #[test] + fn managed_ca_trust_bundle_skips_inherited_current_bundle() { + let dir = tempdir().unwrap(); + let managed_ca_cert_path = dir.path().join("ca.pem"); + let inherited_bundle_path = dir.path().join("ca-bundle-parent.pem"); + let (managed_ca_cert, _) = generate_ca().unwrap(); + fs::write(&managed_ca_cert_path, &managed_ca_cert).unwrap(); + fs::write( + &inherited_bundle_path, + format!("parent roots\n{managed_ca_cert}"), + ) + .unwrap(); + let env = HashMap::from([( + "REQUESTS_CA_BUNDLE", + inherited_bundle_path.display().to_string(), + )]); + + let trust_bundle = + managed_ca_trust_bundle_for_cert_path(&managed_ca_cert_path, &env).unwrap(); + let baseline_bundle = fs::read_to_string(&trust_bundle.path).unwrap(); + + assert_eq!(baseline_bundle.matches(&managed_ca_cert).count(), 1); } #[cfg(unix)] diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index e80b154a5e..c8950f1f55 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -6,6 +6,7 @@ mod connect_policy; mod http_proxy; mod mitm; mod mitm_hook; +mod native_certs; mod network_policy; mod policy; mod proxy; diff --git a/codex-rs/network-proxy/src/mitm.rs b/codex-rs/network-proxy/src/mitm.rs index 345c5b5032..70175ca706 100644 --- a/codex-rs/network-proxy/src/mitm.rs +++ b/codex-rs/network-proxy/src/mitm.rs @@ -107,11 +107,19 @@ impl MitmState { // generate/load a local CA and issue per-host leaf certs so we can terminate TLS and // apply policy. let ca = ManagedMitmCa::load_or_create()?; + let upstream_tls_root_store = + crate::certs::upstream_tls_root_store(&crate::certs::ca_env_from_process())?; let upstream = if config.allow_upstream_proxy { - UpstreamClient::from_env_proxy_with_allow_local_binding(config.allow_local_binding) + UpstreamClient::from_env_proxy_with_allow_local_binding( + config.allow_local_binding, + upstream_tls_root_store, + ) } else { - UpstreamClient::direct_with_allow_local_binding(config.allow_local_binding) + UpstreamClient::direct_with_allow_local_binding( + config.allow_local_binding, + upstream_tls_root_store, + ) }; Ok(Self { diff --git a/codex-rs/network-proxy/src/native_certs.rs b/codex-rs/network-proxy/src/native_certs.rs new file mode 100644 index 0000000000..6c8d45005d --- /dev/null +++ b/codex-rs/network-proxy/src/native_certs.rs @@ -0,0 +1,260 @@ +#[cfg(any(target_os = "macos", windows))] +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rustls_native_certs::CertificateResult; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::Error; +#[cfg(any(target_os = "macos", windows))] +use rustls_native_certs::ErrorKind; + +// `rustls_native_certs::load_native_certs()` first consults SSL_CERT_FILE and +// SSL_CERT_DIR. Load platform roots directly so a startup custom CA can be +// layered onto the managed bundle without replacing the platform trust store. +#[cfg(all(unix, not(target_os = "macos")))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + let mut result = + rustls_native_certs::load_certs_from_paths(platform_cert_file().as_deref(), None); + for cert_dir in platform_cert_dirs() { + extend_certificate_result( + &mut result, + rustls_native_certs::load_certs_from_paths(None, Some(&cert_dir)), + ); + } + dedupe_certs(&mut result); + result +} + +#[cfg(target_os = "macos")] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use security_framework::trust_settings::Domain; + use security_framework::trust_settings::TrustSettings; + use security_framework::trust_settings::TrustSettingsForCertificate; + use std::collections::BTreeMap; + + let mut result = CertificateResult::default(); + let mut all_certs = BTreeMap::new(); + for domain in &[Domain::User, Domain::Admin, Domain::System] { + let ts = TrustSettings::new(*domain); + let iter = match ts.iter() { + Ok(iter) => iter, + Err(err) => { + result.errors.push(Error { + context: match domain { + Domain::User => "failed to load user trust settings", + Domain::Admin => "failed to load admin trust settings", + Domain::System => "failed to load system trust settings", + }, + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + + for cert in iter { + let der = cert.to_der(); + let trusted = match ts.tls_trust_settings_for_certificate(&cert) { + Ok(trusted) => trusted.unwrap_or(TrustSettingsForCertificate::TrustRoot), + Err(err) => { + result.errors.push(Error { + context: "certificate not trusted", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + all_certs.entry(der).or_insert(trusted); + } + } + + for (der, trusted) in all_certs { + use TrustSettingsForCertificate::*; + + if let TrustRoot | TrustAsRoot = trusted { + result.certs.push(CertificateDer::from(der)); + } + } + result +} + +#[cfg(windows)] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + use schannel::cert_store::CertStore; + + let mut result = CertificateResult::default(); + let current_user_store = match CertStore::open_current_user("ROOT") { + Ok(store) => store, + Err(err) => { + result.errors.push(Error { + context: "failed to open current user certificate store", + kind: ErrorKind::Os(err.into()), + }); + return result; + } + }; + + for cert in current_user_store.certs() { + let valid_uses = match cert.valid_uses() { + Ok(valid_uses) => valid_uses, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate valid uses", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + let is_time_valid = match cert.is_time_valid() { + Ok(is_time_valid) => is_time_valid, + Err(err) => { + result.errors.push(Error { + context: "failed to inspect certificate time validity", + kind: ErrorKind::Os(err.into()), + }); + continue; + } + }; + if usable_for_rustls(valid_uses) && is_time_valid { + result + .certs + .push(CertificateDer::from(cert.to_der().to_vec())); + } + } + result +} + +#[cfg(not(any(all(unix, not(target_os = "macos")), target_os = "macos", windows)))] +pub(crate) fn load_platform_native_certs() -> CertificateResult { + rustls_native_certs::load_native_certs() +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn extend_certificate_result(result: &mut CertificateResult, extra: CertificateResult) { + result.certs.extend(extra.certs); + result.errors.extend(extra.errors); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn dedupe_certs(result: &mut CertificateResult) { + result.certs.sort_unstable_by(|a, b| a.cmp(b)); + result.certs.dedup(); +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_file() -> Option { + PLATFORM_CERTIFICATE_FILE_NAMES + .iter() + .map(std::path::Path::new) + .find(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn platform_cert_dirs() -> impl Iterator { + PLATFORM_CERTIFICATE_DIRS + .iter() + .map(std::path::Path::new) + .filter(|path| path.exists()) + .map(std::path::Path::to_path_buf) +} + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &[ + "/etc/ssl/certs", + "/etc/pki/tls/certs", + "/etc/security/certificates", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs", "/usr/local/share/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + any(target_os = "illumos", target_os = "solaris") +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/certs/CA"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/openssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "aix"))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/var/ssl/certs"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "illumos", + target_os = "solaris", + target_os = "netbsd", + target_os = "aix" + )) +))] +const PLATFORM_CERTIFICATE_DIRS: &[&str] = &["/etc/ssl/certs"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "linux"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &[ + "/etc/ssl/certs/ca-certificates.crt", + "/etc/pki/ca-trust/extracted/pem/tls-ca-bundle.pem", + "/etc/pki/tls/certs/ca-bundle.crt", + "/etc/ssl/ca-bundle.pem", + "/etc/pki/tls/cacert.pem", + "/etc/ssl/cert.pem", + "/opt/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/certs/cacert.pem", +]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "freebsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "dragonfly"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/usr/local/share/certs/ca-root-nss.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "netbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/openssl/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "openbsd"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "solaris"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "illumos"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/etc/ssl/cacert.pem", "/etc/certs/ca-certificates.crt"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "android"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = + &["/data/data/com.termux/files/usr/etc/tls/cert.pem"]; + +#[cfg(all(unix, not(target_os = "macos"), target_os = "haiku"))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/boot/system/data/ssl/CARootCertificates.pem"]; + +#[cfg(all( + unix, + not(target_os = "macos"), + not(any( + target_os = "linux", + target_os = "freebsd", + target_os = "dragonfly", + target_os = "netbsd", + target_os = "openbsd", + target_os = "solaris", + target_os = "illumos", + target_os = "android", + target_os = "haiku", + )) +))] +const PLATFORM_CERTIFICATE_FILE_NAMES: &[&str] = &["/etc/ssl/certs/ca-certificates.crt"]; + +#[cfg(windows)] +fn usable_for_rustls(uses: schannel::cert_context::ValidUses) -> bool { + match uses { + schannel::cert_context::ValidUses::All => true, + schannel::cert_context::ValidUses::Oids(strs) => strs.iter().any(|x| x == PKIX_SERVER_AUTH), + } +} + +#[cfg(windows)] +const PKIX_SERVER_AUTH: &str = "1.3.6.1.5.5.7.3.1"; diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index ef56846599..13998dd447 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -308,10 +308,7 @@ struct NetworkProxyRuntimeSettings { impl NetworkProxyRuntimeSettings { fn from_config(config: &config::NetworkProxyConfig) -> Result { let mitm_ca_trust_bundle = if config.network.mitm { - let env = crate::certs::CUSTOM_CA_ENV_KEYS - .into_iter() - .filter_map(|key| std::env::var(key).ok().map(|value| (key, value))) - .collect(); + let env = crate::certs::ca_env_from_process(); Some(crate::certs::managed_ca_trust_bundle(&env)?) } else { None diff --git a/codex-rs/network-proxy/src/upstream.rs b/codex-rs/network-proxy/src/upstream.rs index 3437b0d32d..5d36706ffb 100644 --- a/codex-rs/network-proxy/src/upstream.rs +++ b/codex-rs/network-proxy/src/upstream.rs @@ -21,6 +21,8 @@ use rama_net::client::EstablishedClientConnection; use rama_net::http::RequestContext; use rama_tls_rustls::client::TlsConnectorDataBuilder; use rama_tls_rustls::client::TlsConnectorLayer; +use rama_tls_rustls::client::client_root_certs; +use rama_tls_rustls::dep::rustls; use std::sync::Arc; use std::time::Instant; use tracing::info; @@ -104,6 +106,7 @@ impl UpstreamClient { Self::new( ProxyConfig::default(), TargetCheckedTcpConnector::new(state), + client_root_certs(), ) } @@ -111,20 +114,29 @@ impl UpstreamClient { Self::new( ProxyConfig::from_env(), TargetCheckedTcpConnector::new(state), + client_root_certs(), ) } - pub(crate) fn direct_with_allow_local_binding(allow_local_binding: bool) -> Self { + pub(crate) fn direct_with_allow_local_binding( + allow_local_binding: bool, + tls_root_store: Arc, + ) -> Self { Self::new( ProxyConfig::default(), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), + tls_root_store, ) } - pub(crate) fn from_env_proxy_with_allow_local_binding(allow_local_binding: bool) -> Self { + pub(crate) fn from_env_proxy_with_allow_local_binding( + allow_local_binding: bool, + tls_root_store: Arc, + ) -> Self { Self::new( ProxyConfig::from_env(), TargetCheckedTcpConnector::from_allow_local_binding(allow_local_binding), + tls_root_store, ) } @@ -137,8 +149,12 @@ impl UpstreamClient { } } - fn new(proxy_config: ProxyConfig, transport: TargetCheckedTcpConnector) -> Self { - let connector = build_http_connector(transport); + fn new( + proxy_config: ProxyConfig, + transport: TargetCheckedTcpConnector, + tls_root_store: Arc, + ) -> Self { + let connector = build_http_connector(transport, tls_root_store); Self { connector, proxy_config, @@ -221,6 +237,7 @@ impl Service> for UpstreamClient { fn build_http_connector( transport: TargetCheckedTcpConnector, + tls_root_store: Arc, ) -> BoxService< Request, EstablishedClientConnection, Request>, @@ -228,7 +245,10 @@ fn build_http_connector( > { ensure_rustls_crypto_provider(); let proxy = HttpProxyConnectorLayer::optional().into_layer(transport); - let tls_config = TlsConnectorDataBuilder::new() + let client_config = rustls::ClientConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS) + .with_root_certificates(tls_root_store) + .with_no_client_auth(); + let tls_config = TlsConnectorDataBuilder::from(client_config) .with_alpn_protocols_http_auto() .build(); let tls = TlsConnectorLayer::auto() @@ -239,6 +259,10 @@ fn build_http_connector( connector.boxed() } +#[cfg(test)] +#[path = "upstream_tests.rs"] +mod tests; + #[cfg(target_os = "macos")] fn build_unix_connector( path: &str, diff --git a/codex-rs/network-proxy/src/upstream_tests.rs b/codex-rs/network-proxy/src/upstream_tests.rs new file mode 100644 index 0000000000..85f5da9017 --- /dev/null +++ b/codex-rs/network-proxy/src/upstream_tests.rs @@ -0,0 +1,111 @@ +use super::*; +use pretty_assertions::assert_eq; +use rama_http::StatusCode; +use rama_tls_rustls::dep::pki_types::CertificateDer; +use rama_tls_rustls::dep::pki_types::PrivateKeyDer; +use rama_tls_rustls::dep::pki_types::pem::PemObject; +use rama_tls_rustls::dep::rcgen::BasicConstraints; +use rama_tls_rustls::dep::rcgen::CertificateParams; +use rama_tls_rustls::dep::rcgen::DistinguishedName; +use rama_tls_rustls::dep::rcgen::DnType; +use rama_tls_rustls::dep::rcgen::ExtendedKeyUsagePurpose; +use rama_tls_rustls::dep::rcgen::IsCa; +use rama_tls_rustls::dep::rcgen::Issuer; +use rama_tls_rustls::dep::rcgen::KeyPair; +use rama_tls_rustls::dep::rcgen::KeyUsagePurpose; +use rama_tls_rustls::dep::rcgen::PKCS_ECDSA_P256_SHA256; +use rama_tls_rustls::dep::tokio_rustls::TlsAcceptor; +use std::collections::HashMap; +use std::fs; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::io::AsyncReadExt; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; + +fn generate_ca(common_name: &str) -> (String, KeyPair) { + let mut params = CertificateParams::default(); + params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained); + params.key_usages = vec![ + KeyUsagePurpose::KeyCertSign, + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let mut distinguished_name = DistinguishedName::new(); + distinguished_name.push(DnType::CommonName, common_name); + params.distinguished_name = distinguished_name; + let key_pair = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let cert = params.self_signed(&key_pair).unwrap(); + (cert.pem(), key_pair) +} + +#[tokio::test] +async fn mitm_upstream_client_trusts_startup_custom_ca() { + ensure_rustls_crypto_provider(); + let temp_dir = tempdir().unwrap(); + let startup_ca_path = temp_dir.path().join("startup-ca.pem"); + let managed_ca_path = temp_dir.path().join("managed-ca.pem"); + let (startup_ca_pem, startup_ca_key) = generate_ca("startup CA"); + let (managed_ca_pem, _) = generate_ca("managed MITM CA"); + fs::write(&startup_ca_path, &startup_ca_pem).unwrap(); + fs::write(&managed_ca_path, managed_ca_pem).unwrap(); + + let issuer = Issuer::from_ca_cert_pem(&startup_ca_pem, startup_ca_key).unwrap(); + let mut server_params = CertificateParams::new(vec!["localhost".to_string()]).unwrap(); + server_params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth]; + server_params.key_usages = vec![ + KeyUsagePurpose::DigitalSignature, + KeyUsagePurpose::KeyEncipherment, + ]; + let server_key = KeyPair::generate_for(&PKCS_ECDSA_P256_SHA256).unwrap(); + let server_cert = server_params.signed_by(&server_key, &issuer).unwrap(); + let server_cert = CertificateDer::from_pem_slice(server_cert.pem().as_bytes()).unwrap(); + let server_key = PrivateKeyDer::from_pem_slice(server_key.serialize_pem().as_bytes()).unwrap(); + let mut server_config = + rustls::ServerConfig::builder_with_protocol_versions(rustls::ALL_VERSIONS) + .with_no_client_auth() + .with_single_cert(vec![server_cert], server_key) + .unwrap(); + server_config.alpn_protocols = vec![b"http/1.1".to_vec()]; + + let env = HashMap::from([( + "SSL_CERT_FILE", + startup_ca_path.to_string_lossy().into_owned(), + )]); + let roots = + crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &env).unwrap(); + let baseline_roots = + crate::certs::upstream_tls_root_store_for_cert_path(&managed_ca_path, &HashMap::new()) + .unwrap(); + assert_eq!(roots.len(), baseline_roots.len() + 1); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut stream = acceptor.accept(stream).await.unwrap(); + let mut request = [0; 4096]; + let bytes_read = stream.read(&mut request).await.unwrap(); + assert!(bytes_read > 0); + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + }); + + let client = + UpstreamClient::direct_with_allow_local_binding(/*allow_local_binding*/ true, roots); + let response = client + .serve( + Request::builder() + .uri(format!("https://localhost:{}/", address.port())) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + server.await.unwrap(); +} From 0a9b7d2c36937420135efdcfe7b960d34b763d8f Mon Sep 17 00:00:00 2001 From: Anton Panasenko Date: Mon, 22 Jun 2026 14:24:02 -0700 Subject: [PATCH 47/86] chore: advance tungstenite fork pins (#29480) ## Why `openai-oss-forks/tokio-tungstenite` now includes the updated `tungstenite` fork revision from [openai-oss-forks/tokio-tungstenite#3](https://github.com/openai-oss-forks/tokio-tungstenite/pull/3). Codex should consume the merged fork commit and resolve its direct and transitive `tungstenite` dependencies to the same revision instead of retaining the older pins. ## What Changed - Advanced the `tokio-tungstenite` git pin to `0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186`. - Advanced the `tungstenite` fork pin to `4fffad30fe373adbdcffab9545e9e9bf4f2fc19f` and adjusted the patch source so the transitive dependency resolves to that revision. - Updated `Cargo.lock` and `MODULE.bazel.lock` to match the dependency graph. --- MODULE.bazel.lock | 6 +++--- codex-rs/Cargo.lock | 29 ++++++++++++----------------- codex-rs/Cargo.toml | 6 +++--- 3 files changed, 18 insertions(+), 23 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 1826001540..74d66c1f66 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -986,8 +986,8 @@ "git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}", "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}", "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", - "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"tokio/time\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}", + "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib-rs\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}", "git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}", "git2_0.20.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.1.0\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4.4.13\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"libgit2-sys\",\"req\":\"^0.18.3\"},{\"name\":\"log\",\"req\":\"^0.4.8\"},{\"name\":\"openssl-probe\",\"optional\":true,\"req\":\"^0.1\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.45\",\"target\":\"cfg(all(unix, not(target_os = \\\"macos\\\")))\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"},{\"features\":[\"formatting\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"name\":\"url\",\"req\":\"^2.5.4\"}],\"features\":{\"default\":[\"ssh\",\"https\"],\"https\":[\"libgit2-sys/https\",\"openssl-sys\",\"openssl-probe\"],\"ssh\":[\"libgit2-sys/ssh\"],\"unstable\":[],\"vendored-libgit2\":[\"libgit2-sys/vendored\"],\"vendored-openssl\":[\"openssl-sys/vendored\",\"libgit2-sys/vendored-openssl\"],\"zlib-ng-compat\":[\"libgit2-sys/zlib-ng-compat\"]}}", "gix-actor_0.40.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"std\",\"unicode\"],\"name\":\"bstr\",\"req\":\"^1.12.0\"},{\"name\":\"document-features\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"gix-date\",\"req\":\"^0.15.0\"},{\"name\":\"gix-error\",\"req\":\"^0.2.0\"},{\"kind\":\"dev\",\"name\":\"pretty_assertions\",\"req\":\"^1.0.0\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.114\"},{\"features\":[\"simd\"],\"name\":\"winnow\",\"req\":\"^0.7.14\"}],\"features\":{\"serde\":[\"dep:serde\",\"bstr/serde\",\"gix-date/serde\"]}}", @@ -1197,7 +1197,6 @@ "libredox_0.1.14": "{\"dependencies\":[{\"name\":\"bitflags\",\"optional\":true,\"req\":\"^2\"},{\"name\":\"ioslice\",\"optional\":true,\"req\":\"^0.6\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"plain\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"redox_syscall\",\"optional\":true,\"req\":\"^0.7\"}],\"features\":{\"base\":[\"libc\"],\"call\":[\"base\"],\"default\":[\"base\",\"call\",\"std\",\"redox_syscall\",\"protocol\"],\"mkns\":[\"ioslice\"],\"protocol\":[\"plain\",\"bitflags\",\"redox_syscall\"],\"std\":[\"base\"]}}", "libsqlite3-sys_0.37.0": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"runtime\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72\"},{\"kind\":\"build\",\"name\":\"cc\",\"optional\":true,\"req\":\"^1.2.27\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.103\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"optional\":true,\"req\":\"^0.3.19\"},{\"kind\":\"build\",\"name\":\"prettyplease\",\"optional\":true,\"req\":\"^0.2.20\"},{\"default_features\":false,\"kind\":\"build\",\"name\":\"quote\",\"optional\":true,\"req\":\"^1.0.36\"},{\"features\":[\"full\",\"extra-traits\",\"visit-mut\"],\"kind\":\"build\",\"name\":\"syn\",\"optional\":true,\"req\":\"^2.0.89\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"optional\":true,\"req\":\"^0.2.15\"}],\"features\":{\"buildtime_bindgen\":[\"bindgen\",\"pkg-config\",\"vcpkg\"],\"bundled\":[\"cc\",\"bundled_bindings\"],\"bundled-sqlcipher\":[\"bundled\"],\"bundled-sqlcipher-vendored-openssl\":[\"bundled-sqlcipher\",\"openssl-sys/vendored\"],\"bundled-windows\":[\"cc\",\"bundled_bindings\"],\"bundled_bindings\":[],\"column_metadata\":[],\"default\":[\"min_sqlite_version_3_34_1\"],\"in_gecko\":[],\"loadable_extension\":[\"prettyplease\",\"quote\",\"syn\"],\"min_sqlite_version_3_34_1\":[\"pkg-config\",\"vcpkg\"],\"preupdate_hook\":[\"buildtime_bindgen\"],\"session\":[\"preupdate_hook\",\"buildtime_bindgen\"],\"sqlcipher\":[],\"unlock_notify\":[],\"wasm32-wasi-vfs\":[],\"with-asan\":[]}}", "libssh2-sys_0.3.1": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.25\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"default_features\":false,\"features\":[\"libc\"],\"name\":\"libz-sys\",\"req\":\"^1.1.0\"},{\"name\":\"openssl-sys\",\"req\":\"^0.9.35\",\"target\":\"cfg(unix)\"},{\"name\":\"openssl-sys\",\"optional\":true,\"req\":\"^0.9.35\",\"target\":\"cfg(windows)\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.11\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2\",\"target\":\"cfg(target_env = \\\"msvc\\\")\"}],\"features\":{\"openssl-on-win32\":[\"openssl-sys\"],\"vendored-openssl\":[\"openssl-sys/vendored\"],\"zlib-ng-compat\":[\"libz-sys/zlib-ng\"]}}", - "libz-sys_1.1.23": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "libz-sys_1.1.25": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.98\"},{\"kind\":\"build\",\"name\":\"cmake\",\"optional\":true,\"req\":\"^0.1.50\"},{\"name\":\"libc\",\"optional\":true,\"req\":\"^0.2.43\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.11\"}],\"features\":{\"asm\":[],\"default\":[\"libc\",\"stock-zlib\"],\"static\":[],\"stock-zlib\":[],\"zlib-ng\":[\"libc\",\"cmake\"],\"zlib-ng-no-cmake-experimental-community-maintained\":[\"libc\"]}}", "link-cplusplus_1.0.12": "{\"dependencies\":[{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1\"}],\"features\":{\"default\":[],\"libc++\":[],\"libcxx\":[\"libc++\"],\"libstdc++\":[],\"libstdcxx\":[\"libstdc++\"],\"nothing\":[]}}", "link-section_0.17.2": "{\"dependencies\":[{\"features\":[\"link_section\"],\"name\":\"linktime-proc-macro\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"macrotest\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"kind\":\"dev\",\"name\":\"trybuild\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2\"}],\"features\":{\"default\":[\"proc_macro\",\"std\"],\"proc_macro\":[\"dep:linktime-proc-macro\"],\"std\":[]}}", @@ -1876,6 +1875,7 @@ "zerovec_0.11.6": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"bincode\",\"req\":\"^1.3.1\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5.0\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"databake\",\"optional\":true,\"req\":\"^0.2.0\"},{\"features\":[\"wasm_js\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"iai\",\"req\":\"^0.1.1\"},{\"features\":[\"json\"],\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.43.2\"},{\"default_features\":false,\"features\":[\"use-std\"],\"kind\":\"dev\",\"name\":\"postcard\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rand_distr\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"rand_pcg\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"rmp-serde\",\"req\":\"^1.2.0\"},{\"default_features\":false,\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0.4\"},{\"default_features\":false,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.220\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1.0.220\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.45\"},{\"default_features\":false,\"features\":[\"xxhash64\"],\"name\":\"twox-hash\",\"optional\":true,\"req\":\"^2.0.0\"},{\"default_features\":false,\"name\":\"yoke\",\"optional\":true,\"req\":\"^0.8.2\"},{\"default_features\":false,\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"yoke\",\"req\":\"^0.8.2\"},{\"default_features\":false,\"name\":\"zerofrom\",\"req\":\"^0.1.6\"},{\"default_features\":false,\"name\":\"zerovec-derive\",\"optional\":true,\"req\":\"^0.11.3\"}],\"features\":{\"alloc\":[\"serde?/alloc\"],\"databake\":[\"dep:databake\"],\"derive\":[\"dep:zerovec-derive\"],\"hashmap\":[\"dep:twox-hash\",\"alloc\"],\"schemars\":[\"dep:schemars\",\"alloc\"],\"serde\":[\"dep:serde\"],\"std\":[],\"yoke\":[\"dep:yoke\"]}}", "zip_0.6.6": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8.2\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"byteorder\",\"req\":\"^1.4.3\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.4.3\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.1.5\"},{\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.8\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0.23\"},{\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.2.5\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12.1\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.11.0\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10.1\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.7\"},{\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.7\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.3.2\"},{\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.11.2\"}],\"features\":{\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate\",\"time\",\"zstd\"],\"deflate\":[\"flate2/rust_backend\"],\"deflate-miniz\":[\"flate2/default\"],\"deflate-zlib\":[\"flate2/zlib\"],\"unreserved\":[]}}", "zip_2.4.2": "{\"dependencies\":[{\"name\":\"aes\",\"optional\":true,\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"req\":\"^1.4.1\",\"target\":\"cfg(fuzzing)\"},{\"kind\":\"dev\",\"name\":\"bencher\",\"req\":\"^0.1.5\"},{\"name\":\"bzip2\",\"optional\":true,\"req\":\"^0.5.0\"},{\"name\":\"chrono\",\"optional\":true,\"req\":\"^0.4\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"=4.4.18\"},{\"name\":\"constant_time_eq\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"crc32fast\",\"req\":\"^1.4\"},{\"name\":\"crossbeam-utils\",\"req\":\"^0.8.21\",\"target\":\"cfg(any(all(target_arch = \\\"arm\\\", target_pointer_width = \\\"32\\\"), target_arch = \\\"mips\\\", target_arch = \\\"powerpc\\\"))\"},{\"name\":\"deflate64\",\"optional\":true,\"req\":\"^0.1.9\"},{\"default_features\":false,\"name\":\"displaydoc\",\"req\":\"^0.2\"},{\"default_features\":false,\"name\":\"flate2\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"wasm_js\",\"std\"],\"name\":\"getrandom\",\"optional\":true,\"req\":\"^0.3.1\"},{\"features\":[\"wasm_js\",\"std\"],\"kind\":\"dev\",\"name\":\"getrandom\",\"req\":\"^0.3.1\"},{\"features\":[\"reset\"],\"name\":\"hmac\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"indexmap\",\"req\":\"^2\"},{\"default_features\":false,\"name\":\"lzma-rs\",\"optional\":true,\"req\":\"^0.3\"},{\"name\":\"memchr\",\"req\":\"^2.7\"},{\"default_features\":false,\"name\":\"nt-time\",\"optional\":true,\"req\":\"^0.10.6\"},{\"name\":\"pbkdf2\",\"optional\":true,\"req\":\"^0.12\"},{\"name\":\"sha1\",\"optional\":true,\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.15\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"time\",\"optional\":true,\"req\":\"^0.3.37\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3.37\"},{\"kind\":\"dev\",\"name\":\"walkdir\",\"req\":\"^2.5\"},{\"name\":\"xz2\",\"optional\":true,\"req\":\"^0.1.7\"},{\"features\":[\"zeroize_derive\"],\"name\":\"zeroize\",\"optional\":true,\"req\":\"^1.8\"},{\"name\":\"zopfli\",\"optional\":true,\"req\":\"^0.8\"},{\"default_features\":false,\"name\":\"zstd\",\"optional\":true,\"req\":\"^0.13\"}],\"features\":{\"_all-features\":[],\"_deflate-any\":[],\"aes-crypto\":[\"aes\",\"constant_time_eq\",\"hmac\",\"pbkdf2\",\"sha1\",\"getrandom\",\"zeroize\"],\"chrono\":[\"chrono/default\"],\"default\":[\"aes-crypto\",\"bzip2\",\"deflate64\",\"deflate\",\"lzma\",\"time\",\"zstd\",\"xz\"],\"deflate\":[\"flate2/rust_backend\",\"deflate-zopfli\",\"deflate-flate2\"],\"deflate-flate2\":[\"_deflate-any\"],\"deflate-miniz\":[\"deflate\",\"deflate-flate2\"],\"deflate-zlib\":[\"flate2/zlib\",\"deflate-flate2\"],\"deflate-zlib-ng\":[\"flate2/zlib-ng\",\"deflate-flate2\"],\"deflate-zopfli\":[\"zopfli\",\"_deflate-any\"],\"lzma\":[\"lzma-rs/stream\"],\"nt-time\":[\"dep:nt-time\"],\"unreserved\":[],\"xz\":[\"dep:xz2\"]}}", + "zlib-rs_0.5.5": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zlib-rs_0.6.3": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"crc32fast\",\"req\":\"^1.3.2\"},{\"kind\":\"dev\",\"name\":\"memoffset\",\"req\":\"^0.9.1\"},{\"default_features\":false,\"name\":\"quickcheck\",\"optional\":true,\"req\":\"^1.0.3\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"}],\"features\":{\"ZLIB_DEBUG\":[],\"__internal-api\":[],\"__internal-fuzz\":[\"arbitrary\"],\"__internal-fuzz-disable-checksum\":[],\"__internal-test\":[\"quickcheck\"],\"avx512\":[\"vpclmulqdq\"],\"c-allocator\":[],\"default\":[\"std\",\"c-allocator\"],\"rust-allocator\":[],\"std\":[\"rust-allocator\"],\"vpclmulqdq\":[]}}", "zmij_1.0.19": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.9\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", "zmij_1.0.21": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\",\"target\":\"cfg(not(miri))\"},{\"name\":\"no-panic\",\"optional\":true,\"req\":\"^0.1.36\"},{\"kind\":\"dev\",\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"kind\":\"dev\",\"name\":\"num-integer\",\"req\":\"^0.1\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.8\"},{\"kind\":\"dev\",\"name\":\"opt-level\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"},{\"kind\":\"dev\",\"name\":\"ryu\",\"req\":\"^1\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 03ed82f1bf..cd658f9a88 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -6028,8 +6028,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369" dependencies = [ "crc32fast", - "libz-sys", "miniz_oxide", + "zlib-rs 0.5.5", ] [[package]] @@ -6717,7 +6717,7 @@ dependencies = [ "prodash", "thiserror 2.0.18", "walkdir", - "zlib-rs", + "zlib-rs 0.6.3", ] [[package]] @@ -8770,17 +8770,6 @@ dependencies = [ "webrtc-sys", ] -[[package]] -name = "libz-sys" -version = "1.1.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - [[package]] name = "link-cplusplus" version = "1.0.12" @@ -9457,7 +9446,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.22.1", + "base64 0.21.7", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -10543,7 +10532,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools 0.14.0", "log", "multimap", @@ -13608,7 +13597,7 @@ dependencies = [ [[package]] name = "tokio-tungstenite" version = "0.28.0" -source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=e5e64b86722c5453c4038131c3193faa49a648ad#e5e64b86722c5453c4038131c3193faa49a648ad" +source = "git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186#0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" dependencies = [ "futures-util", "log", @@ -14046,7 +14035,7 @@ dependencies = [ [[package]] name = "tungstenite" version = "0.27.0" -source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f" +source = "git+https://github.com/openai-oss-forks/tungstenite-rs?rev=4fffad30fe373adbdcffab9545e9e9bf4f2fc19f#4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" dependencies = [ "bytes", "data-encoding", @@ -15807,6 +15796,12 @@ dependencies = [ "zstd 0.13.3", ] +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + [[package]] name = "zlib-rs" version = "0.6.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 667bb7dfdf..878d359148 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -546,11 +546,11 @@ opt-level = 0 # ratatui = { path = "../../ratatui" } crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" } ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" } -tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "e5e64b86722c5453c4038131c3193faa49a648ad" } -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } +tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "0e5b2d73aa18dd9f0a50ee9ff199d5aef7594186" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } # Uncomment to debug local changes. # rmcp = { path = "../../rust-sdk/crates/rmcp" } [patch."ssh://git@github.com/openai-oss-forks/tungstenite-rs.git"] -tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" } +tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "4fffad30fe373adbdcffab9545e9e9bf4f2fc19f" } From a72433d5606984e71dc37a83a65a6f91af5c84b4 Mon Sep 17 00:00:00 2001 From: jameswt-oai Date: Mon, 22 Jun 2026 14:28:39 -0700 Subject: [PATCH 48/86] [codex-core-plugins] Remote Plugin ID Persisted to File (#27669) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## This PR Remote plugin analytics cannot rely only on the in-memory installed-plugin snapshot because that snapshot is refreshed asynchronously after startup. This PR persists the authoritative backend identity alongside each cached remote plugin bundle so later consumers can resolve it without a network request. ### Behavior - Store Codex-owned remote installation metadata in an atomic `.codex-remote-plugin-install.json` sidecar under the plugin cache root. - Use a versioned, snake_case schema: ```json { "schema_version": 1, "remote_plugin_id": "plugins~Plugin_..." } ``` - Write the metadata during remote bundle installation. - Backfill it when bundle sync finds an already-current cached bundle. - Clear it when a generic/local install replaces the cache. - Let existing uninstall and stale-cache removal delete it with the plugin cache root. - Reject unsupported schema versions rather than silently misreading future formats. This PR does not change analytics serialization or event behavior. ### Review surface The implementation is limited to four `codex-core-plugins` files: - `store.rs`: owns the versioned sidecar read/write/remove lifecycle. - `remote_bundle.rs`: persists the backend ID after a remote bundle install. - `remote/remote_installed_plugin_sync.rs`: backfills metadata for an already-current cached bundle. - Tests cover the storage lifecycle and both remote write paths. ## Testing / Validation ### Automated - `just test -p codex-core-plugins` (268 tests passed) - `just fix -p codex-core-plugins` passes with one pre-existing `large_enum_variant` warning in `manifest.rs`. - Coverage verifies the exact filename and JSON schema, identity replacement, local reinstall clearing, uninstall cleanup, remote bundle installation, unsupported schema rejection, and installed-plugin sync backfill. ### Live manual validation Validated the production app-server RPC path with an isolated temporary `CODEX_HOME` and the PR-built Codex binary. The app-server communicated over stdio and did not bind a port. Test plugin: `plugins~Plugin_b80dd84519148191a409cde181c9b3d6` (`build-macos-apps@openai-curated-remote`). 1. Confirmed `plugin/read` initially reported the plugin uninstalled. 2. Installed it through `plugin/install` and confirmed version `0.1.4` was cached. 3. Verified `$CODEX_HOME/plugins/cache/openai-curated-remote/build-macos-apps/.codex-remote-plugin-install.json` was created beside the `0.1.4/` bundle directory with mode `0600` and the expected contents: ```json { "schema_version": 1, "remote_plugin_id": "plugins~Plugin_b80dd84519148191a409cde181c9b3d6" } ``` 4. Deleted only the sidecar, restarted the app-server, and confirmed installed-plugin startup sync recreated it with the same contents. 5. Uninstalled through `plugin/uninstall`, confirmed `plugin/read` returned `installed: false`, and verified the local plugin cache root was removed. 6. Restored the account's original uninstalled state and removed the isolated home and copied credentials. ## Split Overview ```text main ├── #27093 Debug analytics capture merged │ └── #27099 Non-mutating plugin smoke merged │ └── #27100 Remote install/uninstall smoke merged └── #27102 Plugin telemetry metadata refactor merged └── #27669 Persist remote plugin identity ← this PR Next: └── Final PR: add explicit local and remote IDs to plugin analytics ``` This PR is based directly on `main`; prerequisite [#27102](https://github.com/openai/codex/pull/27102) has merged. The original combined [#26281](https://github.com/openai/codex/pull/26281) remains the aggregate reference until the final replacement PR is published. --- .../remote/remote_installed_plugin_sync.rs | 116 ++++++++++++++++ codex-rs/core-plugins/src/remote_bundle.rs | 46 ++++++- codex-rs/core-plugins/src/store.rs | 127 ++++++++++++++++++ codex-rs/core-plugins/src/store_tests.rs | 106 +++++++++++++++ 4 files changed, 393 insertions(+), 2 deletions(-) diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 8041d444b5..2a23b066ed 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -212,6 +212,16 @@ pub async fn sync_remote_installed_plugin_bundles_once( .map(str::trim) .filter(|version| !version.is_empty()); if store.active_plugin_version(&plugin_id).as_deref() == release_version { + if let Err(err) = store.write_remote_plugin_id(&plugin_id, &plugin.id) { + warn!( + remote_plugin_id = %plugin.id, + plugin = %plugin.name, + marketplace = %marketplace_name, + error = %err, + "failed to persist identity for cached remote installed plugin" + ); + failed_remote_plugin_ids.insert(plugin.id); + } continue; } @@ -439,6 +449,13 @@ fn clear_remote_installed_plugin_bundle_sync_in_flight(key: &RemoteInstalledPlug mod tests { use super::*; use pretty_assertions::assert_eq; + use serde_json::json; + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + use wiremock::matchers::query_param; #[test] fn remote_installed_plugin_sync_in_flight_dedupes_by_cache_root() { @@ -461,6 +478,105 @@ mod tests { clear_remote_installed_plugin_bundle_sync_in_flight(&key); } + #[tokio::test] + async fn sync_backfills_remote_plugin_install_metadata_for_current_bundle() { + let server = MockServer::start().await; + let codex_home = tempfile::tempdir().expect("create codex home"); + let cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_GLOBAL_MARKETPLACE_NAME) + .join("linear") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent")) + .expect("create cached plugin manifest parent"); + std::fs::write(&cached_manifest, r#"{"name":"linear","version":"1.2.3"}"#) + .expect("write cached plugin manifest"); + let remote_plugin_id = "plugins~Plugin_linear"; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [{ + "id": remote_plugin_id, + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "ENABLED", + "release": { + "version": "1.2.3", + "display_name": "Linear", + "description": "Track work", + "interface": {}, + }, + "enabled": true, + }], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "USER")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "WORKSPACE")) + .and(query_param("includeDownloadUrls", "true")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plugins": [], + "pagination": {"next_page_token": null}, + }))) + .expect(1) + .mount(&server) + .await; + let config = RemotePluginServiceConfig { + chatgpt_base_url: format!("{}/backend-api", server.uri()), + }; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let outcome = sync_remote_installed_plugin_bundles_once( + codex_home.path().to_path_buf(), + &config, + Some(&auth), + ) + .await + .expect("sync current remote plugin bundle"); + + assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default()); + let plugin_id = PluginId::new( + "linear".to_string(), + REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), + ) + .expect("valid plugin id"); + let metadata_path = PluginStore::new(codex_home.path().to_path_buf()) + .plugin_base_root(&plugin_id) + .join(".codex-remote-plugin-install.json"); + assert_eq!( + serde_json::from_str::( + &std::fs::read_to_string(metadata_path.as_path()) + .expect("read remote plugin install metadata") + ) + .expect("parse remote plugin install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": remote_plugin_id, + }) + ); + } + #[test] fn stale_remote_plugin_cleanup_skips_cache_mutations_in_progress() { let codex_home = tempfile::tempdir().expect("create codex home"); diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index 49e6bd4138..66592dc363 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -34,6 +34,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = pub struct ValidatedRemotePluginBundle { pub plugin_id: PluginId, pub plugin_version: String, + remote_plugin_id: String, app_manifest: Option, bundle_download_url: String, } @@ -190,6 +191,7 @@ pub fn validate_remote_plugin_bundle( Ok(ValidatedRemotePluginBundle { plugin_id, plugin_version, + remote_plugin_id: remote_plugin_id.to_string(), app_manifest, bundle_download_url, }) @@ -403,9 +405,12 @@ fn install_remote_plugin_bundle( })?; let store = PluginStore::try_new(codex_home)?; - store + let remote_plugin_id = bundle.remote_plugin_id; + let result = store .install_with_version(plugin_root, bundle.plugin_id, bundle.plugin_version) - .map_err(RemotePluginBundleInstallError::from) + .map_err(RemotePluginBundleInstallError::from)?; + store.write_remote_plugin_id(&result.plugin_id, &remote_plugin_id)?; + Ok(result) } fn extract_remote_plugin_bundle_to_path( @@ -745,6 +750,43 @@ mod tests { ); } + #[test] + fn install_persists_remote_plugin_install_metadata() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let result = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[( + ".codex-plugin/plugin.json", + br#"{"name":"linear","version":"1.2.3"}"#, + /*mode*/ 0o644, + )]), + ) + .expect("install bundle"); + let store = PluginStore::new(codex_home.path().to_path_buf()); + + assert_eq!( + store.remote_plugin_id(&result.plugin_id).unwrap(), + Some(REMOTE_PLUGIN_ID.to_string()) + ); + let metadata_path = store + .plugin_base_root(&result.plugin_id) + .join(".codex-remote-plugin-install.json"); + assert_eq!( + serde_json::from_str::( + &std::fs::read_to_string(metadata_path.as_path()) + .expect("read remote plugin install metadata") + ) + .expect("parse remote plugin install metadata"), + serde_json::json!({ + "schema_version": 1, + "remote_plugin_id": REMOTE_PLUGIN_ID, + }) + ); + } + #[test] fn install_preserves_non_global_bundle_manifest_metadata() { let codex_home = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index f0c737e28d..5d78fefa90 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -7,16 +7,26 @@ use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_plugins::find_plugin_manifest_path; use semver::Version; use serde::Deserialize; +use serde::Serialize; use serde_json::Value as JsonValue; use std::cmp::Ordering; use std::fs; use std::io; +use std::io::Write; use std::path::Path; use std::path::PathBuf; pub const DEFAULT_PLUGIN_VERSION: &str = "local"; pub const PLUGINS_CACHE_DIR: &str = "plugins/cache"; pub const PLUGINS_DATA_DIR: &str = "plugins/data"; +const REMOTE_PLUGIN_INSTALL_METADATA_FILE: &str = ".codex-remote-plugin-install.json"; +const REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION: u8 = 1; + +#[derive(Debug, Deserialize, Serialize)] +struct RemotePluginInstallMetadata { + schema_version: u8, + remote_plugin_id: String, +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallResult { @@ -106,6 +116,102 @@ impl PluginStore { self.active_plugin_version(plugin_id).is_some() } + pub fn remote_plugin_id( + &self, + plugin_id: &PluginId, + ) -> Result, PluginStoreError> { + if !self.is_installed(plugin_id) { + return Ok(None); + } + let path = self.remote_plugin_install_metadata_path(plugin_id); + let contents = match fs::read_to_string(path.as_path()) { + Ok(contents) => contents, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None), + Err(err) => { + return Err(PluginStoreError::io( + "failed to read remote plugin install metadata", + err, + )); + } + }; + let metadata: RemotePluginInstallMetadata = + serde_json::from_str(&contents).map_err(|err| { + PluginStoreError::Invalid(format!( + "failed to parse remote plugin install metadata: {err}" + )) + })?; + if metadata.schema_version != REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION { + return Err(PluginStoreError::Invalid(format!( + "unsupported remote plugin install metadata schema version: {}", + metadata.schema_version + ))); + } + let remote_plugin_id = metadata.remote_plugin_id.trim(); + if remote_plugin_id.is_empty() { + return Err(PluginStoreError::Invalid( + "invalid remote plugin install metadata: remote plugin id must not be blank" + .to_string(), + )); + } + Ok(Some(remote_plugin_id.to_string())) + } + + pub fn write_remote_plugin_id( + &self, + plugin_id: &PluginId, + remote_plugin_id: &str, + ) -> Result<(), PluginStoreError> { + if !self.is_installed(plugin_id) { + return Err(PluginStoreError::Invalid(format!( + "cannot write remote identity for uninstalled plugin `{}`", + plugin_id.as_key() + ))); + } + let remote_plugin_id = remote_plugin_id.trim(); + if remote_plugin_id.is_empty() { + return Err(PluginStoreError::Invalid( + "invalid remote plugin install metadata: remote plugin id must not be blank" + .to_string(), + )); + } + let path = self.remote_plugin_install_metadata_path(plugin_id); + let parent = path.as_path().parent().ok_or_else(|| { + PluginStoreError::Invalid(format!( + "remote plugin install metadata path has no parent: {}", + path.display() + )) + })?; + let mut contents = serde_json::to_vec_pretty(&RemotePluginInstallMetadata { + schema_version: REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION, + remote_plugin_id: remote_plugin_id.to_string(), + }) + .map_err(|err| { + PluginStoreError::Invalid(format!( + "failed to serialize remote plugin install metadata: {err}" + )) + })?; + contents.push(b'\n'); + let mut temporary = tempfile::NamedTempFile::new_in(parent).map_err(|err| { + PluginStoreError::io( + "failed to create temporary remote plugin install metadata", + err, + ) + })?; + temporary.write_all(&contents).map_err(|err| { + PluginStoreError::io("failed to write remote plugin install metadata", err) + })?; + temporary.as_file_mut().flush().map_err(|err| { + PluginStoreError::io("failed to flush remote plugin install metadata", err) + })?; + temporary.persist(path.as_path()).map_err(|err| { + PluginStoreError::io( + "failed to persist remote plugin install metadata", + err.error, + ) + })?; + Ok(()) + } + pub fn install( &self, source_path: AbsolutePathBuf, @@ -197,6 +303,7 @@ impl PluginStore { &plugin_version, manifest, )?; + self.remove_remote_plugin_install_metadata(&plugin_id)?; Ok(PluginInstallResult { plugin_id, @@ -208,6 +315,26 @@ impl PluginStore { pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> { remove_existing_target(self.plugin_base_root(plugin_id).as_path()) } + + fn remote_plugin_install_metadata_path(&self, plugin_id: &PluginId) -> AbsolutePathBuf { + self.plugin_base_root(plugin_id) + .join(REMOTE_PLUGIN_INSTALL_METADATA_FILE) + } + + fn remove_remote_plugin_install_metadata( + &self, + plugin_id: &PluginId, + ) -> Result<(), PluginStoreError> { + let path = self.remote_plugin_install_metadata_path(plugin_id); + match fs::remove_file(path.as_path()) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(PluginStoreError::io( + "failed to remove remote plugin install metadata", + err, + )), + } + } } #[derive(Debug, thiserror::Error)] diff --git a/codex-rs/core-plugins/src/store_tests.rs b/codex-rs/core-plugins/src/store_tests.rs index aa9b17cc29..237fb4e88a 100644 --- a/codex-rs/core-plugins/src/store_tests.rs +++ b/codex-rs/core-plugins/src/store_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_plugin::PluginId; use pretty_assertions::assert_eq; +use serde_json::json; use tempfile::tempdir; fn write_plugin_with_version( @@ -191,6 +192,111 @@ fn install_with_version_uses_requested_cache_version() { assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); } +#[test] +fn remote_plugin_install_metadata_follows_installed_cache_lifecycle() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new( + "sample-plugin".to_string(), + "openai-curated-remote".to_string(), + ) + .unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + let source = AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(); + + store + .install(source.clone(), plugin_id.clone()) + .expect("install plugin"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") + .expect("write remote identity"); + let metadata_path = store.remote_plugin_install_metadata_path(&plugin_id); + assert_eq!( + metadata_path.as_path().file_name(), + Some(std::ffi::OsStr::new(".codex-remote-plugin-install.json")) + ); + assert_eq!( + serde_json::from_str::( + &fs::read_to_string(metadata_path.as_path()).expect("read install metadata") + ) + .expect("parse install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": "plugins~Plugin_sample", + }) + ); + assert_eq!( + store.remote_plugin_id(&plugin_id).unwrap(), + Some("plugins~Plugin_sample".to_string()) + ); + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_updated") + .expect("replace remote identity"); + assert_eq!( + store.remote_plugin_id(&plugin_id).unwrap(), + Some("plugins~Plugin_updated".to_string()) + ); + assert_eq!( + serde_json::from_str::( + &fs::read_to_string(metadata_path.as_path()).expect("read updated install metadata") + ) + .expect("parse updated install metadata"), + json!({ + "schema_version": 1, + "remote_plugin_id": "plugins~Plugin_updated", + }) + ); + + store + .install(source, plugin_id.clone()) + .expect("replace with local install"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + assert!(!metadata_path.as_path().exists()); + + store + .write_remote_plugin_id(&plugin_id, "plugins~Plugin_sample") + .expect("restore remote identity"); + store.uninstall(&plugin_id).expect("uninstall plugin"); + assert_eq!(store.remote_plugin_id(&plugin_id).unwrap(), None); + assert!(!metadata_path.as_path().exists()); +} + +#[test] +fn remote_plugin_install_metadata_rejects_unsupported_schema_version() { + let tmp = tempdir().unwrap(); + write_plugin(tmp.path(), "sample-plugin", "sample-plugin"); + let plugin_id = PluginId::new( + "sample-plugin".to_string(), + "openai-curated-remote".to_string(), + ) + .unwrap(); + let store = PluginStore::new(tmp.path().to_path_buf()); + store + .install( + AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(), + plugin_id.clone(), + ) + .expect("install plugin"); + fs::write( + store + .remote_plugin_install_metadata_path(&plugin_id) + .as_path(), + r#"{"schema_version":2,"remote_plugin_id":"plugins~Plugin_sample"}"#, + ) + .expect("write unsupported install metadata"); + + let err = store + .remote_plugin_id(&plugin_id) + .expect_err("unsupported schema version should fail"); + + assert_eq!( + err.to_string(), + "unsupported remote plugin install metadata schema version: 2" + ); +} + #[test] fn install_prefers_on_disk_manifest_version_over_fallback() { let tmp = tempdir().unwrap(); From 5f129a4703ceaf843e1cb6996bbf4a4f21225198 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 14:38:33 -0700 Subject: [PATCH 49/86] PAC 3 - Add Windows system proxy resolver (#26708) ## Summary Stacked on #26707. Adds the Windows implementation of the shared system-proxy contract. This allows Codex-owned auth clients to use the route Windows selects for each auth URL, including explicit PAC configuration, WPAD auto-detection, static proxies, and bypass rules. The `respect_system_proxy` feature is disabled by default, so existing client behavior remains unchanged unless explicitly enabled. ## Implementation - Adds Windows-only `codex-client` dependencies: - `windows-sys` with `Win32_Foundation` and `Win32_Networking_WinHttp`; - `sha2` for redacted cache keys. - Dispatches system-proxy resolution to `outbound_proxy/windows.rs` on Windows. - Reads the current-user WinHTTP/IE proxy configuration via `WinHttpGetIEProxyConfigForCurrentUser`. - Resolves explicit PAC URLs first, then OS-enabled WPAD auto-detection, then static proxy and bypass settings. - Uses `WinHttpGetProxyForUrl` for PAC/WPAD and maps results into the shared `SystemProxyDecision::{Direct, Proxy, Unavailable}` contract. - Parses `DIRECT`, `PROXY`, `HTTPS`, and keyed static proxy entries. - Treats unsupported schemes such as SOCKS as unavailable so the shared resolver can apply its environment-proxy fallback. - Handles Windows bypass entries, including `` and host, suffix, wildcard, and port matching. - Releases WinHTTP-owned strings with `GlobalFree` and closes sessions with `WinHttpCloseHandle`. - Hashes URL-specific cache keys with SHA-256 so PAC decisions remain URL-specific without retaining raw request URLs or query strings. ## End-user behavior - Disabled/default: existing client behavior is unchanged. - Enabled with `[features.respect_system_proxy]`: - Windows auth clients honor explicit PAC configuration, OS-enabled WPAD, static proxies, and bypass rules; - valid OS/PAC `DIRECT` decisions use a direct connection; - unavailable system resolution falls back to explicit environment proxy variables, then `DIRECT`, through the shared contract from #26707. - Unsupported proxy schemes are not silently translated into a different route. - Custom CA handling remains separate from proxy selection. ## Tests Adds coverage for: - PAC-style proxy tokens such as `PROXY proxy.internal:8080` and `HTTPS proxy.internal:8443`; - static WinHTTP proxy entries keyed by target scheme; - `DIRECT` and unsupported proxy-token behavior; - Windows bypass matching, including ``, wildcard, suffix, and port-qualified entries; - preserving URL-specific PAC cache decisions without retaining the raw URL on Windows. --- codex-rs/Cargo.lock | 2 + codex-rs/codex-client/Cargo.toml | 7 + codex-rs/codex-client/src/outbound_proxy.rs | 244 +++++++++++- .../src/outbound_proxy/windows.rs | 362 ++++++++++++++++++ .../src/outbound_proxy/windows_tests.rs | 18 + .../codex-client/src/outbound_proxy_tests.rs | 76 ++++ 6 files changed, 696 insertions(+), 13 deletions(-) create mode 100644 codex-rs/codex-client/src/outbound_proxy/windows.rs create mode 100644 codex-rs/codex-client/src/outbound_proxy/windows_tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index cd658f9a88..f0d9f135b7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2404,12 +2404,14 @@ dependencies = [ "rustls-pki-types", "serde", "serde_json", + "sha2 0.10.9", "tempfile", "thiserror 2.0.18", "tokio", "tracing", "tracing-opentelemetry", "tracing-subscriber", + "windows-sys 0.52.0", "zstd 0.13.3", ] diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 87f95de655..65be21913a 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -24,6 +24,13 @@ tracing-opentelemetry = { workspace = true } codex-utils-rustls-provider = { workspace = true } zstd = { workspace = true } +[target.'cfg(target_os = "windows")'.dependencies] +sha2 = { workspace = true } +windows-sys = { version = "0.52", features = [ + "Win32_Foundation", + "Win32_Networking_WinHttp", +] } + [lints] workspace = true diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs index a5bef21bcb..ba3735ba53 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -14,12 +14,19 @@ use std::time::Instant; use crate::custom_ca::BuildCustomCaTransportError; use crate::custom_ca::build_reqwest_client_with_custom_ca; +#[cfg(target_os = "windows")] +use sha2::Digest; +#[cfg(target_os = "windows")] +use sha2::Sha256; use thiserror::Error; const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60); const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5); const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256; +#[cfg(target_os = "windows")] +mod windows; + /// Coarse semantic bucket for the HTTP or WebSocket client being constructed. /// /// This is not the selected proxy route or a concrete endpoint. It labels the @@ -209,11 +216,14 @@ impl RequestOrigin { } } -#[derive(Debug, Clone, PartialEq, Eq)] -#[allow( - dead_code, - reason = "Direct and Proxy are constructed by platform resolvers added in later PRs" +#[cfg_attr( + not(target_os = "windows"), + allow( + dead_code, + reason = "Direct and Proxy are constructed only by platform-specific resolvers" + ) )] +#[derive(Debug, Clone, PartialEq, Eq)] enum SystemProxyDecision { Direct, Proxy { url: String }, @@ -230,6 +240,12 @@ fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProx decision } +#[cfg(target_os = "windows")] +fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + windows::resolve(request_url, origin) +} + +#[cfg(not(target_os = "windows"))] fn resolve_platform_system_proxy( _request_url: &str, _origin: &RequestOrigin, @@ -251,24 +267,26 @@ static SYSTEM_PROXY_CACHE: OnceLock Option { let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); let mut cache = cache.lock().ok()?; - let cached = cache.get(request_url)?; + let key = system_proxy_cache_key(request_url); + let cached = cache.get(&key)?; if cached.expires_at > Instant::now() { return Some(cached.decision.clone()); } - cache.remove(request_url); + cache.remove(&key); None } fn cache_system_proxy_decision(request_url: &str, decision: SystemProxyDecision) { let cache = SYSTEM_PROXY_CACHE.get_or_init(|| Mutex::new(HashMap::new())); if let Ok(mut cache) = cache.lock() { - insert_system_proxy_cache_entry(&mut cache, request_url, decision, Instant::now()); + let cache_key = system_proxy_cache_key(request_url); + insert_system_proxy_cache_entry(&mut cache, &cache_key, decision, Instant::now()); } } fn insert_system_proxy_cache_entry( cache: &mut HashMap, - request_url: &str, + cache_key: &str, decision: SystemProxyDecision, now: Instant, ) { @@ -281,16 +299,16 @@ fn insert_system_proxy_cache_entry( cache.retain(|_, cached| cached.expires_at > now); if cache.len() >= SYSTEM_PROXY_CACHE_MAX_ENTRIES - && !cache.contains_key(request_url) - && let Some(request_url_to_evict) = cache + && !cache.contains_key(cache_key) + && let Some(cache_key_to_evict) = cache .iter() .min_by_key(|(_, cached)| cached.expires_at) - .map(|(request_url, _)| request_url.clone()) + .map(|(cache_key, _)| cache_key.clone()) { - cache.remove(&request_url_to_evict); + cache.remove(&cache_key_to_evict); } cache.insert( - request_url.to_string(), + cache_key.to_string(), CachedSystemProxyDecision { decision, expires_at: now + ttl, @@ -298,6 +316,206 @@ fn insert_system_proxy_cache_entry( ); } +fn system_proxy_cache_key(request_url: &str) -> String { + #[cfg(target_os = "windows")] + { + // Keep URL-specific PAC decisions without retaining the raw routed URL. + let mut hasher = Sha256::new(); + hasher.update(b"system-proxy-cache-v1\0"); + hasher.update(request_url.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + #[cfg(not(target_os = "windows"))] + request_url.to_string() +} + +#[cfg(any(test, target_os = "windows"))] +fn no_proxy_matches_origin(no_proxy: &str, origin: &RequestOrigin) -> bool { + no_proxy + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .any(|entry| no_proxy_entry_matches_origin(entry, origin)) +} + +#[cfg(any(test, target_os = "windows"))] +fn no_proxy_entry_matches_origin(entry: &str, origin: &RequestOrigin) -> bool { + if entry == "*" { + return true; + } + + let mut entry = entry + .strip_prefix("http://") + .or_else(|| entry.strip_prefix("https://")) + .unwrap_or(entry) + .trim_matches(['[', ']']) + .to_ascii_lowercase(); + let mut port = None; + let parsed_host_port = entry.rsplit_once(':').and_then(|(host, candidate_port)| { + if host.contains(':') { + return None; + } + candidate_port + .parse::() + .ok() + .map(|parsed_port| (host.to_string(), parsed_port)) + }); + if let Some((host, parsed_port)) = parsed_host_port { + entry = host; + port = Some(parsed_port); + } + if port.is_some_and(|port| port != origin.port) { + return false; + } + + if let Some(suffix) = entry.strip_prefix('.') { + return origin.host == suffix || origin.host.ends_with(&format!(".{suffix}")); + } + + if entry.contains('*') { + return wildcard_host_match(&entry, &origin.host); + } + + origin.host == entry +} + +#[cfg(any(test, target_os = "windows"))] +fn wildcard_host_match(pattern: &str, host: &str) -> bool { + let mut remaining = host; + let mut first = true; + for part in pattern.split('*') { + if part.is_empty() { + continue; + } + if first && !pattern.starts_with('*') { + let Some(stripped) = remaining.strip_prefix(part) else { + return false; + }; + remaining = stripped; + } else { + let Some(index) = remaining.find(part) else { + return false; + }; + remaining = &remaining[index + part.len()..]; + } + first = false; + } + pattern.ends_with('*') || remaining.is_empty() +} + +#[cfg(any(test, target_os = "windows"))] +#[derive(Debug, Clone, PartialEq, Eq)] +enum ParsedProxyListDecision { + Direct, + Proxy(String), + UnsupportedScheme, + Unavailable, +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_list(input: &str, target_scheme: &str) -> ParsedProxyListDecision { + let mut saw_unsupported = false; + + { + let mut process_token = |token: &str| { + let decision = parse_proxy_token(token, target_scheme); + match decision { + ParsedProxyListDecision::Direct => Some(ParsedProxyListDecision::Direct), + ParsedProxyListDecision::Proxy(url) => Some(ParsedProxyListDecision::Proxy(url)), + ParsedProxyListDecision::UnsupportedScheme => { + saw_unsupported = true; + None + } + ParsedProxyListDecision::Unavailable => None, + } + }; + + for segment in input + .split(';') + .map(str::trim) + .filter(|segment| !segment.is_empty()) + { + let mut parts = segment.split_whitespace(); + let directive = parts.next(); + let hostport = parts.next(); + let extra = parts.next(); + let is_proxy_directive = matches!( + directive.map(str::to_ascii_lowercase).as_deref(), + Some("proxy" | "http" | "https" | "socks" | "socks4" | "socks5") + ) && hostport.is_some() + && extra.is_none(); + + if is_proxy_directive { + if let Some(decision) = process_token(segment) { + return decision; + } + } else { + for token in segment.split_whitespace() { + if let Some(decision) = process_token(token) { + return decision; + } + } + } + } + } + + if saw_unsupported { + ParsedProxyListDecision::UnsupportedScheme + } else { + ParsedProxyListDecision::Unavailable + } +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_token(token: &str, target_scheme: &str) -> ParsedProxyListDecision { + if token.eq_ignore_ascii_case("DIRECT") { + return ParsedProxyListDecision::Direct; + } + + if let Some(decision) = parse_proxy_key_token(token, target_scheme) { + return decision; + } + if token.contains('=') { + return ParsedProxyListDecision::Unavailable; + } + + let mut parts = token.split_whitespace(); + let directive = parts.next(); + let hostport = parts.next(); + if let (Some(directive), Some(hostport), None) = (directive, hostport, parts.next()) { + return match directive.to_ascii_lowercase().as_str() { + "proxy" | "http" => proxy_url_from_hostport("http", hostport), + "https" => proxy_url_from_hostport("https", hostport), + "socks" | "socks4" | "socks5" => ParsedProxyListDecision::UnsupportedScheme, + _ => ParsedProxyListDecision::Unavailable, + }; + } + + proxy_url_from_hostport("http", token) +} + +#[cfg(any(test, target_os = "windows"))] +fn parse_proxy_key_token(token: &str, target_scheme: &str) -> Option { + let (key, value) = token.split_once('=')?; + if key.trim().eq_ignore_ascii_case(target_scheme) { + Some(proxy_url_from_hostport("http", value.trim())) + } else { + Some(ParsedProxyListDecision::Unavailable) + } +} + +#[cfg(any(test, target_os = "windows"))] +fn proxy_url_from_hostport(proxy_scheme: &str, hostport: &str) -> ParsedProxyListDecision { + if hostport.is_empty() { + return ParsedProxyListDecision::Unavailable; + } + if hostport.contains("://") { + return ParsedProxyListDecision::Proxy(hostport.to_string()); + } + ParsedProxyListDecision::Proxy(format!("{proxy_scheme}://{hostport}")) +} + trait EnvSource { fn var(&self, key: &str) -> Option; } diff --git a/codex-rs/codex-client/src/outbound_proxy/windows.rs b/codex-rs/codex-client/src/outbound_proxy/windows.rs new file mode 100644 index 0000000000..9e64196705 --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/windows.rs @@ -0,0 +1,362 @@ +use std::ffi::c_void; +use std::ptr; + +use super::ParsedProxyListDecision; +use super::RequestOrigin; +use super::RouteFailureClass; +use super::SystemProxyDecision; +use super::no_proxy_matches_origin; +use super::parse_proxy_list; +use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; +use windows_sys::Win32::Foundation::FALSE; +use windows_sys::Win32::Foundation::GetLastError; +use windows_sys::Win32::Foundation::GlobalFree; +use windows_sys::Win32::Foundation::TRUE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_AUTODETECTION_FAILED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CANNOT_CONNECT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_CONNECTION_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_INVALID_URL; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_LOGIN_FAILURE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_NAME_NOT_RESOLVED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_CN_INVALID; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_DATE_INVALID; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REV_FAILED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_REVOKED; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_CHANNEL_ERROR; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_FAILURE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CA; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_SECURE_INVALID_CERT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_TIMEOUT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE; +use windows_sys::Win32::Networking::WinHttp::ERROR_WINHTTP_UNRECOGNIZED_SCHEME; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NAMED_PROXY; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_ACCESS_TYPE_NO_PROXY; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DHCP; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTO_DETECT_TYPE_DNS_A; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_AUTO_DETECT; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_CONFIG_URL; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_AUTOPROXY_OPTIONS; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_CURRENT_USER_IE_PROXY_CONFIG; +use windows_sys::Win32::Networking::WinHttp::WINHTTP_PROXY_INFO; +use windows_sys::Win32::Networking::WinHttp::WinHttpCloseHandle; +use windows_sys::Win32::Networking::WinHttp::WinHttpGetIEProxyConfigForCurrentUser; +use windows_sys::Win32::Networking::WinHttp::WinHttpGetProxyForUrl; +use windows_sys::Win32::Networking::WinHttp::WinHttpOpen; +use windows_sys::core::PWSTR; + +pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let ie_config = match current_user_ie_proxy_config() { + Ok(config) => config, + Err(failure) => { + return SystemProxyDecision::Unavailable { failure }; + } + }; + + if let Some(pac_url) = ie_config.auto_config_url.as_deref() { + let decision = resolve_with_pac_url(request_url, origin, pac_url); + if !matches!(decision, SystemProxyDecision::Unavailable { .. }) { + return decision; + } + } + + if ie_config.auto_detect { + let decision = resolve_with_auto_detect(request_url, origin); + if !matches!(decision, SystemProxyDecision::Unavailable { .. }) { + return decision; + } + } + + if let Some(proxy) = ie_config.static_proxy.as_deref() { + if ie_config + .proxy_bypass + .as_deref() + .is_some_and(|bypass| proxy_bypass_matches_origin(bypass, origin)) + { + return SystemProxyDecision::Direct; + } + return proxy_list_decision(proxy, origin); + } + + if ie_config.auto_config_url.is_some() || ie_config.auto_detect { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } + } else { + SystemProxyDecision::Direct + } +} + +fn resolve_with_pac_url( + request_url: &str, + origin: &RequestOrigin, + pac_url: &str, +) -> SystemProxyDecision { + let pac_url = wide_null(pac_url); + let options = WINHTTP_AUTOPROXY_OPTIONS { + dwFlags: WINHTTP_AUTOPROXY_CONFIG_URL, + dwAutoDetectFlags: 0, + lpszAutoConfigUrl: pac_url.as_ptr(), + lpvReserved: ptr::null_mut(), + dwReserved: 0, + fAutoLogonIfChallenged: TRUE, + }; + resolve_with_winhttp_options(request_url, origin, options) +} + +fn resolve_with_auto_detect(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let options = WINHTTP_AUTOPROXY_OPTIONS { + dwFlags: WINHTTP_AUTOPROXY_AUTO_DETECT, + dwAutoDetectFlags: WINHTTP_AUTO_DETECT_TYPE_DHCP | WINHTTP_AUTO_DETECT_TYPE_DNS_A, + lpszAutoConfigUrl: ptr::null(), + lpvReserved: ptr::null_mut(), + dwReserved: 0, + fAutoLogonIfChallenged: FALSE, + }; + resolve_with_winhttp_options(request_url, origin, options) +} + +fn resolve_with_winhttp_options( + request_url: &str, + origin: &RequestOrigin, + mut options: WINHTTP_AUTOPROXY_OPTIONS, +) -> SystemProxyDecision { + let Some(session) = WinHttpSession::open() else { + return SystemProxyDecision::Unavailable { + failure: classify_winhttp_error(last_error()), + }; + }; + + let request_url = wide_null(request_url); + let mut proxy_info = WINHTTP_PROXY_INFO { + dwAccessType: WINHTTP_ACCESS_TYPE_NO_PROXY, + lpszProxy: ptr::null_mut(), + lpszProxyBypass: ptr::null_mut(), + }; + let ok = unsafe { + WinHttpGetProxyForUrl( + session.0, + request_url.as_ptr(), + &mut options, + &mut proxy_info, + ) + }; + if ok == FALSE { + return SystemProxyDecision::Unavailable { + failure: classify_winhttp_error(last_error()), + }; + } + + let proxy_info = ProxyInfo::from_raw(proxy_info); + if proxy_info.access_type == WINHTTP_ACCESS_TYPE_NO_PROXY { + return SystemProxyDecision::Direct; + } + if proxy_info.access_type != WINHTTP_ACCESS_TYPE_NAMED_PROXY { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + } + let Some(proxy) = proxy_info.proxy.as_deref() else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + proxy_list_decision(proxy, origin) +} + +fn proxy_list_decision(proxy_list: &str, origin: &RequestOrigin) -> SystemProxyDecision { + match parse_proxy_list(proxy_list, &origin.scheme) { + ParsedProxyListDecision::Direct => SystemProxyDecision::Direct, + ParsedProxyListDecision::Proxy(url) => SystemProxyDecision::Proxy { url }, + ParsedProxyListDecision::UnsupportedScheme => SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + }, + ParsedProxyListDecision::Unavailable => SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }, + } +} + +fn current_user_ie_proxy_config() -> Result { + let mut raw = WINHTTP_CURRENT_USER_IE_PROXY_CONFIG { + fAutoDetect: FALSE, + lpszAutoConfigUrl: ptr::null_mut(), + lpszProxy: ptr::null_mut(), + lpszProxyBypass: ptr::null_mut(), + }; + let ok = unsafe { WinHttpGetIEProxyConfigForCurrentUser(&mut raw) }; + if ok == FALSE { + let error = last_error(); + if error == ERROR_FILE_NOT_FOUND { + // Match WinHTTP's fallback by attempting WPAD when no IE proxy settings exist. + return Ok(IeProxyConfig { + auto_detect: true, + ..Default::default() + }); + } + return Err(classify_winhttp_error(error)); + } + + let auto_config_url = GlobalWideString::from_raw(raw.lpszAutoConfigUrl).into_string(); + let static_proxy = GlobalWideString::from_raw(raw.lpszProxy).into_string(); + let proxy_bypass = GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(); + + Ok(IeProxyConfig { + auto_detect: raw.fAutoDetect != FALSE, + auto_config_url, + static_proxy, + proxy_bypass, + }) +} + +#[derive(Debug, Default)] +struct IeProxyConfig { + auto_detect: bool, + auto_config_url: Option, + static_proxy: Option, + proxy_bypass: Option, +} + +struct ProxyInfo { + access_type: u32, + proxy: Option, + _proxy_bypass: Option, +} + +impl ProxyInfo { + fn from_raw(raw: WINHTTP_PROXY_INFO) -> Self { + Self { + access_type: raw.dwAccessType, + proxy: GlobalWideString::from_raw(raw.lpszProxy).into_string(), + _proxy_bypass: GlobalWideString::from_raw(raw.lpszProxyBypass).into_string(), + } + } +} + +struct GlobalWideString(PWSTR); + +impl GlobalWideString { + fn from_raw(ptr: PWSTR) -> Self { + Self(ptr) + } + + fn into_string(self) -> Option { + if self.0.is_null() { + return None; + } + let string = unsafe { wide_ptr_to_string(self.0) }; + if string.is_empty() { + None + } else { + Some(string) + } + } +} + +impl Drop for GlobalWideString { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + GlobalFree(self.0.cast::()); + } + } + } +} + +struct WinHttpSession(*mut c_void); + +impl WinHttpSession { + fn open() -> Option { + let agent = wide_null("Codex"); + let handle = unsafe { + WinHttpOpen( + agent.as_ptr(), + WINHTTP_ACCESS_TYPE_NO_PROXY, + ptr::null(), + ptr::null(), + 0, + ) + }; + if handle.is_null() { + None + } else { + Some(Self(handle)) + } + } +} + +impl Drop for WinHttpSession { + fn drop(&mut self) { + if !self.0.is_null() { + unsafe { + WinHttpCloseHandle(self.0); + } + } + } +} + +fn proxy_bypass_matches_origin(proxy_bypass: &str, origin: &RequestOrigin) -> bool { + proxy_bypass + .split(|ch: char| ch == ';' || ch == ',' || ch.is_whitespace()) + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .any(|entry| { + if entry.eq_ignore_ascii_case("") { + !origin.host.contains('.') + } else { + no_proxy_matches_origin(entry, origin) + } + }) +} + +#[cfg(test)] +#[path = "windows_tests.rs"] +mod tests; + +fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +} + +unsafe fn wide_ptr_to_string(ptr: PWSTR) -> String { + let mut len = 0; + while unsafe { *ptr.add(len) } != 0 { + len += 1; + } + let slice = unsafe { std::slice::from_raw_parts(ptr, len) }; + String::from_utf16_lossy(slice) +} + +fn last_error() -> u32 { + unsafe { GetLastError() } +} + +fn classify_winhttp_error(code: u32) -> RouteFailureClass { + match code { + ERROR_WINHTTP_TIMEOUT => RouteFailureClass::ConnectTimeout, + ERROR_WINHTTP_LOGIN_FAILURE => RouteFailureClass::ProxyAuthenticationRequired, + ERROR_WINHTTP_AUTODETECTION_FAILED + | ERROR_WINHTTP_BAD_AUTO_PROXY_SCRIPT + | ERROR_WINHTTP_SCRIPT_EXECUTION_ERROR + | ERROR_WINHTTP_UNABLE_TO_DOWNLOAD_SCRIPT + | ERROR_WINHTTP_UNHANDLED_SCRIPT_TYPE => RouteFailureClass::ProxyResolutionUnavailable, + ERROR_WINHTTP_SECURE_CERT_CN_INVALID + | ERROR_WINHTTP_SECURE_CERT_DATE_INVALID + | ERROR_WINHTTP_SECURE_CERT_REVOKED + | ERROR_WINHTTP_SECURE_CERT_REV_FAILED + | ERROR_WINHTTP_SECURE_CERT_WRONG_USAGE + | ERROR_WINHTTP_SECURE_CHANNEL_ERROR + | ERROR_WINHTTP_SECURE_FAILURE + | ERROR_WINHTTP_SECURE_INVALID_CA + | ERROR_WINHTTP_SECURE_INVALID_CERT => RouteFailureClass::TlsError, + ERROR_WINHTTP_INVALID_URL | ERROR_WINHTTP_UNRECOGNIZED_SCHEME => { + RouteFailureClass::InvalidProxyConfig + } + ERROR_WINHTTP_CANNOT_CONNECT + | ERROR_WINHTTP_CONNECTION_ERROR + | ERROR_WINHTTP_NAME_NOT_RESOLVED => RouteFailureClass::ResolverError, + _ => RouteFailureClass::ResolverError, + } +} diff --git a/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs b/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs new file mode 100644 index 0000000000..3772c057db --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs @@ -0,0 +1,18 @@ +use super::*; + +#[test] +fn proxy_bypass_matches_whitespace_separated_winhttp_entries() { + let local_origin = RequestOrigin { + scheme: "https".to_string(), + host: "intranet".to_string(), + port: 443, + }; + assert!(proxy_bypass_matches_origin(" *.corp", &local_origin)); + + let corp_origin = RequestOrigin { + scheme: "https".to_string(), + host: "service.corp".to_string(), + port: 443, + }; + assert!(proxy_bypass_matches_origin(" *.corp", &corp_origin)); +} diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs index 9c713d28f1..27e303a5d8 100644 --- a/codex-rs/codex-client/src/outbound_proxy_tests.rs +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -103,6 +103,18 @@ async fn enabled_environment_proxy_routes_request_through_proxy() { ); } +#[test] +fn parses_pac_proxy_tokens() { + assert_eq!( + parse_proxy_list("PROXY proxy.internal:8080; DIRECT", "https"), + ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string()) + ); + assert_eq!( + parse_proxy_list("HTTPS proxy.internal:8443", "https"), + ParsedProxyListDecision::Proxy("https://proxy.internal:8443".to_string()) + ); +} + #[test] fn unavailable_system_proxy_decision_is_cached() { let request_url = "https://unavailable-cache.test/oauth/token"; @@ -131,3 +143,67 @@ fn system_proxy_cache_is_bounded() { assert_eq!(cache.len(), SYSTEM_PROXY_CACHE_MAX_ENTRIES); } + +#[test] +fn parses_static_winhttp_proxy_entries_for_target_scheme() { + assert_eq!( + parse_proxy_list("http=web-proxy:8080;https=secure-proxy:8443", "https"), + ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string()) + ); + assert_eq!( + parse_proxy_list("http=web-proxy:8080 https=secure-proxy:8443", "https"), + ParsedProxyListDecision::Proxy("http://secure-proxy:8443".to_string()) + ); + assert_eq!( + parse_proxy_list("http=web-proxy:8080", "https"), + ParsedProxyListDecision::Unavailable + ); + assert_eq!( + parse_proxy_list("proxy.internal:8080", "https"), + ParsedProxyListDecision::Proxy("http://proxy.internal:8080".to_string()) + ); +} + +#[test] +fn reports_direct_and_unsupported_proxy_tokens() { + assert_eq!( + parse_proxy_list("DIRECT; PROXY proxy.internal:8080", "https"), + ParsedProxyListDecision::Direct + ); + assert_eq!( + parse_proxy_list("DIRECT", "https"), + ParsedProxyListDecision::Direct + ); + assert_eq!( + parse_proxy_list("SOCKS proxy.internal:1080", "https"), + ParsedProxyListDecision::UnsupportedScheme + ); +} + +#[test] +fn no_proxy_matches_exact_suffix_wildcard_and_port() { + let origin = RequestOrigin { + scheme: "https".to_string(), + host: "auth.openai.com".to_string(), + port: 443, + }; + assert!(no_proxy_matches_origin("auth.openai.com", &origin)); + assert!(!no_proxy_matches_origin("openai.com", &origin)); + assert!(no_proxy_matches_origin(".openai.com", &origin)); + assert!(no_proxy_matches_origin("*.openai.com", &origin)); + assert!(no_proxy_matches_origin("auth.openai.com:443", &origin)); + assert!(!no_proxy_matches_origin("auth.openai.com:8443", &origin)); +} + +#[test] +fn system_proxy_cache_key_preserves_url_specific_pac_decisions() { + let request_url = "https://auth.openai.com/oauth/token?access_token=secret"; + let cache_key = system_proxy_cache_key(request_url); + + assert_ne!( + cache_key, + system_proxy_cache_key("https://auth.openai.com/oauth/revoke") + ); + #[cfg(target_os = "windows")] + assert!(!cache_key.contains(request_url)); +} From ff37f4a6efc98c205809c2ae20e2c01e7b49040d Mon Sep 17 00:00:00 2001 From: Samuel Yuan Date: Mon, 22 Jun 2026 17:08:15 -0500 Subject: [PATCH 50/86] Register full CDP requirements feature (#28769) register cdp requirements feature flag --- codex-rs/core/config.schema.json | 6 ++++++ codex-rs/core/src/config/config_tests.rs | 2 ++ codex-rs/features/src/lib.rs | 10 ++++++++++ 3 files changed, 18 insertions(+) diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 4615f4e858..a10d01a8a7 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -449,6 +449,9 @@ "browser_use_external": { "type": "boolean" }, + "browser_use_full_cdp_access": { + "type": "boolean" + }, "chronicle": { "type": "boolean" }, @@ -4750,6 +4753,9 @@ "browser_use_external": { "type": "boolean" }, + "browser_use_full_cdp_access": { + "type": "boolean" + }, "chronicle": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 2d27d8d603..9b4b6e93f8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -9752,6 +9752,7 @@ async fn browser_feature_requirements_are_valid() -> std::io::Result<()> { [features] in_app_browser = false browser_use = false +browser_use_full_cdp_access = false "#, ), ) @@ -9760,6 +9761,7 @@ browser_use = false assert!(!config.features.enabled(Feature::InAppBrowser)); assert!(!config.features.enabled(Feature::BrowserUse)); + assert!(!config.features.enabled(Feature::BrowserUseFullCdpAccess)); Ok(()) } diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 933ec73c60..210b8689af 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -175,6 +175,10 @@ pub enum Feature { /// /// Requirements-only gate: this should be set from requirements, not user config. BrowserUse, + /// Allow Browser Use integration to access the full Chrome DevTools Protocol surface. + /// + /// Requirements-only gate: this should be set from requirements, not user config. + BrowserUseFullCdpAccess, /// Allow Browser Use integration with external browsers. /// /// Requirements-only gate: this should be set from requirements, not user config. @@ -1115,6 +1119,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::BrowserUseFullCdpAccess, + key: "browser_use_full_cdp_access", + stage: Stage::Stable, + default_enabled: true, + }, FeatureSpec { id: Feature::BrowserUseExternal, key: "browser_use_external", From bf7148626b36e278e8473a09996f21ec6a0c7482 Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Mon, 22 Jun 2026 15:08:59 -0700 Subject: [PATCH 51/86] [codex] fetch featured IDs for remote plugins (#29485) ## Summary - fetch featured plugin IDs when the loaded catalog includes `openai-curated-remote` - extend the existing remote marketplace regression test to cover the featured IDs response ## Why When the remote plugin catalog was enabled, app-server loaded `openai-curated-remote` but skipped `/plugins/featured` because the request processor only fetched featured IDs for the local `openai-curated` marketplace. As a result, the desktop app could not render the backend-curated remote featured set. This keeps the existing local behavior and also returns the curated ranking for remote plugins. ## Test plan - `just fmt` - `git diff --check` - `just test -p codex-app-server plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled` --- .../app-server/src/request_processors/plugins.rs | 9 ++++----- codex-rs/app-server/tests/suite/v2/plugin_list.rs | 13 ++++++++++++- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 1e4716a88d..3913ded3a9 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -745,11 +745,10 @@ impl PluginRequestProcessor { ); } - let featured_plugin_ids = if !plugins_input.remote_plugin_enabled - && data - .iter() - .any(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) - { + let featured_plugin_ids = if data.iter().any(|marketplace| { + marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME + || marketplace.name == REMOTE_GLOBAL_MARKETPLACE_NAME + }) { match plugins_manager .featured_plugin_ids_for_config(&plugins_input, auth.as_ref()) .await diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 62a17278f2..bcfd85f191 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1761,6 +1761,14 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - .respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body)) .mount(&server) .await; + Mock::given(method("GET")) + .and(path("/backend-api/plugins/featured")) + .and(query_param("platform", "codex")) + .respond_with( + ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated-remote"]"#), + ) + .mount(&server) + .await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -1854,7 +1862,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - cached_plugin_ids, vec!["plugins~Plugin_00000000000000000000000000000000".to_string()] ); - assert_eq!(response.featured_plugin_ids, Vec::::new()); + assert_eq!( + response.featured_plugin_ids, + vec!["linear@openai-curated-remote".to_string()] + ); assert!( !server .received_requests() From 372b5ac7c2876ace31b736a35a19e09a45c6f798 Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 22 Jun 2026 23:19:54 +0100 Subject: [PATCH 52/86] Upgrade bundled OpenSSL to 3.6.3 (#29487) ## Summary - upgrade the bundled OpenSSL source from 3.5.5 to 3.6.3 - update the Bazel `openssl-sys` build dependency to use the upgraded source crate - refresh the Bazel module lockfile ## Why OpenSSL 3.5.5 is within the affected ranges for security issues fixed in later releases. The Rust `openssl-src` wrapper does not currently publish OpenSSL 3.5.7, so this moves the vendored Linux musl build to the available patched 3.6.3 release. --- MODULE.bazel | 2 +- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/MODULE.bazel b/MODULE.bazel index a24f93afd8..20d80f3962 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -309,7 +309,7 @@ crate.annotation( # Build scripts compile in Bazel's exec configuration, so target-specific # optional build deps are otherwise dropped for the musl release platforms. build_script_deps = [ - "@crates//:openssl-src-300.5.5+3.5.5", + "@crates//:openssl-src-300.6.1+3.6.3", ], build_script_env = { "OPENSSL_DIR": "$(execpath @openssl//:gen_dir)", diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 74d66c1f66..7cc8d83419 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1293,7 +1293,7 @@ "openssl-macros_0.1.1": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{}}", "openssl-probe_0.1.6": "{\"dependencies\":[],\"features\":{}}", "openssl-probe_0.2.1": "{\"dependencies\":[],\"features\":{}}", - "openssl-src_300.5.5+3.5.5": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{\"camellia\":[],\"default\":[],\"force-engine\":[],\"idea\":[],\"ktls\":[],\"legacy\":[],\"no-dso\":[],\"seed\":[],\"ssl3\":[],\"weak-crypto\":[]}}", + "openssl-src_300.6.1+3.6.3": "{\"dependencies\":[{\"name\":\"cc\",\"req\":\"^1.0.79\"}],\"features\":{\"camellia\":[],\"default\":[],\"force-engine\":[],\"idea\":[],\"ktls\":[],\"legacy\":[],\"no-dso\":[],\"seed\":[],\"ssl3\":[],\"weak-crypto\":[]}}", "openssl-sys_0.9.111": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.27\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}", "openssl-sys_0.9.112": "{\"dependencies\":[{\"features\":[\"ssl\",\"bindgen\"],\"name\":\"aws-lc-fips-sys\",\"optional\":true,\"req\":\"^0.13\"},{\"features\":[\"ssl\"],\"name\":\"aws-lc-sys\",\"optional\":true,\"req\":\"^0.38\"},{\"features\":[\"experimental\"],\"kind\":\"build\",\"name\":\"bindgen\",\"optional\":true,\"req\":\"^0.72.0\"},{\"name\":\"bssl-sys\",\"optional\":true,\"req\":\"^0.1.0\"},{\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.0.61\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"features\":[\"legacy\"],\"kind\":\"build\",\"name\":\"openssl-src\",\"optional\":true,\"req\":\"^300.2.0\"},{\"kind\":\"build\",\"name\":\"pkg-config\",\"req\":\"^0.3.9\"},{\"kind\":\"build\",\"name\":\"vcpkg\",\"req\":\"^0.2.8\"}],\"features\":{\"aws-lc\":[\"dep:aws-lc-sys\"],\"aws-lc-fips\":[\"dep:aws-lc-fips-sys\"],\"unstable_boringssl\":[\"bssl-sys\"],\"vendored\":[\"openssl-src\"]}}", "openssl_0.10.75": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^2.2.1\"},{\"name\":\"cfg-if\",\"req\":\"^1.0\"},{\"name\":\"ffi\",\"package\":\"openssl-sys\",\"req\":\"^0.9.111\"},{\"name\":\"foreign-types\",\"req\":\"^0.3.1\"},{\"kind\":\"dev\",\"name\":\"hex\",\"req\":\"^0.4\"},{\"name\":\"libc\",\"req\":\"^0.2\"},{\"name\":\"once_cell\",\"req\":\"^1.5.2\"},{\"name\":\"openssl-macros\",\"req\":\"^0.1.1\"}],\"features\":{\"aws-lc\":[\"ffi/aws-lc\"],\"aws-lc-fips\":[\"ffi/aws-lc-fips\"],\"bindgen\":[\"ffi/bindgen\"],\"default\":[],\"unstable_boringssl\":[\"ffi/unstable_boringssl\"],\"v101\":[],\"v102\":[],\"v110\":[],\"v111\":[],\"vendored\":[\"ffi/vendored\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f0d9f135b7..18b7bc9190 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -9735,9 +9735,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.5.5+3.5.5" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] From 7ce84e64d892044285e1a5918ddf96d0b8a99c7b Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 22 Jun 2026 16:00:56 -0700 Subject: [PATCH 53/86] [codex] Update esbuild to 0.28.1 (#29489) ## Why The TypeScript workspace resolved `esbuild` 0.25.10 transitively through the SDK toolchain. `esbuild` 0.28.1 adds integrity verification to the Deno binary download path addressed by [GHSA-gv7w-rqvm-qjhr](https://github.com/evanw/esbuild/security/advisories/GHSA-gv7w-rqvm-qjhr), preventing an attacker-controlled npm registry from supplying an executable without a content check. ## What changed - Add a root workspace resolution for `esbuild` 0.28.1. - Regenerate `pnpm-lock.yaml` so `tsup`, `bundle-require`, and `ts-jest` all resolve the patched version. ## Validation - Frozen pnpm install, including the SDK's `tsup` build - `pnpm --filter @openai/codex-sdk exec jest tests/exec.test.ts --runInBand` - Confirmed the installed dependency graph contains only `esbuild` 0.28.1 --- package.json | 1 + pnpm-lock.yaml | 237 +++++++++++++++++++++++++------------------------ 2 files changed, 120 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index 0c99b5e8b0..c510d787de 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "resolutions": { "@modelcontextprotocol/sdk": "1.26.0", "braces": "^3.0.3", + "esbuild": "0.28.1", "flatted": "3.4.2", "glob@10.4.5": "10.5.0", "handlebars": "4.7.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03ff4643ab..a363b29edc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,7 @@ settings: overrides: '@modelcontextprotocol/sdk': 1.26.0 braces: ^3.0.3 + esbuild: 0.28.1 flatted: 3.4.2 glob@10.4.5: 10.5.0 handlebars: 4.7.9 @@ -62,10 +63,10 @@ importers: version: 3.6.2 ts-jest: specifier: ^29.3.4 - version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) + version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) ts-jest-mock-import-meta: specifier: ^1.3.1 - version: 1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)) + version: 1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)) ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@20.19.18)(typescript@5.9.2) @@ -256,158 +257,158 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} - '@esbuild/aix-ppc64@0.25.10': - resolution: {integrity: sha512-0NFWnA+7l41irNuaSVlLfgNT12caWJVLzp5eAVhZ0z1qpxbockccEt3s+149rE64VUI3Ml2zt8Nv5JVc4QXTsw==} + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.10': - resolution: {integrity: sha512-LSQa7eDahypv/VO6WKohZGPSJDq5OVOo3UoFR1E4t4Gj1W7zEQMUhI+lo81H+DtB+kP+tDgBp+M4oNCwp6kffg==} + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.10': - resolution: {integrity: sha512-dQAxF1dW1C3zpeCDc5KqIYuZ1tgAdRXNoZP7vkBIRtKZPYe2xVr/d3SkirklCHudW1B45tGiUlz2pUWDfbDD4w==} + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.10': - resolution: {integrity: sha512-MiC9CWdPrfhibcXwr39p9ha1x0lZJ9KaVfvzA0Wxwz9ETX4v5CHfF09bx935nHlhi+MxhA63dKRRQLiVgSUtEg==} + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.10': - resolution: {integrity: sha512-JC74bdXcQEpW9KkV326WpZZjLguSZ3DfS8wrrvPMHgQOIEIG/sPXEN/V8IssoJhbefLRcRqw6RQH2NnpdprtMA==} + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.10': - resolution: {integrity: sha512-tguWg1olF6DGqzws97pKZ8G2L7Ig1vjDmGTwcTuYHbuU6TTjJe5FXbgs5C1BBzHbJ2bo1m3WkQDbWO2PvamRcg==} + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.10': - resolution: {integrity: sha512-3ZioSQSg1HT2N05YxeJWYR+Libe3bREVSdWhEEgExWaDtyFbbXWb49QgPvFH8u03vUPX10JhJPcz7s9t9+boWg==} + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.10': - resolution: {integrity: sha512-LLgJfHJk014Aa4anGDbh8bmI5Lk+QidDmGzuC2D+vP7mv/GeSN+H39zOf7pN5N8p059FcOfs2bVlrRr4SK9WxA==} + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.10': - resolution: {integrity: sha512-5luJWN6YKBsawd5f9i4+c+geYiVEw20FVW5x0v1kEMWNq8UctFjDiMATBxLvmmHA4bf7F6hTRaJgtghFr9iziQ==} + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.10': - resolution: {integrity: sha512-oR31GtBTFYCqEBALI9r6WxoU/ZofZl962pouZRTEYECvNF/dtXKku8YXcJkhgK/beU+zedXfIzHijSRapJY3vg==} + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.10': - resolution: {integrity: sha512-NrSCx2Kim3EnnWgS4Txn0QGt0Xipoumb6z6sUtl5bOEZIVKhzfyp/Lyw4C1DIYvzeW/5mWYPBFJU3a/8Yr75DQ==} + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.10': - resolution: {integrity: sha512-xoSphrd4AZda8+rUDDfD9J6FUMjrkTz8itpTITM4/xgerAZZcFW7Dv+sun7333IfKxGG8gAq+3NbfEMJfiY+Eg==} + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.10': - resolution: {integrity: sha512-ab6eiuCwoMmYDyTnyptoKkVS3k8fy/1Uvq7Dj5czXI6DF2GqD2ToInBI0SHOp5/X1BdZ26RKc5+qjQNGRBelRA==} + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.10': - resolution: {integrity: sha512-NLinzzOgZQsGpsTkEbdJTCanwA5/wozN9dSgEl12haXJBzMTpssebuXR42bthOF3z7zXFWH1AmvWunUCkBE4EA==} + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.10': - resolution: {integrity: sha512-FE557XdZDrtX8NMIeA8LBJX3dC2M8VGXwfrQWU7LB5SLOajfJIxmSdyL/gU1m64Zs9CBKvm4UAuBp5aJ8OgnrA==} + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.10': - resolution: {integrity: sha512-3BBSbgzuB9ajLoVZk0mGu+EHlBwkusRmeNYdqmznmMc9zGASFjSsxgkNsqmXugpPk00gJ0JNKh/97nxmjctdew==} + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.10': - resolution: {integrity: sha512-QSX81KhFoZGwenVyPoberggdW1nrQZSvfVDAIUXr3WqLRZGZqWk/P4T8p2SP+de2Sr5HPcvjhcJzEiulKgnxtA==} + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.10': - resolution: {integrity: sha512-AKQM3gfYfSW8XRk8DdMCzaLUFB15dTrZfnX8WXQoOUpUBQ+NaAFCP1kPS/ykbbGYz7rxn0WS48/81l9hFl3u4A==} + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.10': - resolution: {integrity: sha512-7RTytDPGU6fek/hWuN9qQpeGPBZFfB4zZgcz2VK2Z5VpdUxEI8JKYsg3JfO0n/Z1E/6l05n0unDCNc4HnhQGig==} + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.10': - resolution: {integrity: sha512-5Se0VM9Wtq797YFn+dLimf2Zx6McttsH2olUBsDml+lm0GOCRVebRWUvDtkY4BWYv/3NgzS8b/UM3jQNh5hYyw==} + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.10': - resolution: {integrity: sha512-XkA4frq1TLj4bEMB+2HnI0+4RnjbuGZfet2gs/LNs5Hc7D89ZQBHQ0gL2ND6Lzu1+QVkjp3x1gIcPKzRNP8bXw==} + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.10': - resolution: {integrity: sha512-AVTSBhTX8Y/Fz6OmIVBip9tJzZEUcY8WLh7I59+upa5/GPhh2/aM6bvOMQySspnCCHvFi79kMtdJS1w0DXAeag==} + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.10': - resolution: {integrity: sha512-fswk3XT0Uf2pGJmOpDB7yknqhVkJQkAQOcW/ccVOtfx05LkbWOaRAtn5SaqXypeKQra1QaEa841PgrSL9ubSPQ==} + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.10': - resolution: {integrity: sha512-ah+9b59KDTSfpaCg6VdJoOQvKjI33nTaQr4UluQwW7aEwZQsbMCfTmfEO4VyewOxx4RaDT/xCy9ra2GPWmO7Kw==} + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.10': - resolution: {integrity: sha512-QHPDbKkrGO8/cz9LKVnJU22HOi4pxZnZhhA2HYHez5Pz4JeffhDjf85E57Oyco163GnzNCVkZK0b/n4Y0UHcSw==} + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.10': - resolution: {integrity: sha512-9KpxSVFCu0iK1owoez6aC/s/EdUQLDN3adTxGCqxMVhrPDj6bt5dbrHDXUuq+Bs2vATFBBrQS5vdQ/Ed2P+nbw==} + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -1000,7 +1001,7 @@ packages: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.18' + esbuild: 0.28.1 bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -1201,8 +1202,8 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} - esbuild@0.25.10: - resolution: {integrity: sha512-9RiGKvCwaqxO2owP61uQ4BgNborAQskMR6QusfWzQqv7AZOg5oGehdY2pRJMTKuwxd1IDBP4rSbI5lHzU7SMsQ==} + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} hasBin: true @@ -2750,82 +2751,82 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 - '@esbuild/aix-ppc64@0.25.10': + '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.25.10': + '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.25.10': + '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.25.10': + '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.25.10': + '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.25.10': + '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.25.10': + '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.25.10': + '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.25.10': + '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.25.10': + '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.25.10': + '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.25.10': + '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.25.10': + '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.25.10': + '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.25.10': + '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.25.10': + '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.25.10': + '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.25.10': + '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.25.10': + '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.25.10': + '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.25.10': + '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.25.10': + '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.25.10': + '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.25.10': + '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.25.10': + '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.25.10': + '@esbuild/win32-x64@0.28.1': optional: true '@eslint-community/eslint-utils@4.9.0(eslint@9.36.0)': @@ -3541,9 +3542,9 @@ snapshots: buffer-from@1.1.2: {} - bundle-require@5.1.0(esbuild@0.25.10): + bundle-require@5.1.0(esbuild@0.28.1): dependencies: - esbuild: 0.25.10 + esbuild: 0.28.1 load-tsconfig: 0.2.5 bytes@3.1.2: {} @@ -3697,34 +3698,34 @@ snapshots: dependencies: es-errors: 1.3.0 - esbuild@0.25.10: + esbuild@0.28.1: optionalDependencies: - '@esbuild/aix-ppc64': 0.25.10 - '@esbuild/android-arm': 0.25.10 - '@esbuild/android-arm64': 0.25.10 - '@esbuild/android-x64': 0.25.10 - '@esbuild/darwin-arm64': 0.25.10 - '@esbuild/darwin-x64': 0.25.10 - '@esbuild/freebsd-arm64': 0.25.10 - '@esbuild/freebsd-x64': 0.25.10 - '@esbuild/linux-arm': 0.25.10 - '@esbuild/linux-arm64': 0.25.10 - '@esbuild/linux-ia32': 0.25.10 - '@esbuild/linux-loong64': 0.25.10 - '@esbuild/linux-mips64el': 0.25.10 - '@esbuild/linux-ppc64': 0.25.10 - '@esbuild/linux-riscv64': 0.25.10 - '@esbuild/linux-s390x': 0.25.10 - '@esbuild/linux-x64': 0.25.10 - '@esbuild/netbsd-arm64': 0.25.10 - '@esbuild/netbsd-x64': 0.25.10 - '@esbuild/openbsd-arm64': 0.25.10 - '@esbuild/openbsd-x64': 0.25.10 - '@esbuild/openharmony-arm64': 0.25.10 - '@esbuild/sunos-x64': 0.25.10 - '@esbuild/win32-arm64': 0.25.10 - '@esbuild/win32-ia32': 0.25.10 - '@esbuild/win32-x64': 0.25.10 + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 escalade@3.2.0: {} @@ -5047,11 +5048,11 @@ snapshots: ts-interface-checker@0.1.13: {} - ts-jest-mock-import-meta@1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)): + ts-jest-mock-import-meta@1.3.1(ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)): dependencies: - ts-jest: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) + ts-jest: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2) - ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2): + ts-jest@29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.28.1)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 @@ -5069,7 +5070,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 babel-jest: 29.7.0(@babel/core@7.28.4) - esbuild: 0.25.10 + esbuild: 0.28.1 jest-util: 29.7.0 ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2): @@ -5092,12 +5093,12 @@ snapshots: tsup@8.5.0(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.1): dependencies: - bundle-require: 5.1.0(esbuild@0.25.10) + bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 debug: 4.4.3 - esbuild: 0.25.10 + esbuild: 0.28.1 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 From 2c351cb8646054427e0ea81a484233114b1234f7 Mon Sep 17 00:00:00 2001 From: Drew Date: Mon, 22 Jun 2026 16:01:27 -0700 Subject: [PATCH 54/86] [plugins] Add dark-mode logo metadata (#29488) Adds additive dark-mode plugin logo metadata across manifests, remote catalogs, and the app-server protocol while keeping uninstalled Git listings free of synthetic local paths. Supersedes #28945. This replacement uses an upstream branch so trusted CI can use the repository-provided remote Bazel configuration. ## Current state Plugin interfaces expose only the default logo asset. Clients therefore cannot select a dedicated dark-mode logo even when a plugin provides one. ## What this PR changes - Adds nullable `logoDark` and `logoUrlDark` fields to `PluginInterface`. - Resolves local `interface.logoDark` assets and maps remote `logo_url_dark` values. - Removes path-backed interface assets, including `logoDark`, from uninstalled Git fallback listings until the plugin has a real local root. - Updates the bundled plugin validator and manifest reference. - Regenerates the app-server JSON schemas and TypeScript types. Local manifests expose `interface.logoDark` as a package-relative asset path. Remote catalog responses expose `logo_url_dark`. These values map into separate app-server fields so clients can preserve local-path and remote-URL handling. ## Risk The fields are additive and nullable, so existing clients retain their current logo behavior. The main risks are an incomplete mapping path or exposing a synthetic local path for an uninstalled Git plugin. Local-manifest, remote-catalog, fallback-listing, protocol serialization, and app-server integration tests cover those paths. Spiciness: 2/5 ## Testing - `just write-app-server-schema` - `just fmt` - Regression test first failed with `logo_dark` resolved to `/assets/logo-dark.png`, then passed after the fallback-listing fix. - `just test -p codex-core-plugins` (267 tests passed) - `just test -p codex-app-server 'suite::v2::plugin'` (114 tests passed) - `just test -p codex-app-server-protocol -p codex-core-plugins -p codex-plugin -p codex-skills` (517 tests passed before the follow-up) - `just test -p codex-tui plugin` (47 tests passed) - Validated a local plugin manifest containing `interface.logoDark` with the bundled validator. ## Manual verification Create a local plugin with both `interface.logo` and `interface.logoDark`, then call `plugin/list` or `plugin/read`. Confirm the response contains separate `logo` and `logoDark` paths. For a remote catalog entry, confirm `logoUrlDark` is populated from `logo_url_dark`. For an uninstalled Git marketplace entry, confirm path-backed interface assets remain absent until installation. Issue: N/A - coordinated maintainer change. --- .../codex_app_server_protocol.schemas.json | 18 +++++++ .../codex_app_server_protocol.v2.schemas.json | 18 +++++++ .../json/v2/PluginInstalledResponse.json | 18 +++++++ .../schema/json/v2/PluginListResponse.json | 18 +++++++ .../schema/json/v2/PluginReadResponse.json | 18 +++++++ .../json/v2/PluginShareListResponse.json | 18 +++++++ .../schema/typescript/v2/PluginInterface.ts | 8 +++ .../src/protocol/v2/plugin.rs | 4 ++ .../src/protocol/v2/tests.rs | 11 ++++ .../src/request_processors/plugins.rs | 2 + .../app-server/tests/suite/v2/plugin_list.rs | 2 + .../app-server/tests/suite/v2/plugin_read.rs | 25 +++++++++- .../app-server/tests/suite/v2/plugin_share.rs | 2 + codex-rs/core-plugins/src/manager_tests.rs | 2 + codex-rs/core-plugins/src/manifest.rs | 35 +++++++++++++ codex-rs/core-plugins/src/marketplace.rs | 1 + .../core-plugins/src/marketplace_tests.rs | 50 +++++++++++++++++++ codex-rs/core-plugins/src/remote.rs | 4 ++ .../core-plugins/src/remote/share/tests.rs | 2 + codex-rs/core-plugins/src/remote_tests.rs | 15 ++++++ codex-rs/plugin/src/manifest.rs | 4 ++ .../references/plugin-json-spec.md | 4 +- .../plugin-creator/scripts/validate_plugin.py | 3 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + 24 files changed, 281 insertions(+), 3 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index b2be46f1f3..810b8541d4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13407,6 +13407,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -13414,6 +13425,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 98b0d42976..de7d46aacb 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9811,6 +9811,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -9818,6 +9829,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json index ffe20e8c55..ce4ae5aa0b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -134,6 +134,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -141,6 +152,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index d756f61a68..1002ad1a8d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -134,6 +134,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -141,6 +152,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index bef85afddd..a2d2d442c5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -313,6 +313,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -320,6 +331,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index 7cffdeab9a..be97e20b65 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -108,6 +108,17 @@ ], "description": "Local logo path, resolved from the installed plugin package." }, + "logoDark": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ], + "description": "Local dark-mode logo path, resolved from the installed plugin package." + }, "logoUrl": { "description": "Remote logo URL from the plugin catalog.", "type": [ @@ -115,6 +126,13 @@ "null" ] }, + "logoUrlDark": { + "description": "Remote dark-mode logo URL from the plugin catalog.", + "type": [ + "string", + "null" + ] + }, "longDescription": { "type": [ "string", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts index 4e97ee66f3..1e57d4974d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInterface.ts @@ -21,10 +21,18 @@ composerIconUrl: string | null, * Local logo path, resolved from the installed plugin package. */ logo: AbsolutePathBuf | null, +/** + * Local dark-mode logo path, resolved from the installed plugin package. + */ +logoDark: AbsolutePathBuf | null, /** * Remote logo URL from the plugin catalog. */ logoUrl: string | null, +/** + * Remote dark-mode logo URL from the plugin catalog. + */ +logoUrlDark: string | null, /** * Local screenshot paths, resolved from the installed plugin package. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 128425d1ce..dbe0b3d55d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -716,8 +716,12 @@ pub struct PluginInterface { pub composer_icon_url: Option, /// Local logo path, resolved from the installed plugin package. pub logo: Option, + /// Local dark-mode logo path, resolved from the installed plugin package. + pub logo_dark: Option, /// Remote logo URL from the plugin catalog. pub logo_url: Option, + /// Remote dark-mode logo URL from the plugin catalog. + pub logo_url_dark: Option, /// Local screenshot paths, resolved from the installed plugin package. pub screenshots: Vec, /// Remote screenshot URLs from the plugin catalog. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 74e534be00..ba69b6d6e2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2938,6 +2938,13 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { }; let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap(); let composer_icon_json = composer_icon.as_path().display().to_string(); + let logo_dark = if cfg!(windows) { + r"C:\plugins\linear\logo-dark.png" + } else { + "/plugins/linear/logo-dark.png" + }; + let logo_dark = AbsolutePathBuf::try_from(PathBuf::from(logo_dark)).unwrap(); + let logo_dark_json = logo_dark.as_path().display().to_string(); let interface = PluginInterface { display_name: Some("Linear".to_string()), @@ -2954,7 +2961,9 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { composer_icon: Some(composer_icon), composer_icon_url: Some("https://example.com/linear/icon.png".to_string()), logo: None, + logo_dark: Some(logo_dark), logo_url: Some("https://example.com/linear/logo.png".to_string()), + logo_url_dark: Some("https://example.com/linear/logo-dark.png".to_string()), screenshots: Vec::new(), screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()], }; @@ -2976,7 +2985,9 @@ fn plugin_interface_serializes_local_paths_and_remote_urls_separately() { "composerIcon": composer_icon_json, "composerIconUrl": "https://example.com/linear/icon.png", "logo": null, + "logoDark": logo_dark_json, "logoUrl": "https://example.com/linear/logo.png", + "logoUrlDark": "https://example.com/linear/logo-dark.png", "screenshots": [], "screenshotUrls": ["https://example.com/linear/screenshot.png"], }), diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 3913ded3a9..76123b6aa2 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -77,7 +77,9 @@ fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginI composer_icon: interface.composer_icon, composer_icon_url: None, logo: interface.logo, + logo_dark: interface.logo_dark, logo_url: None, + logo_url_dark: None, screenshots: interface.screenshots, screenshot_urls: Vec::new(), } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index bcfd85f191..4d143109fa 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -753,7 +753,9 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), }), diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 5009beb640..9d7b7902b6 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -162,7 +162,8 @@ apps = true "short_description": "Example plugin", "capabilities": [], "default_prompt": "Use the legacy example prompt", - "default_prompts": [] + "default_prompts": [], + "logo_url_dark": "https://example.com/example-plugin-dark.png" }, "skills": [], "mcp_servers": [ @@ -273,6 +274,15 @@ apps = true .and_then(|interface| interface.default_prompt.clone()), Some(vec!["Use the legacy example prompt".to_string()]) ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.logo_url_dark.as_deref()), + Some("https://example.com/example-plugin-dark.png") + ); assert_eq!( response.plugin.mcp_servers, vec!["other-server".to_string()] @@ -1330,6 +1340,7 @@ async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> "brandColor": "#3B82F6", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", "screenshots": ["./assets/screenshot1.png"] } }"##, @@ -1508,6 +1519,18 @@ enabled = false "Find my next action".to_string() ]) ); + assert_eq!( + response + .plugin + .summary + .interface + .as_ref() + .and_then(|interface| interface.logo_dark.as_ref()), + Some( + &AbsolutePathBuf::try_from(plugin_root.join("assets/logo-dark.png")) + .expect("absolute dark logo path") + ) + ); assert_eq!( response.plugin.summary.keywords, vec!["api-key".to_string(), "developer tools".to_string()] diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 99fc1289d4..ba142feb90 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -1357,7 +1357,9 @@ fn expected_plugin_interface() -> PluginInterface { composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), } diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 50a5fe3e73..adf721fc6e 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -1032,7 +1032,9 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), }); diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 37630a8436..2352fdbefb 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -74,6 +74,8 @@ struct RawPluginManifestInterface { #[serde(default)] logo: Option, #[serde(default)] + logo_dark: Option, + #[serde(default)] screenshots: Vec, } @@ -175,6 +177,7 @@ pub(crate) fn parse_plugin_manifest( brand_color, composer_icon, logo, + logo_dark, screenshots, } = interface; @@ -196,6 +199,11 @@ pub(crate) fn parse_plugin_manifest( composer_icon.as_deref(), ), logo: resolve_interface_asset_path(plugin_root, "interface.logo", logo.as_deref()), + logo_dark: resolve_interface_asset_path( + plugin_root, + "interface.logoDark", + logo_dark.as_deref(), + ), screenshots: screenshots .iter() .filter_map(|screenshot| { @@ -221,6 +229,7 @@ pub(crate) fn parse_plugin_manifest( || interface.brand_color.is_some() || interface.composer_icon.is_some() || interface.logo.is_some() + || interface.logo_dark.is_some() || !interface.screenshots.is_empty(); has_fields.then_some(interface) @@ -594,6 +603,32 @@ mod tests { assert_eq!(interface.default_prompt, None); } + #[test] + fn plugin_interface_reads_dark_logo_path() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("demo-plugin"); + write_manifest( + &plugin_root, + /*version*/ None, + r#"{ + "logoDark": "./assets/logo-dark.svg" + }"#, + ); + + let manifest = load_manifest(&plugin_root); + let interface = manifest.interface.expect("plugin interface"); + + assert_eq!( + interface.logo_dark, + Some( + AbsolutePathBuf::from_absolute_path_checked( + plugin_root.join("assets/logo-dark.svg"), + ) + .expect("absolute dark logo path") + ) + ); + } + #[test] fn plugin_manifest_reads_trimmed_version() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/marketplace.rs b/codex-rs/core-plugins/src/marketplace.rs index 456bd9004c..f5024ab25c 100644 --- a/codex-rs/core-plugins/src/marketplace.rs +++ b/codex-rs/core-plugins/src/marketplace.rs @@ -103,6 +103,7 @@ impl MarketplacePluginManifestFallback { if let Some(interface) = manifest.interface.as_mut() { interface.composer_icon = None; interface.logo = None; + interface.logo_dark = None; interface.screenshots.clear(); } Some(manifest) diff --git a/codex-rs/core-plugins/src/marketplace_tests.rs b/codex-rs/core-plugins/src/marketplace_tests.rs index 45aa2364e2..3ea2c9d606 100644 --- a/codex-rs/core-plugins/src/marketplace_tests.rs +++ b/codex-rs/core-plugins/src/marketplace_tests.rs @@ -180,6 +180,51 @@ fn find_marketplace_plugin_supports_git_subdir_sources() { ); } +#[test] +fn find_marketplace_plugin_omits_interface_asset_paths_for_git_sources() { + let tmp = tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap(); + fs::write( + repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "codex-curated", + "plugins": [ + { + "name": "remote-plugin", + "source": { + "source": "git-subdir", + "url": "openai/joey_marketplace3", + "path": "plugins/toolkit" + }, + "interface": { + "displayName": "Remote Plugin", + "composerIcon": "./assets/icon.svg", + "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", + "screenshots": ["./assets/shot.png"] + } + } + ] +}"#, + ) + .unwrap(); + + let resolved = find_marketplace_plugin( + &AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(), + "remote-plugin", + ) + .unwrap(); + + let interface = resolved.interface.expect("fallback interface"); + assert_eq!(interface.display_name.as_deref(), Some("Remote Plugin")); + assert_eq!(interface.composer_icon, None); + assert_eq!(interface.logo, None); + assert_eq!(interface.logo_dark, None); + assert!(interface.screenshots.is_empty()); +} + #[test] fn find_marketplace_plugin_builds_manifest_fallback_from_entry() { let tmp = tempdir().unwrap(); @@ -319,6 +364,7 @@ fn find_marketplace_plugin_builds_manifest_fallback_from_entry() { AbsolutePathBuf::try_from(plugin_root.join("assets/icon.svg")).unwrap() ), logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + logo_dark: None, screenshots: vec![ AbsolutePathBuf::try_from(plugin_root.join("assets/shot.png")).unwrap() ], @@ -634,6 +680,7 @@ fn list_marketplaces_supports_alternate_manifest_layout() { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), }), keywords: Vec::new(), @@ -720,6 +767,7 @@ fn list_marketplaces_supports_repo_root_local_plugin_sources() { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), }), keywords: Vec::new(), @@ -1625,6 +1673,7 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(), ), logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()), + logo_dark: None, screenshots: vec![ AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(), ], @@ -1739,6 +1788,7 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), }) ); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index a0007aa1df..05ce110953 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -463,6 +463,7 @@ struct RemotePluginReleaseInterfaceResponse { default_prompts: Option>, composer_icon_url: Option, logo_url: Option, + logo_url_dark: Option, #[serde(default)] screenshot_urls: Vec, } @@ -1529,7 +1530,9 @@ fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option composer_icon: None, composer_icon_url: interface.composer_icon_url.clone(), logo: None, + logo_dark: None, logo_url: interface.logo_url.clone(), + logo_url_dark: interface.logo_url_dark.clone(), screenshots: Vec::new(), screenshot_urls: interface.screenshot_urls.clone(), }; @@ -1546,6 +1549,7 @@ fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option || result.brand_color.is_some() || result.composer_icon_url.is_some() || result.logo_url.is_some() + || result.logo_url_dark.is_some() || !result.screenshot_urls.is_empty(); has_fields.then_some(result) } diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index e33d3c5d51..ed48341237 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -156,7 +156,9 @@ fn expected_plugin_interface() -> PluginInterface { composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), } diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index 793a8a55bd..f1d4478d7f 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -69,6 +69,7 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { default_prompts: None, composer_icon_url: None, logo_url: None, + logo_url_dark: None, screenshot_urls: Vec::new(), }, skills: Vec::new(), @@ -76,6 +77,20 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { }, } } + +#[test] +fn remote_plugin_interface_maps_dark_logo_url() { + let mut plugin = directory_plugin("plugin-linear", "linear"); + plugin.release.interface.logo_url_dark = + Some("https://example.com/linear/logo-dark.png".to_string()); + + assert_eq!( + remote_plugin_interface_to_info(&plugin) + .expect("plugin interface") + .logo_url_dark, + Some("https://example.com/linear/logo-dark.png".to_string()) + ); +} fn item(name: &str, display_name: &str) -> RecommendedPluginItem { RecommendedPluginItem { id: format!("plugin_{name}"), diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 599f4ee407..eb8e1f7d93 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -53,6 +53,7 @@ pub struct PluginManifestInterface { pub brand_color: Option, pub composer_icon: Option, pub logo: Option, + pub logo_dark: Option, pub screenshots: Vec, } @@ -72,6 +73,7 @@ impl Default for PluginManifestInterface { brand_color: None, composer_icon: None, logo: None, + logo_dark: None, screenshots: Vec::new(), } } @@ -141,6 +143,7 @@ impl PluginManifest { brand_color, composer_icon, logo, + logo_dark, screenshots, } = interface; Some(PluginManifestInterface { @@ -157,6 +160,7 @@ impl PluginManifest { brand_color, composer_icon: composer_icon.map(&mut map).transpose()?, logo: logo.map(&mut map).transpose()?, + logo_dark: logo_dark.map(&mut map).transpose()?, screenshots: screenshots .into_iter() .map(&mut map) diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index ec5476ae14..40d3ae6b54 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -36,6 +36,7 @@ "brandColor": "#3B82F6", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png", + "logoDark": "./assets/logo-dark.png", "screenshots": [ "./assets/screenshot1.png", "./assets/screenshot2.png", @@ -105,6 +106,7 @@ Or as an object directly in `plugin.json`: - `brandColor` (`string`): Theme color for the plugin card. - `composerIcon` (`string`): Path to icon asset. - `logo` (`string`): Path to logo asset. +- `logoDark` (`string`): Optional path to the logo asset used in dark mode. - `screenshots` (`array` of `string`): List of screenshot asset paths. - Screenshot entries must be PNG filenames and stored under `./assets/`. - Keep file paths relative to plugin root. @@ -205,7 +207,7 @@ personal marketplace unless the caller explicitly requests a repo-local destinat - `version` must use strict semver. - `websiteURL`, `privacyPolicyURL`, and `termsOfServiceURL` must be absolute `https://` URLs when present. -- `composerIcon`, `logo`, and `screenshots` must point to real files inside the plugin archive when +- `composerIcon`, `logo`, `logoDark`, and `screenshots` must point to real files inside the plugin archive when present. - `apps` should appear in `plugin.json` only when `.app.json` actually exists. - `mcpServers` may point to `.mcp.json` or contain the MCP server object directly in diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py index 5c6fae8e63..88fae0fd00 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -153,6 +153,7 @@ def validate_manifest_shape( "brandColor", "composerIcon", "logo", + "logoDark", "screenshots", "defaultPrompt", "default_prompt", @@ -184,7 +185,7 @@ def validate_manifest_shape( not isinstance(brand_color, str) or HEX_COLOR_RE.fullmatch(brand_color) is None ): errors.append("plugin.json field `interface.brandColor` must use `#RRGGBB`") - for field in ("composerIcon", "logo"): + for field in ("composerIcon", "logo", "logoDark"): validate_optional_asset_path(plugin_root, plugin_root, interface, field, errors) screenshots = interface.get("screenshots", []) if not isinstance(screenshots, list): diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 9a50b37832..abedbe567f 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1304,7 +1304,9 @@ pub(super) fn plugins_test_interface( composer_icon: None, composer_icon_url: None, logo: None, + logo_dark: None, logo_url: None, + logo_url_dark: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), } From 3b32d861c5ecc1e705812bc21177d0038e3c05ee Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Mon, 22 Jun 2026 16:07:27 -0700 Subject: [PATCH 55/86] [codex] migrate environment context to model world state (#29249) ## Why Environment context is model-visible state, but it is currently assembled from transient turn values and diffed through environment-specific paths. That makes initial injection, turn-to-turn updates, and changes that happen within a turn use different baselines. This PR introduces the smallest useful model world-state slice: environments only, with one in-memory baseline and one renderer for full state and diffs. ## What changed - Add a typed `WorldState` container whose sections render fragments relative to an optional previous value. Full rendering uses the same diff path with no previous state. - Replace the parallel `EnvironmentContext` representation with an `EnvironmentsState` section keyed by environment ID and rendered in deterministic order. - Preserve the legacy single-environment output while supporting multiple environments, starting environments, unavailable tombstones, and changes to persisted turn-context values. - Store the latest complete `WorldState` on `ContextManager` and use it for both turn-boundary and mid-turn environment diffs. - Build initial and post-compaction context from the same world-state builder, then retain the rendered state as the next baseline. - Seed the in-memory baseline from the latest `TurnContextItem` when resuming an existing rollout; the world state itself is not serialized. - Keep non-world settings updates on their existing path and merge rendered world-state fragments at the session consumer. ## Known limitation A legacy `TurnContextItem` only reconstructs the primary environment as `local`; it cannot faithfully recover a remote-primary environment ID after resume. Live state uses the exact environment IDs once a complete baseline is established. ## Test plan - `just test -p codex-core world_state` - `just test -p codex-core record_context_updates` - `just test -p codex-core deferred_executor_` - `just test -p codex-core build_initial_context` - `just test -p codex-core rollout_reconstruction` - `just test -p codex-core process_compacted_history_reinjects_full_initial_context` --- .../src/context/contextual_user_message.rs | 4 +- .../core/src/context/environment_context.rs | 413 +----------------- codex-rs/core/src/context/mod.rs | 2 +- .../src/context/world_state/environment.rs | 387 ++++++++++++++++ .../environment_render_tests.rs} | 242 +++------- .../context/world_state/environment_tests.rs | 256 +++++++++++ codex-rs/core/src/context/world_state/mod.rs | 80 ++++ codex-rs/core/src/context_manager/history.rs | 35 +- .../core/src/context_manager/history_tests.rs | 20 +- codex-rs/core/src/context_manager/updates.rs | 56 +-- codex-rs/core/src/session/mod.rs | 156 ++++--- codex-rs/core/src/session/tests.rs | 113 ++--- codex-rs/core/src/session/world_state.rs | 41 ++ 13 files changed, 1055 insertions(+), 750 deletions(-) create mode 100644 codex-rs/core/src/context/world_state/environment.rs rename codex-rs/core/src/context/{environment_context_tests.rs => world_state/environment_render_tests.rs} (64%) create mode 100644 codex-rs/core/src/context/world_state/environment_tests.rs create mode 100644 codex-rs/core/src/context/world_state/mod.rs create mode 100644 codex-rs/core/src/session/world_state.rs diff --git a/codex-rs/core/src/context/contextual_user_message.rs b/codex-rs/core/src/context/contextual_user_message.rs index de735b6f1e..390a20fd41 100644 --- a/codex-rs/core/src/context/contextual_user_message.rs +++ b/codex-rs/core/src/context/contextual_user_message.rs @@ -3,7 +3,6 @@ use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; use super::AdditionalContextUserFragment; -use super::EnvironmentContext; use super::FragmentRegistration; use super::FragmentRegistrationProxy; use super::InternalModelContextFragment; @@ -16,10 +15,11 @@ use super::SubagentNotification; use super::TurnAborted; use super::UserInstructions; use super::UserShellCommand; +use super::world_state::EnvironmentsState; static USER_INSTRUCTIONS_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); -static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy = +static ENVIRONMENT_CONTEXT_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); static ADDITIONAL_CONTEXT_REGISTRATION: FragmentRegistrationProxy = FragmentRegistrationProxy::new(); diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 5f36124b81..2f795a8ea3 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -1,119 +1,13 @@ -use crate::session::step_context::StepContext; -use crate::session::turn_context::TurnContext; -use crate::session::turn_context::TurnEnvironment; -use crate::shell::Shell; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::permissions::FileSystemSpecialPath; -use codex_protocol::protocol::TurnContextItem; -use codex_protocol::protocol::TurnContextNetworkItem; use codex_utils_absolute_path::AbsolutePathBuf; -use codex_utils_path_uri::PathUri; use std::collections::HashSet; use std::path::PathBuf; -use super::ContextualUserFragment; - -#[derive(Debug, Clone, PartialEq)] -pub(crate) struct EnvironmentContext { - pub(crate) environments: EnvironmentContextEnvironments, - pub(crate) current_date: Option, - pub(crate) timezone: Option, - pub(crate) network: Option, - pub(crate) filesystem: Option, - pub(crate) subagents: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct EnvironmentContextEnvironment { - pub(crate) id: String, - pub(crate) cwd: PathUri, - status: EnvironmentContextEnvironmentStatus, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum EnvironmentContextEnvironmentStatus { - Starting, - Ready { shell: String }, -} - -impl EnvironmentContextEnvironment { - fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { - environments - .iter() - .map(|environment| Self { - id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), - status: EnvironmentContextEnvironmentStatus::Ready { - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), - }, - }) - .collect() - } -} - -impl EnvironmentContextEnvironmentStatus { - fn equals_except_shell(&self, other: &Self) -> bool { - matches!( - (self, other), - (Self::Starting, Self::Starting) | (Self::Ready { .. }, Self::Ready { .. }) - ) - } - - fn render(&self, indent: &str) -> String { - match self { - Self::Starting => format!("{indent}starting"), - Self::Ready { shell } => format!("{indent}{shell}"), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum EnvironmentContextEnvironments { - None, - Single(EnvironmentContextEnvironment), - Multiple(Vec), -} - -impl EnvironmentContextEnvironments { - fn from_vec(environments: Vec) -> Self { - let mut environments = environments; - match environments.pop() { - None => Self::None, - Some(environment) if environments.is_empty() => Self::Single(environment), - Some(environment) => { - environments.push(environment); - Self::Multiple(environments) - } - } - } - - fn equals_except_shell(&self, other: &Self) -> bool { - match (self, other) { - (Self::None, Self::None) => true, - (Self::Single(left), Self::Single(right)) => { - left.cwd == right.cwd && left.status.equals_except_shell(&right.status) - } - (Self::Multiple(left), Self::Multiple(right)) => { - left.len() == right.len() - && left.iter().zip(right.iter()).all(|(left, right)| { - left.id == right.id - && left.cwd == right.cwd - && left.status.equals_except_shell(&right.status) - }) - } - _ => false, - } - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FileSystemContext { workspace_roots: Vec, @@ -137,7 +31,7 @@ enum ManagedFileSystemContext { } impl FileSystemContext { - fn from_permission_profile( + pub(super) fn from_permission_profile( permission_profile: &PermissionProfile, workspace_roots: &[AbsolutePathBuf], ) -> Self { @@ -163,7 +57,7 @@ impl FileSystemContext { } } - fn render(&self) -> String { + pub(super) fn render(&self) -> String { let mut rendered = "".to_string(); if !self.workspace_roots.is_empty() { rendered.push_str(""); @@ -303,7 +197,7 @@ fn push_text_element(rendered: &mut String, name: &str, value: &str) { rendered.push_str(&format!("")); } -fn push_xml_escaped_text(rendered: &mut String, value: &str) { +pub(crate) fn push_xml_escaped_text(rendered: &mut String, value: &str) { for ch in value.chars() { match ch { '&' => rendered.push_str("&"), @@ -330,7 +224,7 @@ impl NetworkContext { } } - fn render(&self) -> String { + pub(super) fn render(&self) -> String { let mut rendered = "".to_string(); Self::push_rendered_domain_element(&mut rendered, "allowed", &self.allowed_domains); Self::push_rendered_domain_element(&mut rendered, "denied", &self.denied_domains); @@ -348,302 +242,3 @@ impl NetworkContext { rendered_network.push_str(&format!("")); } } - -impl EnvironmentContext { - pub(crate) fn new( - environments: Vec, - current_date: Option, - timezone: Option, - network: Option, - subagents: Option, - ) -> Self { - Self { - environments: EnvironmentContextEnvironments::from_vec(environments), - current_date, - timezone, - network, - filesystem: None, - subagents, - } - } - - fn new_with_environments( - environments: EnvironmentContextEnvironments, - current_date: Option, - timezone: Option, - network: Option, - filesystem: Option, - subagents: Option, - ) -> Self { - Self { - environments, - current_date, - timezone, - network, - filesystem, - subagents, - } - } - - pub(crate) fn from_step_context(step_context: &StepContext, shell: &Shell) -> Option { - let mut environments = EnvironmentContextEnvironment::from_turn_environments( - &step_context.environments.turn_environments, - shell, - ); - environments.extend( - step_context - .environments - .starting - .iter() - .map(|environment| EnvironmentContextEnvironment { - id: environment.selection.environment_id.clone(), - cwd: environment.selection.cwd.clone(), - status: EnvironmentContextEnvironmentStatus::Starting, - }), - ); - - Self::environment_only(environments) - } - - pub(crate) fn from_attached_environments( - environments: &[TurnEnvironment], - shell: &Shell, - ) -> Option { - Self::environment_only(EnvironmentContextEnvironment::from_turn_environments( - environments, - shell, - )) - } - - fn environment_only(environments: Vec) -> Option { - (!environments.is_empty()).then(|| { - Self::new( - environments, - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ) - }) - } - - /// Compares two environment contexts, ignoring the shell. Useful when - /// comparing turn to turn, since the initial environment_context will - /// include the shell, and then it is not configurable from turn to turn. - pub(crate) fn equals_except_shell(&self, other: &EnvironmentContext) -> bool { - self.environments.equals_except_shell(&other.environments) - && self.current_date == other.current_date - && self.timezone == other.timezone - && self.network == other.network - && self.filesystem == other.filesystem - && self.subagents == other.subagents - } - - pub(crate) fn diff_from_turn_context_item( - before: &TurnContextItem, - after: &EnvironmentContext, - ) -> Self { - let before_network = Self::network_from_turn_context_item(before); - let before_filesystem = Self::filesystem_from_turn_context_item(before); - let environments = match &after.environments { - EnvironmentContextEnvironments::Single(environment) => { - if PathUri::from_abs_path(&before.cwd) != environment.cwd { - EnvironmentContextEnvironments::Single(EnvironmentContextEnvironment { - id: String::new(), - cwd: environment.cwd.clone(), - status: environment.status.clone(), - }) - } else { - EnvironmentContextEnvironments::None - } - } - EnvironmentContextEnvironments::Multiple(environments) => { - EnvironmentContextEnvironments::Multiple(environments.clone()) - } - EnvironmentContextEnvironments::None => EnvironmentContextEnvironments::None, - }; - let network = if before_network != after.network { - after.network.clone() - } else { - before_network - }; - let filesystem = if before_filesystem != after.filesystem { - after.filesystem.clone() - } else { - before_filesystem - }; - EnvironmentContext::new_with_environments( - environments, - after.current_date.clone(), - after.timezone.clone(), - network, - filesystem, - /*subagents*/ None, - ) - } - - pub(crate) fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { - let mut context = Self::new( - EnvironmentContextEnvironment::from_turn_environments( - &turn_context.environments.turn_environments, - shell, - ), - turn_context.current_date.clone(), - turn_context.timezone.clone(), - Self::network_from_turn_context(turn_context), - /*subagents*/ None, - ); - context.filesystem = Some(FileSystemContext::from_permission_profile( - &turn_context.permission_profile, - &turn_context.config.effective_workspace_roots(), - )); - context - } - - pub(crate) fn from_turn_context_item( - turn_context_item: &TurnContextItem, - shell: String, - ) -> Self { - Self::new_with_environments( - EnvironmentContextEnvironments::from_vec(vec![EnvironmentContextEnvironment { - id: String::new(), - cwd: PathUri::from_abs_path(&turn_context_item.cwd), - status: EnvironmentContextEnvironmentStatus::Ready { shell }, - }]), - turn_context_item.current_date.clone(), - turn_context_item.timezone.clone(), - Self::network_from_turn_context_item(turn_context_item), - Self::filesystem_from_turn_context_item(turn_context_item), - /*subagents*/ None, - ) - } - - pub(crate) fn with_subagents(mut self, subagents: String) -> Self { - if !subagents.is_empty() { - self.subagents = Some(subagents); - } - self - } - - fn network_from_turn_context(turn_context: &TurnContext) -> Option { - let network = turn_context - .config - .config_layer_stack - .requirements() - .network - .as_ref()?; - - Some(NetworkContext::new( - network - .domains - .as_ref() - .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) - .unwrap_or_default(), - network - .domains - .as_ref() - .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) - .unwrap_or_default(), - )) - } - - fn network_from_turn_context_item( - turn_context_item: &TurnContextItem, - ) -> Option { - let TurnContextNetworkItem { - allowed_domains, - denied_domains, - } = turn_context_item.network.as_ref()?; - Some(NetworkContext::new( - allowed_domains.clone(), - denied_domains.clone(), - )) - } - - fn filesystem_from_turn_context_item( - turn_context_item: &TurnContextItem, - ) -> Option { - Some(FileSystemContext::from_permission_profile( - &turn_context_item.permission_profile(), - &workspace_roots_from_turn_context_item(turn_context_item), - )) - } -} - -fn workspace_roots_from_turn_context_item( - turn_context_item: &TurnContextItem, -) -> Vec { - if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() { - return workspace_roots.clone(); - } - - vec![turn_context_item.cwd.clone()] -} - -impl ContextualUserFragment for EnvironmentContext { - fn role(&self) -> &'static str { - "user" - } - - fn markers(&self) -> (&'static str, &'static str) { - Self::type_markers() - } - - fn type_markers() -> (&'static str, &'static str) { - ( - codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG, - codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG, - ) - } - - fn body(&self) -> String { - let mut lines = Vec::new(); - match &self.environments { - EnvironmentContextEnvironments::Single(environment) => { - let cwd = environment.cwd.inferred_native_path_string(); - lines.push(format!(" {cwd}")); - lines.push(environment.status.render(" ")); - } - EnvironmentContextEnvironments::Multiple(environments) => { - lines.push(" ".to_string()); - for environment in environments { - lines.push(format!(" ", environment.id)); - let cwd = environment.cwd.inferred_native_path_string(); - lines.push(format!(" {cwd}")); - lines.push(environment.status.render(" ")); - lines.push(" ".to_string()); - } - lines.push(" ".to_string()); - } - EnvironmentContextEnvironments::None => {} - } - if let Some(current_date) = &self.current_date { - lines.push(format!(" {current_date}")); - } - if let Some(timezone) = &self.timezone { - lines.push(format!(" {timezone}")); - } - match &self.network { - Some(network) => { - lines.push(format!(" {}", network.render())); - } - None => { - // TODO(mbolin): Include this line if it helps the model. - // lines.push(" ".to_string()); - } - } - if let Some(filesystem) = &self.filesystem { - lines.push(format!(" {}", filesystem.render())); - } - if let Some(subagents) = &self.subagents { - lines.push(" ".to_string()); - lines.extend(subagents.lines().map(|line| format!(" {line}"))); - lines.push(" ".to_string()); - } - format!("\n{}\n", lines.join("\n")) - } -} - -#[cfg(test)] -#[path = "environment_context_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index c9319ebef0..4ba6acf029 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -32,6 +32,7 @@ mod token_budget_context; mod turn_aborted; mod user_instructions; mod user_shell_command; +pub(crate) mod world_state; pub(crate) use approved_command_prefix_saved::ApprovedCommandPrefixSaved; pub(crate) use apps_instructions::AppsInstructions; @@ -47,7 +48,6 @@ pub(crate) use collaboration_mode_instructions::CollaborationModeInstructions; pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::parse_visible_hook_prompt_message; pub(crate) use current_time_reminder::CurrentTimeReminder; -pub(crate) use environment_context::EnvironmentContext; pub(crate) use guardian_followup_review_reminder::GuardianFollowupReviewReminder; pub(crate) use hook_additional_context::HookAdditionalContext; pub(crate) use image_generation_instructions::ImageGenerationInstructions; diff --git a/codex-rs/core/src/context/world_state/environment.rs b/codex-rs/core/src/context/world_state/environment.rs new file mode 100644 index 0000000000..589e8dbadf --- /dev/null +++ b/codex-rs/core/src/context/world_state/environment.rs @@ -0,0 +1,387 @@ +use super::WorldStateSection; +use crate::context::ContextualUserFragment; +use crate::context::environment_context::FileSystemContext; +use crate::context::environment_context::NetworkContext; +use crate::context::environment_context::push_xml_escaped_text; +use crate::environment_selection::TurnEnvironmentSnapshot; +use crate::session::turn_context::TurnContext; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_protocol::protocol::TurnContextItem; +use codex_protocol::protocol::TurnContextNetworkItem; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::PathUri; +use std::collections::BTreeMap; + +/// Environment values visible to the model. +#[derive(Clone, Debug, Default)] +pub(crate) struct EnvironmentsState { + environments: BTreeMap, + current_date: Option, + timezone: Option, + network: Option, + filesystem: Option, + subagents: Option, +} + +impl EnvironmentsState { + pub(crate) fn from_turn_context(turn_context: &TurnContext) -> Self { + Self::from_turn_context_with_environments(turn_context, &turn_context.environments) + } + + pub(crate) fn from_turn_context_with_environments( + turn_context: &TurnContext, + environments: &TurnEnvironmentSnapshot, + ) -> Self { + Self { + environments: environment_states(environments), + current_date: turn_context.current_date.clone(), + timezone: turn_context.timezone.clone(), + network: network_from_turn_context(turn_context), + filesystem: Some(FileSystemContext::from_permission_profile( + &turn_context.permission_profile, + &turn_context.config.effective_workspace_roots(), + )), + subagents: None, + } + } + + pub(crate) fn from_turn_context_item(turn_context_item: &TurnContextItem) -> Self { + Self { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + EnvironmentState { + cwd: PathUri::from_abs_path(&turn_context_item.cwd), + status: EnvironmentStatus::Available, + shell: None, + }, + )] + .into_iter() + .collect(), + current_date: turn_context_item.current_date.clone(), + timezone: turn_context_item.timezone.clone(), + network: network_from_turn_context_item(turn_context_item), + filesystem: Some(FileSystemContext::from_permission_profile( + &turn_context_item.permission_profile(), + &workspace_roots_from_turn_context_item(turn_context_item), + )), + subagents: None, + } + } + + pub(crate) fn with_subagents(mut self, subagents: String) -> Self { + if !subagents.is_empty() { + self.subagents = Some(subagents); + } + self + } + + fn rendered_full(&self) -> RenderedEnvironments { + RenderedEnvironments { + updates: self + .environments + .iter() + .map(|(id, environment)| { + (id.clone(), EnvironmentUpdate::Current(environment.clone())) + }) + .collect(), + legacy_single: is_legacy_single(&self.environments), + current_date: self.current_date.clone(), + timezone: self.timezone.clone(), + network: self.network.clone(), + filesystem: self.filesystem.clone(), + subagents: self.subagents.clone(), + } + } +} + +impl WorldStateSection for EnvironmentsState { + fn render_diff(&self, previous: Option<&Self>) -> Option> { + let empty = Self::default(); + let previous = previous.unwrap_or(&empty); + let turn_context_values_changed = self.current_date != previous.current_date + || self.timezone != previous.timezone + || self.network != previous.network + || self.filesystem != previous.filesystem; + let mut updates = self + .environments + .iter() + .filter(|(id, environment)| { + previous + .environments + .get(*id) + .is_none_or(|previous| !environment.has_same_diff_value(previous)) + }) + .map(|(id, environment)| (id.clone(), EnvironmentUpdate::Current(environment.clone()))) + .collect::>(); + updates.extend( + previous + .environments + .keys() + .filter(|id| !self.environments.contains_key(*id)) + .map(|id| (id.clone(), EnvironmentUpdate::Unavailable)), + ); + let legacy_single = is_legacy_single(&self.environments) + && updates + .values() + .all(|update| matches!(update, EnvironmentUpdate::Current(_))); + (!updates.is_empty() || turn_context_values_changed).then(|| { + Box::new(RenderedEnvironments { + updates, + legacy_single, + current_date: self.current_date.clone(), + timezone: self.timezone.clone(), + network: self.network.clone(), + filesystem: self.filesystem.clone(), + subagents: self.subagents.clone(), + }) as Box + }) + } +} + +impl ContextualUserFragment for EnvironmentsState { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + environment_context_markers() + } + + fn body(&self) -> String { + self.rendered_full().body() + } +} + +struct RenderedEnvironments { + updates: BTreeMap, + legacy_single: bool, + current_date: Option, + timezone: Option, + network: Option, + filesystem: Option, + subagents: Option, +} + +enum EnvironmentUpdate { + Current(EnvironmentState), + Unavailable, +} + +impl ContextualUserFragment for RenderedEnvironments { + fn role(&self) -> &'static str { + "user" + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + environment_context_markers() + } + + fn body(&self) -> String { + let mut rendered = "\n".to_string(); + if self.legacy_single { + if let Some(EnvironmentUpdate::Current(environment)) = self.updates.values().next() { + push_environment_values(&mut rendered, environment, " "); + } + } else if !self.updates.is_empty() { + rendered.push_str(" \n"); + for (id, update) in &self.updates { + match update { + EnvironmentUpdate::Current(environment) => { + rendered.push_str(" \n"); + push_environment_values(&mut rendered, environment, " "); + rendered.push_str(" \n"); + } + EnvironmentUpdate::Unavailable => { + rendered.push_str(" \n"); + } + } + } + rendered.push_str(" \n"); + } + push_optional_element(&mut rendered, "current_date", self.current_date.as_deref()); + push_optional_element(&mut rendered, "timezone", self.timezone.as_deref()); + if let Some(network) = &self.network { + rendered.push_str(" "); + rendered.push_str(&network.render()); + rendered.push('\n'); + } + if let Some(filesystem) = &self.filesystem { + rendered.push_str(" "); + rendered.push_str(&filesystem.render()); + rendered.push('\n'); + } + if let Some(subagents) = &self.subagents { + rendered.push_str(" \n"); + for line in subagents.lines() { + rendered.push_str(" "); + rendered.push_str(line); + rendered.push('\n'); + } + rendered.push_str(" \n"); + } + rendered + } +} + +fn push_environment_values(rendered: &mut String, environment: &EnvironmentState, indent: &str) { + rendered.push_str(indent); + rendered.push_str(""); + push_xml_escaped_text(rendered, &environment.cwd.inferred_native_path_string()); + rendered.push_str("\n"); + if environment.status == EnvironmentStatus::Starting { + rendered.push_str(indent); + rendered.push_str("starting\n"); + } + if let Some(shell) = &environment.shell { + rendered.push_str(indent); + rendered.push_str(""); + push_xml_escaped_text(rendered, shell); + rendered.push_str("\n"); + } +} + +fn push_optional_element(rendered: &mut String, name: &str, value: Option<&str>) { + let Some(value) = value else { + return; + }; + rendered.push_str(" <"); + rendered.push_str(name); + rendered.push('>'); + push_xml_escaped_text(rendered, value); + rendered.push_str("\n"); +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct EnvironmentState { + cwd: PathUri, + status: EnvironmentStatus, + shell: Option, +} + +impl EnvironmentState { + fn has_same_diff_value(&self, other: &Self) -> bool { + self.cwd == other.cwd + && self.status == other.status + && self + .shell + .as_ref() + .zip(other.shell.as_ref()) + .is_none_or(|(current, previous)| current == previous) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EnvironmentStatus { + Starting, + Available, +} + +fn environment_states(snapshot: &TurnEnvironmentSnapshot) -> BTreeMap { + let mut environments = snapshot + .turn_environments + .iter() + .map(|environment| { + ( + environment.environment_id.clone(), + EnvironmentState { + cwd: environment.cwd().clone(), + status: EnvironmentStatus::Available, + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()), + }, + ) + }) + .collect::>(); + for environment in &snapshot.starting { + environments + .entry(environment.selection.environment_id.clone()) + .or_insert_with(|| EnvironmentState { + cwd: environment.selection.cwd.clone(), + status: EnvironmentStatus::Starting, + shell: None, + }); + } + environments +} + +fn is_legacy_single(environments: &BTreeMap) -> bool { + environments.len() == 1 + && environments + .values() + .all(|environment| environment.status == EnvironmentStatus::Available) +} + +fn environment_context_markers() -> (&'static str, &'static str) { + ( + codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG, + codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG, + ) +} + +fn network_from_turn_context(turn_context: &TurnContext) -> Option { + let network = turn_context + .config + .config_layer_stack + .requirements() + .network + .as_ref()?; + + Some(NetworkContext::new( + network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::allowed_domains) + .unwrap_or_default(), + network + .domains + .as_ref() + .and_then(codex_config::NetworkDomainPermissionsToml::denied_domains) + .unwrap_or_default(), + )) +} + +fn network_from_turn_context_item(turn_context_item: &TurnContextItem) -> Option { + let TurnContextNetworkItem { + allowed_domains, + denied_domains, + } = turn_context_item.network.as_ref()?; + Some(NetworkContext::new( + allowed_domains.clone(), + denied_domains.clone(), + )) +} + +fn workspace_roots_from_turn_context_item( + turn_context_item: &TurnContextItem, +) -> Vec { + if let Some(workspace_roots) = turn_context_item.workspace_roots.as_ref() { + return workspace_roots.clone(); + } + + vec![turn_context_item.cwd.clone()] +} + +#[cfg(test)] +#[path = "environment_tests.rs"] +mod tests; + +#[cfg(test)] +#[path = "environment_render_tests.rs"] +mod render_tests; diff --git a/codex-rs/core/src/context/environment_context_tests.rs b/codex-rs/core/src/context/world_state/environment_render_tests.rs similarity index 64% rename from codex-rs/core/src/context/environment_context_tests.rs rename to codex-rs/core/src/context/world_state/environment_render_tests.rs index 4d9f1a7291..be127de554 100644 --- a/codex-rs/core/src/context/environment_context_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_render_tests.rs @@ -1,6 +1,5 @@ use crate::shell::ShellType; -use super::EnvironmentContextEnvironmentStatus::Ready; use super::*; use codex_protocol::models::PermissionProfile; use codex_protocol::permissions::FileSystemAccessMode; @@ -31,17 +30,43 @@ fn test_abs_path(unix_path: &str) -> AbsolutePathBuf { test_path_buf(unix_path).abs() } +fn environment(id: &str, cwd: PathUri, shell: impl Into) -> (String, EnvironmentState) { + ( + id.to_string(), + EnvironmentState { + cwd, + status: EnvironmentStatus::Available, + shell: Some(shell.into()), + }, + ) +} + +fn environment_state( + environments: impl IntoIterator, + current_date: Option, + timezone: Option, + network: Option, + subagents: Option, +) -> EnvironmentsState { + EnvironmentsState { + environments: environments.into_iter().collect(), + current_date, + timezone, + network, + filesystem: None, + subagents, + } +} + #[test] fn serialize_workspace_write_environment_context() { let cwd = test_path_buf("/repo"); - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&cwd.abs()), - status: Ready { - shell: fake_shell_name(), - }, - }], + let context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&cwd.abs()), + fake_shell_name(), + )], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), /*network*/ None, @@ -63,14 +88,12 @@ fn serialize_workspace_write_environment_context() { #[test] fn serialize_environment_context_with_foreign_windows_cwd() { - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "remote".to_string(), - cwd: PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), - status: Ready { - shell: "powershell".to_string(), - }, - }], + let context = environment_state( + [environment( + "remote", + PathUri::parse("file:///C:/windows").expect("Windows cwd URI"), + "powershell", + )], /*current_date*/ None, /*timezone*/ None, /*network*/ None, @@ -92,14 +115,12 @@ fn serialize_environment_context_with_network() { vec!["api.example.com".to_string(), "*.openai.com".to_string()], vec!["blocked.example.com".to_string()], ); - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - status: Ready { - shell: fake_shell_name(), - }, - }], + let context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&test_abs_path("/repo")), + fake_shell_name(), + )], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), Some(network), @@ -156,14 +177,12 @@ fn serialize_environment_context_with_full_filesystem_profile() { AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), repo.as_path()); let other_repo_private_glob = AbsolutePathBuf::resolve_path_against_base(Path::new("private/**"), other_repo.as_path()); - let mut context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - status: Ready { - shell: fake_shell_name(), - }, - }], + let mut context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&test_abs_path("/repo")), + fake_shell_name(), + )], /*current_date*/ None, /*timezone*/ None, /*network*/ None, @@ -219,7 +238,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { summary: codex_protocol::config_types::ReasoningSummary::Auto, }; - let context = EnvironmentContext::from_turn_context_item(&item, fake_shell_name()).render(); + let context = EnvironmentsState::from_turn_context_item(&item).render(); assert!( context.contains(&format!( @@ -246,7 +265,7 @@ fn turn_context_item_filesystem_uses_workspace_roots_instead_of_cwd() { #[test] fn serialize_read_only_environment_context() { - let context = EnvironmentContext::new( + let context = environment_state( Vec::new(), Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -262,111 +281,14 @@ fn serialize_read_only_environment_context() { assert_eq!(context.render(), expected); } -#[test] -fn equals_except_shell_compares_cwd() { - let context1 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - status: Ready { - shell: fake_shell_name(), - }, - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - let context2 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - status: Ready { - shell: fake_shell_name(), - }, - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - assert!(context1.equals_except_shell(&context2)); -} - -#[test] -fn equals_except_shell_compares_cwd_differences() { - let context1 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo1")), - status: Ready { - shell: fake_shell_name(), - }, - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - let context2 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo2")), - status: Ready { - shell: fake_shell_name(), - }, - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - - assert!(!context1.equals_except_shell(&context2)); -} - -#[test] -fn equals_except_shell_ignores_shell() { - let context1 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - status: Ready { - shell: "bash".to_string(), - }, - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - let context2 = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "other".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - status: Ready { - shell: "zsh".to_string(), - }, - }], - /*current_date*/ None, - /*timezone*/ None, - /*network*/ None, - /*subagents*/ None, - ); - - assert!(context1.equals_except_shell(&context2)); -} - #[test] fn serialize_environment_context_with_subagents() { - let context = EnvironmentContext::new( - vec![EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&test_abs_path("/repo")), - status: Ready { - shell: fake_shell_name(), - }, - }], + let context = environment_state( + [environment( + "local", + PathUri::from_abs_path(&test_abs_path("/repo")), + fake_shell_name(), + )], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), /*network*/ None, @@ -394,22 +316,10 @@ fn serialize_environment_context_with_subagents() { fn serialize_environment_context_with_multiple_selected_environments() { let local_cwd = test_path_buf("/repo/local"); let remote_cwd = test_path_buf("/repo/remote"); - let context = EnvironmentContext::new( - vec![ - EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&local_cwd.abs()), - status: Ready { - shell: "bash".to_string(), - }, - }, - EnvironmentContextEnvironment { - id: "remote".to_string(), - cwd: PathUri::from_abs_path(&remote_cwd.abs()), - status: Ready { - shell: "bash".to_string(), - }, - }, + let context = environment_state( + [ + environment("local", PathUri::from_abs_path(&local_cwd.abs()), "bash"), + environment("remote", PathUri::from_abs_path(&remote_cwd.abs()), "bash"), ], Some("2026-02-26".to_string()), Some("America/Los_Angeles".to_string()), @@ -443,22 +353,14 @@ fn serialize_environment_context_with_multiple_selected_environments() { fn serialize_environment_context_prefers_environment_shell_when_present() { let local_cwd = test_path_buf("/repo/local"); let remote_cwd = test_path_buf("/repo/remote"); - let context = EnvironmentContext::new( - vec![ - EnvironmentContextEnvironment { - id: "local".to_string(), - cwd: PathUri::from_abs_path(&local_cwd.abs()), - status: Ready { - shell: "powershell".to_string(), - }, - }, - EnvironmentContextEnvironment { - id: "remote".to_string(), - cwd: PathUri::from_abs_path(&remote_cwd.abs()), - status: Ready { - shell: "cmd".to_string(), - }, - }, + let context = environment_state( + [ + environment( + "local", + PathUri::from_abs_path(&local_cwd.abs()), + "powershell", + ), + environment("remote", PathUri::from_abs_path(&remote_cwd.abs()), "cmd"), ], /*current_date*/ None, /*timezone*/ None, diff --git a/codex-rs/core/src/context/world_state/environment_tests.rs b/codex-rs/core/src/context/world_state/environment_tests.rs new file mode 100644 index 0000000000..77a3adde17 --- /dev/null +++ b/codex-rs/core/src/context/world_state/environment_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use crate::context::ContextualUserFragment; +use crate::context::world_state::WorldState; +use anyhow::Result; +use codex_exec_server::LOCAL_ENVIRONMENT_ID; +use codex_protocol::models::ContentItem; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::ResponseItem; +use codex_protocol::permissions::NetworkSandboxPolicy; +use pretty_assertions::assert_eq; + +#[test] +fn renders_full_environment_state() -> Result<()> { + let context = EnvironmentsState { + environments: [ + ("laptop".to_string(), available("file:///repo", "zsh")?), + ( + "devbox".to_string(), + available("file:///workspace", "bash")?, + ), + ] + .into_iter() + .collect(), + ..Default::default() + }; + + let mut world_state = WorldState::default(); + world_state.add_section(context); + + assert_eq!( + vec![user_message( + r#" + + + /workspace + bash + + + /repo + zsh + + +"#, + )], + render_fragments(world_state.render_full()), + ); + Ok(()) +} + +#[test] +fn renders_only_changed_environments() -> Result<()> { + let mut previous = WorldState::default(); + previous.add_section(EnvironmentsState { + environments: [ + ("laptop".to_string(), available("file:///repo", "bash")?), + ("devbox".to_string(), starting("file:///workspace")?), + ("old".to_string(), available("file:///old", "sh")?), + ] + .into_iter() + .collect(), + ..Default::default() + }); + let mut current = WorldState::default(); + current.add_section(EnvironmentsState { + environments: [ + ("laptop".to_string(), available("file:///repo", "zsh")?), + ( + "devbox".to_string(), + available("file:///workspace", "powershell")?, + ), + ("remote".to_string(), starting("file:///remote")?), + ] + .into_iter() + .collect(), + ..Default::default() + }); + + assert_eq!( + vec![user_message( + r#" + + + /workspace + powershell + + + /repo + zsh + + + + /remote + starting + + +"#, + )], + render_fragments(current.render_diff(&previous)), + ); + Ok(()) +} + +#[test] +fn persisted_turn_context_values_render_a_diff() -> Result<()> { + let environments = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + available("file:///repo", "zsh")?, + )] + .into_iter() + .collect(), + ..Default::default() + }; + let mut previous = WorldState::default(); + previous.add_section(EnvironmentsState { + current_date: Some("2026-06-19".to_string()), + timezone: Some("UTC".to_string()), + network: Some(NetworkContext::new( + vec!["old.example.com".to_string()], + vec![], + )), + filesystem: Some(FileSystemContext::from_permission_profile( + &PermissionProfile::Disabled, + &[], + )), + ..environments.clone() + }); + let mut current = WorldState::default(); + current.add_section(EnvironmentsState { + current_date: Some("2026-06-20".to_string()), + timezone: Some("America/Los_Angeles".to_string()), + network: Some(NetworkContext::new( + vec!["new.example.com".to_string()], + vec!["blocked.example.com".to_string()], + )), + filesystem: Some(FileSystemContext::from_permission_profile( + &PermissionProfile::External { + network: NetworkSandboxPolicy::Restricted, + }, + &[], + )), + ..environments + }); + + assert_eq!( + vec![user_message( + r#" + 2026-06-20 + America/Los_Angeles + new.example.comblocked.example.com + +"#, + )], + render_fragments(current.render_diff(&previous)), + ); + Ok(()) +} + +#[test] +fn single_environment_diff_ignores_unknown_shell() -> Result<()> { + let previous = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + EnvironmentState { + cwd: PathUri::parse("file:///repo")?, + status: EnvironmentStatus::Available, + shell: None, + }, + )] + .into_iter() + .collect(), + ..Default::default() + }; + let current = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + available("file:///repo", "zsh")?, + )] + .into_iter() + .collect(), + ..Default::default() + }; + + assert_eq!( + None, + render_fragment(WorldStateSection::render_diff(¤t, Some(&previous))) + ); + Ok(()) +} + +#[test] +fn removed_legacy_environment_renders_unavailable() -> Result<()> { + let previous = EnvironmentsState { + environments: [( + LOCAL_ENVIRONMENT_ID.to_string(), + available("file:///repo", "bash")?, + )] + .into_iter() + .collect(), + ..Default::default() + }; + + assert_eq!( + Some(user_message( + r#" + + + +"#, + )), + render_fragment(WorldStateSection::render_diff( + &EnvironmentsState::default(), + Some(&previous), + )), + ); + Ok(()) +} + +fn available(cwd: &str, shell: &str) -> Result { + Ok(EnvironmentState { + cwd: PathUri::parse(cwd)?, + status: EnvironmentStatus::Available, + shell: Some(shell.to_string()), + }) +} + +fn starting(cwd: &str) -> Result { + Ok(EnvironmentState { + cwd: PathUri::parse(cwd)?, + status: EnvironmentStatus::Starting, + shell: None, + }) +} + +fn render_fragments(fragments: Vec>) -> Vec { + fragments + .into_iter() + .map(ContextualUserFragment::into_boxed_response_item) + .collect() +} + +fn render_fragment(fragment: Option>) -> Option { + fragment.map(ContextualUserFragment::into_boxed_response_item) +} + +fn user_message(text: &str) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { + text: text.to_string(), + }], + phase: None, + metadata: None, + } +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs new file mode 100644 index 0000000000..06f750ef86 --- /dev/null +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -0,0 +1,80 @@ +mod environment; + +use crate::context::ContextualUserFragment; +use indexmap::IndexMap; +use std::any::Any; +use std::any::TypeId; +use std::fmt; + +pub(crate) use environment::EnvironmentsState; + +trait ErasedWorldStateSection: Send + Sync { + fn as_any(&self) -> &dyn Any; + + fn render_diff(&self, previous: Option<&dyn Any>) -> Option>; +} + +impl ErasedWorldStateSection for S { + fn as_any(&self) -> &dyn Any { + self + } + + fn render_diff(&self, previous: Option<&dyn Any>) -> Option> { + let previous = match previous { + Some(previous) => { + let Some(previous) = previous.downcast_ref::() else { + unreachable!("world-state section type must match its type ID"); + }; + Some(previous) + } + None => None, + }; + WorldStateSection::render_diff(self, previous) + } +} + +/// A typed portion of the state visible to the model. +/// +/// Implementations own how their current state is rendered relative to an +/// earlier value of the same section type. A missing previous value requests +/// the section's complete current representation. +pub(crate) trait WorldStateSection: Any + Send + Sync { + fn render_diff(&self, previous: Option<&Self>) -> Option>; +} + +/// A snapshot of the model-visible world with one section per concrete type. +#[derive(Default)] +pub(crate) struct WorldState { + sections: IndexMap>, +} + +impl fmt::Debug for WorldState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("WorldState") + .field("section_count", &self.sections.len()) + .finish() + } +} + +impl WorldState { + pub(crate) fn add_section(&mut self, section: S) { + self.sections.insert(TypeId::of::(), Box::new(section)); + } + + pub(crate) fn render_full(&self) -> Vec> { + self.render_diff(&Self::default()) + } + + pub(crate) fn render_diff(&self, previous: &Self) -> Vec> { + self.sections + .iter() + .filter_map(|(type_id, section)| { + let previous = previous + .sections + .get(type_id) + .map(|section| section.as_any()); + section.render_diff(previous) + }) + .collect() + } +} diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index a639aa51ba..0244f7f96f 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -1,4 +1,5 @@ -use crate::context::EnvironmentContext; +use crate::context::ContextualUserFragment; +use crate::context::world_state::WorldState; use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; @@ -28,6 +29,7 @@ use codex_utils_output_truncation::truncate_function_output_items_with_policy; use codex_utils_output_truncation::truncate_text; use std::num::NonZeroUsize; use std::ops::Deref; +use std::sync::Arc; use std::sync::LazyLock; /// Transcript of thread history @@ -49,8 +51,8 @@ pub(crate) struct ContextManager { /// also clear this when it trims a mixed initial-context developer bundle /// whose non-diff fragments no longer exist in the surviving history. reference_context_item: Option, - /// Environment state most recently appended to model-visible history. - environment_context_baseline: Option, + /// World state most recently appended to model-visible history. + world_state_baseline: Option>, } impl ContextManager { @@ -62,7 +64,7 @@ impl ContextManager { &None, &None, /*model_context_window*/ None, ), reference_context_item: None, - environment_context_baseline: None, + world_state_baseline: None, } } @@ -82,15 +84,20 @@ impl ContextManager { self.reference_context_item.clone() } - pub(crate) fn update_environment_context_baseline( + pub(crate) fn update_world_state( &mut self, - context: &EnvironmentContext, - ) -> bool { - if self.environment_context_baseline.as_ref() == Some(context) { - return false; - } - self.environment_context_baseline = Some(context.clone()); - true + world_state: WorldState, + ) -> Vec> { + let fragments = self.world_state_baseline.as_deref().map_or_else( + || world_state.render_full(), + |previous| world_state.render_diff(previous), + ); + self.world_state_baseline = Some(Arc::new(world_state)); + fragments + } + + pub(crate) fn set_world_state_baseline(&mut self, world_state: WorldState) { + self.world_state_baseline = Some(Arc::new(world_state)); } pub(crate) fn set_token_usage_full(&mut self, context_window: i64) { @@ -178,14 +185,14 @@ impl ContextManager { // its corresponding counterpart to keep the invariants intact without // running a full normalization pass. normalize::remove_corresponding_for(&mut self.items, &removed); - self.environment_context_baseline = None; + self.world_state_baseline = None; } } pub(crate) fn replace(&mut self, items: Vec) { self.items = items; self.history_version = self.history_version.saturating_add(1); - self.environment_context_baseline = None; + self.world_state_baseline = None; } /// Replace image content in the last turn if it originated from a tool output. diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index b388a1a060..da2f14fd4f 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,5 +1,6 @@ use super::*; -use crate::context::EnvironmentContext; +use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::WorldState; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::AgentPath; @@ -75,17 +76,22 @@ fn create_history_with_items(items: Vec) -> ContextManager { } #[test] -fn environment_context_baseline_deduplicates_until_history_is_replaced() { - let context = - EnvironmentContext::from_turn_context_item(&reference_context_item(), "bash".to_string()); +fn world_state_baseline_deduplicates_until_history_is_replaced() { + let world_state = || { + let mut state = WorldState::default(); + state.add_section(EnvironmentsState::from_turn_context_item( + &reference_context_item(), + )); + state + }; let mut history = ContextManager::new(); - assert!(history.update_environment_context_baseline(&context)); - assert!(!history.update_environment_context_baseline(&context)); + assert_eq!(1, history.update_world_state(world_state()).len()); + assert!(history.update_world_state(world_state()).is_empty()); history.replace(Vec::new()); - assert!(history.update_environment_context_baseline(&context)); + assert_eq!(1, history.update_world_state(world_state()).len()); } fn user_msg(text: &str) -> ResponseItem { diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index b83c1b985c..937c4bf059 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,6 +1,5 @@ use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; -use crate::context::EnvironmentContext; use crate::context::ModelSwitchInstructions; use crate::context::MultiAgentModeInstructions; use crate::context::PermissionsInstructions; @@ -10,7 +9,6 @@ use crate::context::RealtimeStartInstructions; use crate::context::RealtimeStartWithInstructions; use crate::session::PreviousTurnSettings; use crate::session::turn_context::TurnContext; -use crate::shell::Shell; use codex_execpolicy::Policy; use codex_features::Feature; use codex_protocol::config_types::MultiAgentMode; @@ -20,27 +18,6 @@ use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::TurnContextItem; -fn build_environment_update_item( - previous: Option<&TurnContextItem>, - next: &TurnContext, - shell: &Shell, -) -> Option { - if !next.config.include_environment_context { - return None; - } - - let prev = previous?; - let prev_context = EnvironmentContext::from_turn_context_item(prev, shell.name().to_string()); - let next_context = EnvironmentContext::from_turn_context(next, shell); - if prev_context.equals_except_shell(&next_context) { - return None; - } - - Some(ContextualUserFragment::into( - EnvironmentContext::diff_from_turn_context_item(prev, &next_context), - )) -} - fn build_permissions_update_item( previous: Option<&TurnContextItem>, next: &TurnContext, @@ -220,6 +197,26 @@ pub(crate) fn build_contextual_user_message(text_sections: Vec) -> Optio build_text_message("user", text_sections) } +pub(crate) fn merge_contextual_fragments( + fragments: Vec>, +) -> Vec { + let mut messages: Vec<(&str, Vec)> = Vec::with_capacity(fragments.len()); + for fragment in fragments { + let role = fragment.role(); + let text = fragment.render(); + match messages.last_mut() { + Some((previous_role, text_sections)) if *previous_role == role => { + text_sections.push(text); + } + _ => messages.push((role, vec![text])), + } + } + messages + .into_iter() + .filter_map(|(role, text_sections)| build_text_message(role, text_sections)) + .collect() +} + fn build_text_message(role: &str, text_sections: Vec) -> Option { if text_sections.is_empty() { return None; @@ -243,7 +240,6 @@ pub(crate) fn build_settings_update_items( previous: Option<&TurnContextItem>, previous_turn_settings: Option<&PreviousTurnSettings>, next: &TurnContext, - shell: &Shell, exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { @@ -251,7 +247,6 @@ pub(crate) fn build_settings_update_items( // model-visible item emitted by build_initial_context. Persist the remaining // inputs or add explicit replay events so fork/resume can diff everything // deterministically. - let contextual_user_message = build_environment_update_item(previous, next, shell); let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before // any other context diffs on this turn. @@ -266,12 +261,7 @@ pub(crate) fn build_settings_update_items( .flatten() .collect(); - let mut items = Vec::with_capacity(2); - if let Some(developer_message) = build_developer_update_item(developer_update_sections) { - items.push(developer_message); - } - if let Some(contextual_user_message) = contextual_user_message { - items.push(contextual_user_message); - } - items + build_developer_update_item(developer_update_sections) + .into_iter() + .collect() } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 32fcfd09e2..4a7c7a9e4a 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -32,6 +32,7 @@ use crate::context::NetworkRuleSaved; use crate::context::PermissionsInstructions; use crate::context::PersonalitySpecInstructions; use crate::context::RecommendedPluginsInstructions; +use crate::context::world_state::WorldState; use crate::current_time::TimeProvider; use crate::default_skill_metadata_budget; use crate::environment_selection::TurnEnvironmentSnapshot; @@ -223,6 +224,7 @@ pub(crate) mod time_reminder; mod token_budget; pub(crate) mod turn; pub(crate) mod turn_context; +mod world_state; use self::config_lock::export_config_lock_if_configured; use self::config_lock::validate_config_lock_if_configured; #[cfg(test)] @@ -243,6 +245,9 @@ use self::turn::collect_explicit_app_ids_from_skill_items; use self::turn::realtime_text_for_event; use self::turn_context::TurnContext; use self::turn_context::TurnSkillsContext; +use self::world_state::build_world_state_from_environment_snapshot; +use self::world_state::build_world_state_from_turn_context; +use self::world_state::build_world_state_from_turn_context_item; #[cfg(test)] mod rollout_reconstruction_tests; @@ -1377,9 +1382,15 @@ impl Session { // will be processed again if the rollout is reconstructed in a future session. // This meets image resizing requirements without modifying persisted rollouts. prepare_response_items(&mut history); + let world_state_baseline = reference_context_item + .as_ref() + .map(build_world_state_from_turn_context_item); { let mut state = self.state.lock().await; state.replace_history(history, reference_context_item); + if let Some(world_state) = world_state_baseline { + state.history.set_world_state_baseline(world_state); + } let fallback_ids = state.auto_compact_window_ids(); let window_id = window_id.unwrap_or(fallback_ids.window_id); state.restore_auto_compact_window( @@ -1674,19 +1685,17 @@ impl Session { ) -> Vec { // TODO: Make context updates a pure diff of persisted previous/current TurnContextItem // state so replay/backtracking is deterministic. Runtime inputs that affect model-visible - // context (shell, exec policy, feature gates, previous-turn bridge) should be persisted + // context (exec policy, feature gates, previous-turn bridge) should be persisted // state or explicit non-state replay events. let previous_turn_settings = { let state = self.state.lock().await; state.previous_turn_settings() }; - let shell = self.user_shell(); let exec_policy = self.services.exec_policy.current(); crate::context_manager::updates::build_settings_update_items( reference_context_item, previous_turn_settings.as_ref(), current_context, - shell.as_ref(), exec_policy.as_ref(), self.features.enabled(Feature::Personality), ) @@ -2757,24 +2766,19 @@ impl Session { return; } - let shell = self.user_shell(); - let Some(environment_context) = - crate::context::EnvironmentContext::from_step_context(step_context, shell.as_ref()) - else { - return; - }; - let changed = { + let world_state = + build_world_state_from_environment_snapshot(turn_context, &step_context.environments); + let items = { let mut state = self.state.lock().await; - state - .history - .update_environment_context_baseline(&environment_context) + crate::context_manager::updates::merge_contextual_fragments( + state.history.update_world_state(world_state), + ) }; - if !changed { + if items.is_empty() { return; } - let item = ContextualUserFragment::into(environment_context); - self.record_conversation_items(turn_context, &[item]).await; + self.record_conversation_items(turn_context, &items).await; } pub(crate) async fn record_inter_agent_communication( @@ -2886,9 +2890,17 @@ impl Session { replacement_history: Some(items.clone()), ..compacted_item }; + let world_state_baseline = if reference_context_item.is_some() { + Some(self.build_world_state(turn_context).await) + } else { + None + }; { let mut state = self.state.lock().await; state.replace_history(items, reference_context_item.clone()); + if let Some(world_state) = world_state_baseline { + state.history.set_world_state_baseline(world_state); + } } self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)]) @@ -3017,6 +3029,28 @@ impl Session { pub(crate) async fn build_initial_context( &self, turn_context: &TurnContext, + ) -> Vec { + let world_state = self.build_world_state(turn_context).await; + self.build_initial_context_with_world_state(turn_context, &world_state) + .await + } + + async fn build_world_state(&self, turn_context: &TurnContext) -> WorldState { + let environment_subagents = if turn_context.config.include_environment_context { + self.services + .agent_control + .format_environment_context_subagents(self.thread_id) + .await + } else { + String::new() + }; + build_world_state_from_turn_context(turn_context, &environment_subagents) + } + + async fn build_initial_context_with_world_state( + &self, + turn_context: &TurnContext, + world_state: &WorldState, ) -> Vec { let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); @@ -3257,18 +3291,12 @@ impl Session { .render(), ); } - if turn_context.config.include_environment_context { - let shell = self.user_shell(); - let subagents = self - .services - .agent_control - .format_environment_context_subagents(self.thread_id) - .await; - contextual_user_sections.push( - crate::context::EnvironmentContext::from_turn_context(turn_context, shell.as_ref()) - .with_subagents(subagents) - .render(), - ); + for fragment in world_state.render_full() { + match fragment.role() { + "developer" => developer_sections.push(fragment.render()), + "user" => contextual_user_sections.push(fragment.render()), + _ => {} + } } let multi_agent_v2_usage_hint_text = @@ -3370,12 +3398,16 @@ impl Session { state.start_new_context_window_if_requested() }; let (window_number, window_ids) = window?; - let context_items = self.build_initial_context(turn_context).await; + let world_state = self.build_world_state(turn_context).await; + let context_items = self + .build_initial_context_with_world_state(turn_context, &world_state) + .await; let turn_context_item = turn_context.to_turn_context_item(); let replacement_history = context_items; { let mut state = self.state.lock().await; state.replace_history(replacement_history.clone(), Some(turn_context_item.clone())); + state.history.set_world_state_baseline(world_state); }; self.persist_rollout_items(&[ RolloutItem::Compacted(CompactedItem { @@ -3406,15 +3438,15 @@ impl Session { /// steady-state turns that emit model-visible context updates. /// /// When the reference snapshot is missing, this injects full initial context. Otherwise, it - /// emits only settings diff items. + /// emits only context diffs. /// /// If full context is injected and a model switch occurred, this prepends the /// `` developer message so model-specific instructions are not lost. /// /// This is the normal runtime path that establishes a new `reference_context_item`. - /// Mid-turn compaction is the other path that can re-establish that baseline when it - /// reinjects full initial context into replacement history. Other non-regular tasks - /// intentionally do not update the baseline. + /// Mid-turn compaction is the other path that can re-establish that reference when it + /// reinjects full initial context into replacement history. Live world-state changes may + /// independently advance their in-memory baseline within a turn. #[instrument(level = "trace", skip_all)] pub(crate) async fn record_context_updates_and_set_reference_context_item( &self, @@ -3425,40 +3457,43 @@ impl Session { state.reference_context_item() }; let turn_context_item = turn_context.to_turn_context_item(); - if reference_context_item.as_ref() == Some(&turn_context_item) { - return; - } + let turn_context_changed = reference_context_item.as_ref() != Some(&turn_context_item); let should_inject_full_context = reference_context_item.is_none(); + let world_state = self.build_world_state(turn_context).await; let mut context_items = if should_inject_full_context { - self.build_initial_context(turn_context).await + let context_items = self + .build_initial_context_with_world_state(turn_context, &world_state) + .await; + self.state + .lock() + .await + .history + .set_world_state_baseline(world_state); + context_items } else { // Steady-state path: append only built-in context diffs here; turn-scoped extension // context is added below. - self.build_settings_update_items(reference_context_item.as_ref(), turn_context) - .await + let mut context_items = self + .build_settings_update_items(reference_context_item.as_ref(), turn_context) + .await; + let world_state_items = { + let mut state = self.state.lock().await; + crate::context_manager::updates::merge_contextual_fragments( + state.history.update_world_state(world_state), + ) + }; + context_items.extend(world_state_items); + context_items }; - if !should_inject_full_context { + if !should_inject_full_context && turn_context_changed { context_items.extend( self.build_turn_context_contribution_items(turn_context) .await, ); } - let initial_environment_context = if should_inject_full_context - && !context_items.is_empty() - && turn_context.config.include_environment_context - && turn_context - .config - .features - .enabled(Feature::DeferredExecutor) - { - let shell = self.user_shell(); - crate::context::EnvironmentContext::from_attached_environments( - &turn_context.environments.turn_environments, - shell.as_ref(), - ) - } else { - None - }; + if !turn_context_changed && context_items.is_empty() { + return; + } if !context_items.is_empty() { self.record_conversation_items(turn_context, &context_items) .await; @@ -3468,15 +3503,10 @@ impl Session { self.persist_rollout_items(&[RolloutItem::TurnContext(turn_context_item.clone())]) .await; - // Advance the in-memory diff baseline even when this turn emitted no model-visible - // context items. This keeps later runtime diffing aligned with the current turn state. + // Advance the persisted-settings baseline even when this turn emitted no model-visible + // context items. let mut state = self.state.lock().await; state.set_reference_context_item(Some(turn_context_item)); - if let Some(environment_context) = initial_environment_context { - state - .history - .update_environment_context_baseline(&environment_context); - } } pub(crate) async fn update_token_usage_info( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 903ea0b477..3443c233d9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7349,7 +7349,7 @@ async fn spawn_task_does_not_update_previous_turn_settings_for_non_run_turn_task } #[tokio::test] -async fn build_settings_update_items_emits_environment_item_for_network_changes() { +async fn record_context_updates_emits_environment_item_for_network_changes() { let (session, previous_context) = make_session_and_context().await; let previous_context = Arc::new(previous_context); let mut current_context = previous_context @@ -7396,10 +7396,8 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( .expect("rebuild config layer stack with network requirements"); current_context.config = Arc::new(config); - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; let environment_update = user_input_texts(&update_items) .into_iter() @@ -7411,50 +7409,40 @@ async fn build_settings_update_items_emits_environment_item_for_network_changes( } #[tokio::test] -async fn environment_context_uses_session_shell_when_environment_shell_is_absent() { - let (mut session, mut turn_context) = make_session_and_context().await; - session.services.user_shell = Arc::new(crate::shell::Shell { - shell_type: crate::shell::ShellType::PowerShell, - shell_path: PathBuf::from("powershell"), - }); - for environment in &mut turn_context.environments.turn_environments { - environment.shell = None; - } - - let session_shell = session.user_shell(); - let environment_context = crate::context::EnvironmentContext::from_turn_context( - &turn_context, - session_shell.as_ref(), - ) - .render(); - assert!( - environment_context.contains("powershell"), - "{environment_context}" +async fn record_context_updates_emits_environment_item_for_cwd_changes() { + let (session, previous_context) = make_session_and_context().await; + let previous_context = Arc::new(previous_context); + let mut current_context = previous_context + .with_model( + previous_context.model_info.slug.clone(), + &session.services.models_manager, + ) + .await; + let cwd = test_path_buf("/new-repo").abs(); + let environment = current_context.environments.turn_environments[0].clone(); + current_context.environments.turn_environments[0] = TurnEnvironment::new( + environment.environment_id, + environment.environment, + PathUri::from_abs_path(&cwd), + environment.shell, ); - let primary_environment = turn_context - .environments - .turn_environments - .first_mut() - .expect("primary environment"); - primary_environment.shell = Some(crate::shell::Shell { - shell_type: crate::shell::ShellType::Cmd, - shell_path: PathBuf::from("cmd"), - }); + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; - let environment_context = crate::context::EnvironmentContext::from_turn_context( - &turn_context, - session_shell.as_ref(), - ) - .render(); + let environment_update = user_input_texts(&update_items) + .into_iter() + .find(|text| text.contains("")) + .expect("environment update item should be emitted"); assert!( - environment_context.contains("cmd"), - "{environment_context}" + environment_update.contains(&format!("{}", cwd.display())), + "{environment_update}" ); + assert!(!environment_update.contains("")); } #[tokio::test] -async fn build_settings_update_items_emits_environment_item_for_time_changes() { +async fn record_context_updates_emits_environment_item_for_time_changes() { let (session, previous_context) = make_session_and_context().await; let previous_context = Arc::new(previous_context); let mut current_context = previous_context @@ -7466,10 +7454,8 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { current_context.current_date = Some("2026-02-27".to_string()); current_context.timezone = Some("Europe/Berlin".to_string()); - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; let environment_update = user_input_texts(&update_items) .into_iter() @@ -7480,7 +7466,7 @@ async fn build_settings_update_items_emits_environment_item_for_time_changes() { } #[tokio::test] -async fn build_settings_update_items_omits_environment_item_when_disabled() { +async fn record_context_updates_omits_environment_item_when_disabled() { let (session, previous_context) = make_session_and_context().await; let previous_context = Arc::new(previous_context); let mut current_context = previous_context @@ -7492,12 +7478,16 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() { let mut config = (*current_context.config).clone(); config.include_environment_context = false; current_context.config = Arc::new(config); - current_context.current_date = Some("2026-02-27".to_string()); + let environment = current_context.environments.turn_environments[0].clone(); + current_context.environments.turn_environments[0] = TurnEnvironment::new( + environment.environment_id, + environment.environment, + PathUri::from_abs_path(&test_path_buf("/new-repo").abs()), + environment.shell, + ); - let reference_context_item = previous_context.to_turn_context_item(); - let update_items = session - .build_settings_update_items(Some(&reference_context_item), ¤t_context) - .await; + let update_items = + record_context_update_items(&session, &previous_context, ¤t_context).await; let user_texts = user_input_texts(&update_items); assert!( @@ -7508,6 +7498,23 @@ async fn build_settings_update_items_omits_environment_item_when_disabled() { ); } +async fn record_context_update_items( + session: &Session, + previous_context: &TurnContext, + current_context: &TurnContext, +) -> Vec { + session + .record_context_updates_and_set_reference_context_item(previous_context) + .await; + let previous_len = session.clone_history().await.raw_items().len(); + + session + .record_context_updates_and_set_reference_context_item(current_context) + .await; + let history = session.clone_history().await; + history.raw_items()[previous_len..].to_vec() +} + #[tokio::test] async fn build_settings_update_items_emits_realtime_start_when_session_becomes_live() { let (session, previous_context) = make_session_and_context().await; @@ -7769,9 +7776,11 @@ async fn record_context_updates_includes_turn_context_fragments_on_steady_state_ }); let mut previous_context_item = turn_context.to_turn_context_item(); previous_context_item.turn_id = Some("previous-turn-id".to_string()); + let world_state = session.build_world_state(&turn_context).await; { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item)); + state.history.set_world_state_baseline(world_state); } session @@ -8441,9 +8450,11 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline .with_model(next_model.to_string(), &session.services.models_manager) .await; let previous_context_item = previous_context.to_turn_context_item(); + let world_state = session.build_world_state(&previous_context).await; { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item.clone())); + state.history.set_world_state_baseline(world_state); } let rollout_path = attach_thread_persistence(&mut session).await; diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs new file mode 100644 index 0000000000..5d12857748 --- /dev/null +++ b/codex-rs/core/src/session/world_state.rs @@ -0,0 +1,41 @@ +use super::turn_context::TurnContext; +use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::WorldState; +use crate::environment_selection::TurnEnvironmentSnapshot; +use codex_protocol::protocol::TurnContextItem; + +pub(super) fn build_world_state_from_turn_context( + turn_context: &TurnContext, + environment_subagents: &str, +) -> WorldState { + let mut world_state = WorldState::default(); + if turn_context.config.include_environment_context { + world_state.add_section( + EnvironmentsState::from_turn_context(turn_context) + .with_subagents(environment_subagents.to_string()), + ); + } + world_state +} + +pub(super) fn build_world_state_from_environment_snapshot( + turn_context: &TurnContext, + environments: &TurnEnvironmentSnapshot, +) -> WorldState { + let mut world_state = WorldState::default(); + if turn_context.config.include_environment_context { + world_state.add_section(EnvironmentsState::from_turn_context_with_environments( + turn_context, + environments, + )); + } + world_state +} + +pub(super) fn build_world_state_from_turn_context_item( + turn_context_item: &TurnContextItem, +) -> WorldState { + let mut world_state = WorldState::default(); + world_state.add_section(EnvironmentsState::from_turn_context_item(turn_context_item)); + world_state +} From f1945de3b764d50539d4d9a01b349c9cfc2426f6 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 22 Jun 2026 16:37:49 -0700 Subject: [PATCH 56/86] core: wrap token budget window context (#29494) Token-budget initial context carries thread and context-window lineage that the model should treat as one structured context-window block. Wrapping it in `` makes that boundary explicit while preserving the existing window id content. Before this change, the window identifiers were injected as an untagged developer text fragment: ```text Thread id . First context window id: Current context window id: Previous context window id: ``` After this change, the same payload is wrapped as a context-window block: ```text Thread id: First context window id: Current context window id: Previous context window id: ``` This adds shared `CONTEXT_WINDOW_*_TAG` protocol constants, updates `TokenBudgetContext` to render with those markers, treats the new wrapper as contextual developer content when mapping history, and refreshes the token-budget request-shape assertions and snapshot. Verification: - `just test -p codex-core token_budget` - `just test -p codex-core recognizes_context_window_as_contextual_developer_content` --- .../core/src/context/token_budget_context.rs | 8 +++++--- codex-rs/core/src/event_mapping.rs | 2 ++ codex-rs/core/src/event_mapping_tests.rs | 16 ++++++++++++++++ ...get_new_context_window_tool_full_context.snap | 4 ++-- codex-rs/core/tests/suite/token_budget.rs | 9 ++++++--- codex-rs/protocol/src/protocol.rs | 6 ++++-- 6 files changed, 35 insertions(+), 10 deletions(-) diff --git a/codex-rs/core/src/context/token_budget_context.rs b/codex-rs/core/src/context/token_budget_context.rs index 55a94b2b11..3b48aeaa72 100644 --- a/codex-rs/core/src/context/token_budget_context.rs +++ b/codex-rs/core/src/context/token_budget_context.rs @@ -1,5 +1,7 @@ use super::ContextualUserFragment; use codex_protocol::ThreadId; +use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use uuid::Uuid; #[derive(Debug, Clone, PartialEq, Eq)] @@ -39,7 +41,7 @@ impl ContextualUserFragment for TokenBudgetContext { } fn type_markers() -> (&'static str, &'static str) { - ("", "") + (CONTEXT_WINDOW_OPEN_TAG, CONTEXT_WINDOW_CLOSE_TAG) } fn body(&self) -> String { @@ -47,7 +49,7 @@ impl ContextualUserFragment for TokenBudgetContext { let first_window_id = self.first_window_id; let window_id = self.window_id; let mut lines = vec![ - format!("Thread id {thread_id}."), + format!("Thread id: {thread_id}"), format!("First context window id: {first_window_id}"), format!("Current context window id: {window_id}"), ]; @@ -57,7 +59,7 @@ impl ContextualUserFragment for TokenBudgetContext { if let Some(mcp_result) = &self.mcp_result { lines.push(mcp_result.clone()); } - lines.join("\n") + format!("\n{}\n", lines.join("\n")) } } diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 4c57c5cadc..07f06c44b6 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -15,6 +15,7 @@ use codex_protocol::models::is_image_open_tag_text; use codex_protocol::models::is_local_image_close_tag_text; use codex_protocol::models::is_local_image_open_tag_text; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; @@ -36,6 +37,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", // Keep recognizing token-budget wrappers persisted by older versions. "", + CONTEXT_WINDOW_OPEN_TAG, "", ]; diff --git a/codex-rs/core/src/event_mapping_tests.rs b/codex-rs/core/src/event_mapping_tests.rs index f4c3ddd3a6..d7e999afb9 100644 --- a/codex-rs/core/src/event_mapping_tests.rs +++ b/codex-rs/core/src/event_mapping_tests.rs @@ -15,6 +15,8 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::ResponseItem; use codex_protocol::models::WebSearchAction; +use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::user_input::UserInput; use pretty_assertions::assert_eq; @@ -39,6 +41,20 @@ fn recognizes_legacy_token_budget_as_contextual_developer_content() { assert!(!has_non_contextual_dev_message_content(&content)); } +#[test] +fn recognizes_context_window_as_contextual_developer_content() { + let content = vec![ContentItem::InputText { + text: format!( + r#"{CONTEXT_WINDOW_OPEN_TAG} +Thread id: 00000000-0000-0000-0000-000000000000 +{CONTEXT_WINDOW_CLOSE_TAG}"# + ), + }]; + + assert!(is_contextual_dev_message_content(&content)); + assert!(!has_non_contextual_dev_message_content(&content)); +} + #[test] fn parses_user_message_with_text_and_two_images() { let img1 = "https://example.com/one.png".to_string(); diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap index c0c905625f..49687d347b 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__token_budget__token_budget_new_context_window_tool_full_context.snap @@ -1,6 +1,6 @@ --- source: core/tests/suite/token_budget.rs -assertion_line: 588 +assertion_line: 545 expression: snapshot --- Scenario: New context window tool installs fresh full context before the next follow-up request. @@ -9,7 +9,7 @@ Scenario: New context window tool installs fresh full context before the next fo 00:message/developer[3]: [01] [02] - [03] Thread id .\nFirst context window id: \nCurrent context window id: \nPrevious context window id: + [03] \nThread id: \nFirst context window id: \nCurrent context window id: \nPrevious context window id: \n 01:message/user:> 02:function_call/update_plan 03:function_call_output:Plan updated diff --git a/codex-rs/core/tests/suite/token_budget.rs b/codex-rs/core/tests/suite/token_budget.rs index ec86ce5946..09399e511e 100644 --- a/codex-rs/core/tests/suite/token_budget.rs +++ b/codex-rs/core/tests/suite/token_budget.rs @@ -4,6 +4,8 @@ use codex_config::types::McpServerTransportConfig; use codex_core::config::TokenBudgetConfig; use codex_features::Feature; use codex_model_provider_info::built_in_model_providers; +use codex_protocol::protocol::CONTEXT_WINDOW_CLOSE_TAG; +use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use core_test_support::PathBufExt; @@ -34,10 +36,11 @@ use std::time::Duration; const CONFIGURED_CONTEXT_WINDOW: i64 = 128_000; fn token_budget_contexts(request: &ResponsesRequest) -> Vec { + let context_window_prefix = format!("{CONTEXT_WINDOW_OPEN_TAG}\nThread id: "); request .message_input_texts("developer") .into_iter() - .filter(|text| text.starts_with("Thread id ")) + .filter(|text| text.starts_with(&context_window_prefix)) .collect() } @@ -47,7 +50,7 @@ fn token_budget_window_ids( ) -> (String, Option, String) { let captures = assert_regex_match( &format!( - r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})(?:\nPrevious context window id: ([0-9a-f-]{{36}}))?$" + r"^{CONTEXT_WINDOW_OPEN_TAG}\nThread id: {thread_id}\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})(?:\nPrevious context window id: ([0-9a-f-]{{36}}))?\n{CONTEXT_WINDOW_CLOSE_TAG}$" ), text, ); @@ -191,7 +194,7 @@ async fn token_budget_context_injects_plain_thread_hint_text() -> Result<()> { assert_eq!(token_budgets.len(), 1); let captures = assert_regex_match( &format!( - r"^Thread id {thread_id}\.\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result$" + r"^{CONTEXT_WINDOW_OPEN_TAG}\nThread id: {thread_id}\nFirst context window id: ([0-9a-f-]{{36}})\nCurrent context window id: ([0-9a-f-]{{36}})\nmanual history hint for thread {thread_id}\nunstructured notes/thread_hint fixture result\n{CONTEXT_WINDOW_CLOSE_TAG}$" ), &token_budgets[0], ); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index dfcc6cb7a5..600aa475bd 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -92,8 +92,8 @@ use crate::permissions::default_read_only_subpaths_for_writable_root; pub use crate::request_permissions::RequestPermissionsArgs; pub use crate::request_user_input::RequestUserInputEvent; -/// Open/close tags for special user-input blocks. Used across crates to avoid -/// duplicated hardcoded strings. +/// Open/close tags for special context blocks. Used across crates to avoid duplicated hardcoded +/// strings. pub const USER_INSTRUCTIONS_OPEN_TAG: &str = ""; pub const USER_INSTRUCTIONS_CLOSE_TAG: &str = ""; pub const ENVIRONMENT_CONTEXT_OPEN_TAG: &str = ""; @@ -110,6 +110,8 @@ pub const MULTI_AGENT_MODE_OPEN_TAG: &str = ""; pub const MULTI_AGENT_MODE_CLOSE_TAG: &str = ""; pub const REALTIME_CONVERSATION_OPEN_TAG: &str = ""; pub const REALTIME_CONVERSATION_CLOSE_TAG: &str = ""; +pub const CONTEXT_WINDOW_OPEN_TAG: &str = ""; +pub const CONTEXT_WINDOW_CLOSE_TAG: &str = ""; pub const USER_MESSAGE_BEGIN: &str = "## My request for Codex:"; // TODO(anp): Replace `TurnEnvironmentSelection` with `PathUri` once path URIs carry environment From 7153affa0fef1653a9ee8d2b4864c50e8ec0d56d Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 16:41:00 -0700 Subject: [PATCH 57/86] [codex] replace remote images with model-visible error text (#29417) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What This PR will extend the existing centralized image-preparation path to replace HTTP(S) image inputs with a model visible error message. It won't "ruin" and break existing rollouts, but it will deprecate support for the pathway. App server clients should no longer use HTTP image urls if they'd like to upgrade. The HTTP image url pathway is currently resolved in the responsesapi. It is slow and not reccomended. ## Behavior - HTTP(S) image URL: replace with `input_text` - data URL: use the existing decode and resize path - other image URL schemes: leave unchanged This intentionally does not change app-server ingress. That validation remains a follow-up. ## Test plan - `just test -p codex-core -E 'test(/image_preparation|prepares_image_failures_before_history_insertion|prepares_resumed_history_before_installing_it|responses_lite_prepares_images/)'` — 7 passed - `just fix -p codex-core` - `just fmt` --- .../tests/suite/v2/imagegen_extension.rs | 2 +- codex-rs/core/src/image_preparation.rs | 20 +++++++- codex-rs/core/src/image_preparation_tests.rs | 16 +++---- codex-rs/core/src/session/tests.rs | 14 ++++-- codex-rs/core/tests/suite/responses_lite.rs | 48 ++++++++++++------- 5 files changed, 69 insertions(+), 31 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs index 7a29bb3542..23b97dc9c5 100644 --- a/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs +++ b/codex-rs/app-server/tests/suite/v2/imagegen_extension.rs @@ -256,7 +256,7 @@ async fn standalone_image_edit_uses_attached_model_visible_image() -> Result<()> #[tokio::test] async fn standalone_image_edit_uses_recent_pathless_image() -> Result<()> { - let image_url = "https://example.com/reference.png"; + let image_url = TINY_PNG_DATA_URL; let edit_request = run_image_edit_test(|_| { Ok(( json!({ diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index d30dfbb5ca..87ac444df5 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -13,6 +13,8 @@ pub(crate) const IMAGE_PROCESSING_ERROR_PLACEHOLDER: &str = const IMAGE_TOO_LARGE_PLACEHOLDER: &str = "image content omitted because it exceeded the supported size limit; use a smaller image"; const UNSUPPORTED_LOW_DETAIL_PLACEHOLDER: &str = "image content omitted because detail 'low' is not supported; use 'high', 'original', or 'auto'"; +const REMOTE_IMAGE_URL_PLACEHOLDER: &str = + "image content omitted because remote image URLs are not supported"; const HIGH_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits { max_dimension: 2048, @@ -24,6 +26,8 @@ const ORIGINAL_DETAIL_LIMITS: PromptImageResizeLimits = PromptImageResizeLimits }; #[derive(Debug, thiserror::Error)] enum ImagePreparationError { + #[error("remote image URLs are not supported")] + RemoteUrlUnsupported, #[error("image detail `low` is not supported")] UnsupportedLowDetail, #[error(transparent)] @@ -33,6 +37,7 @@ enum ImagePreparationError { impl ImagePreparationError { fn placeholder(&self) -> &'static str { match self { + ImagePreparationError::RemoteUrlUnsupported => REMOTE_IMAGE_URL_PLACEHOLDER, ImagePreparationError::UnsupportedLowDetail => UNSUPPORTED_LOW_DETAIL_PLACEHOLDER, ImagePreparationError::Processing(ImageProcessingError::ImageTooLarge { .. }) => { IMAGE_TOO_LARGE_PLACEHOLDER @@ -72,7 +77,6 @@ pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) { fn prepare_message_content(items: &mut [ContentItem]) { for item in items { if let ContentItem::InputImage { image_url, detail } = item - && is_data_url(image_url) && let Err(error) = prepare_image(image_url, *detail) { warn!(%error, "failed to prepare message image"); @@ -86,7 +90,6 @@ fn prepare_message_content(items: &mut [ContentItem]) { fn prepare_tool_output_content(items: &mut [FunctionCallOutputContentItem]) { for item in items { if let FunctionCallOutputContentItem::InputImage { image_url, detail } = item - && is_data_url(image_url) && let Err(error) = prepare_image(image_url, *detail) { warn!(%error, "failed to prepare tool output image"); @@ -97,6 +100,12 @@ fn prepare_tool_output_content(items: &mut [FunctionCallOutputContentItem]) { } } +fn is_remote_image_url(image_url: &str) -> bool { + image_url.split_once(':').is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) +} + fn is_data_url(image_url: &str) -> bool { image_url .get(.."data:".len()) @@ -107,6 +116,13 @@ fn prepare_image( image_url: &mut String, detail: Option, ) -> Result<(), ImagePreparationError> { + if is_remote_image_url(image_url) { + return Err(ImagePreparationError::RemoteUrlUnsupported); + } + if !is_data_url(image_url) { + return Ok(()); + } + let limits = match detail { None | Some(ImageDetail::Auto | ImageDetail::High) => HIGH_DETAIL_LIMITS, Some(ImageDetail::Original) => ORIGINAL_DETAIL_LIMITS, diff --git a/codex-rs/core/src/image_preparation_tests.rs b/codex-rs/core/src/image_preparation_tests.rs index 7bc4b8e103..659eb2b334 100644 --- a/codex-rs/core/src/image_preparation_tests.rs +++ b/codex-rs/core/src/image_preparation_tests.rs @@ -32,9 +32,8 @@ fn decoded_image(image_url: &str) -> (Vec, DynamicImage) { } #[test] -fn preparation_preserves_small_image_bytes_and_non_data_urls() { +fn preparation_preserves_small_image_bytes_and_replaces_remote_urls() { let (data_url, original_bytes) = png_data_url(/*width*/ 64, /*height*/ 32); - let http_url = "https://example.com/image.png".to_string(); let mut items = vec![ResponseItem::Message { id: None, role: "user".to_string(), @@ -44,7 +43,7 @@ fn preparation_preserves_small_image_bytes_and_non_data_urls() { detail: Some(ImageDetail::High), }, ContentItem::InputImage { - image_url: http_url.clone(), + image_url: "https://example.com/image.png".to_string(), detail: Some(ImageDetail::Low), }, ], @@ -59,16 +58,13 @@ fn preparation_preserves_small_image_bytes_and_non_data_urls() { }; let [ ContentItem::InputImage { image_url, .. }, - ContentItem::InputImage { - image_url: preserved_http_url, - detail: Some(ImageDetail::Low), - }, + ContentItem::InputText { text }, ] = content.as_slice() else { panic!("expected two images"); }; assert_eq!(decoded_image(image_url).0, original_bytes); - assert_eq!(preserved_http_url, &http_url); + assert_eq!(text, REMOTE_IMAGE_URL_PLACEHOLDER); } #[test] @@ -173,6 +169,10 @@ fn preparation_replaces_only_failed_tool_images_and_preserves_metadata() { #[test] fn preparation_errors_use_bounded_actionable_placeholders() { let cases = [ + ( + ImagePreparationError::RemoteUrlUnsupported, + REMOTE_IMAGE_URL_PLACEHOLDER, + ), ( ImagePreparationError::UnsupportedLowDetail, UNSUPPORTED_LOW_DETAIL_PLACEHOLDER, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 3443c233d9..ff71775aa5 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1749,9 +1749,9 @@ async fn prepares_image_failures_before_history_insertion() { FunctionCallOutputContentItem::InputText { text: "image content omitted because it could not be processed".to_string(), }, - FunctionCallOutputContentItem::InputImage { - image_url: "https://example.com/image.png".to_string(), - detail: Some(ImageDetail::High), + FunctionCallOutputContentItem::InputText { + text: "image content omitted because remote image URLs are not supported" + .to_string(), }, ]), success: Some(true), @@ -1772,6 +1772,10 @@ async fn prepares_resumed_history_before_installing_it() { image_url: "data:image/png;base64,%%%".to_string(), detail: Some(ImageDetail::High), }, + ContentItem::InputImage { + image_url: "https://example.com/image.png".to_string(), + detail: Some(ImageDetail::High), + }, ContentItem::InputText { text: "keep me".to_string(), }, @@ -1797,6 +1801,10 @@ async fn prepares_resumed_history_before_installing_it() { ContentItem::InputText { text: "image content omitted because it could not be processed".to_string(), }, + ContentItem::InputText { + text: "image content omitted because remote image URLs are not supported" + .to_string(), + }, ContentItem::InputText { text: "keep me".to_string(), }, diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index db4faa675e..8e633522f4 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -55,7 +55,7 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn responses_lite_strips_data_image_detail() -> Result<()> { +async fn responses_lite_prepares_images() -> Result<()> { skip_if_no_network!(Ok(())); let server = responses::start_mock_server().await; @@ -68,6 +68,7 @@ async fn responses_lite_strips_data_image_detail() -> Result<()> { ) .await; let image_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; + let remote_image_url = "https://example.com/image.png"; let mut builder = test_codex().with_model_info_override("gpt-5.4", |model_info| { model_info.use_responses_lite = true; configure_image_capable_model(model_info); @@ -76,10 +77,16 @@ async fn responses_lite_strips_data_image_detail() -> Result<()> { test.codex .submit(Op::UserInput { - items: vec![UserInput::Image { - image_url: image_url.to_string(), - detail: Some(ImageDetail::Original), - }], + items: vec![ + UserInput::Image { + image_url: image_url.to_string(), + detail: Some(ImageDetail::Original), + }, + UserInput::Image { + image_url: remote_image_url.to_string(), + detail: Some(ImageDetail::High), + }, + ], final_output_json_schema: None, responsesapi_client_metadata: None, additional_context: Default::default(), @@ -92,20 +99,27 @@ async fn responses_lite_strips_data_image_detail() -> Result<()> { .await; let request = response_mock.single_request(); - let input = request.input(); - let image = input - .iter() - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .find(|item| item.get("type").and_then(Value::as_str) == Some("input_image")) - .context("request should contain an image")?; + let user_content = request + .input() + .into_iter() + .rev() + .find(|item| item.get("role").and_then(Value::as_str) == Some("user")) + .and_then(|item| item.get("content").and_then(Value::as_array).cloned()) + .context("request should contain user content")?; assert_eq!( - image, - &serde_json::json!({ - "type": "input_image", - "image_url": image_url - }) + user_content, + vec![ + serde_json::json!({ + "type": "input_image", + "image_url": image_url + }), + serde_json::json!({ + "type": "input_text", + "text": "image content omitted because remote image URLs are not supported" + }), + ] ); + assert!(!request.body_json().to_string().contains(remote_image_url)); Ok(()) } From 4a82ecc3c9fcb8b9f21d5144c40a0dfdab54a02a Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 22 Jun 2026 16:45:14 -0700 Subject: [PATCH 58/86] feat(core): store turn_id on ResponseItem metadata (#28360) ## Description This PR is a followup to https://github.com/openai/codex/pull/28355 and starts assigning `internal_chat_message_metadata_passthrough.turn_id` to durable Responses API items created during a turn. The goal is that those items keep the `turn_id` that introduced them when Codex resends stateless HTTP context, reconstructs history for resume/fork paths, or reuses websocket response state. ## What changed - Set `internal_chat_message_metadata_passthrough.turn_id` when missing as response items enter durable history, initial/replacement history, inter-agent communication history, and local compaction summaries. - Preserve existing item turn IDs instead of overwriting them during persistence, resume reconstruction, compaction, forked history, and websocket incremental reuse. - Keep `compaction_trigger` fieldless because it is a request control, not a durable response item. - Update focused history/request assertions and fixtures for stateless requests, websocket incrementals, compaction, thread injection, prompt debug, and related CI coverage. --- .../schema/json/ClientRequest.json | 10 --- .../codex_app_server_protocol.schemas.json | 10 --- .../codex_app_server_protocol.v2.schemas.json | 10 --- .../RawResponseItemCompletedNotification.json | 10 --- .../schema/json/v2/ThreadResumeParams.json | 10 --- .../schema/typescript/ResponseItem.ts | 2 +- .../tests/suite/v2/thread_inject_items.rs | 2 +- .../app-server/tests/suite/v2/web_search.rs | 8 +- codex-rs/core/src/agent/control_tests.rs | 24 +++--- codex-rs/core/src/compact.rs | 5 ++ codex-rs/core/src/compact_remote_v2.rs | 4 +- codex-rs/core/src/session/mod.rs | 11 ++- codex-rs/core/src/session/tests.rs | 70 +++++++++++++++- .../src/tools/handlers/extension_tools.rs | 6 +- codex-rs/core/src/tools/router_tests.rs | 4 +- .../core/tests/common/context_snapshot.rs | 2 +- codex-rs/core/tests/common/responses.rs | 41 ++++++++- codex-rs/core/tests/suite/agent_websocket.rs | 5 +- codex-rs/core/tests/suite/client.rs | 62 +++++++++++++- .../core/tests/suite/client_websockets.rs | 9 +- .../core/tests/suite/compact_remote_parity.rs | 1 + codex-rs/core/tests/suite/image_rollout.rs | 4 +- codex-rs/core/tests/suite/prompt_caching.rs | 41 ++++++--- .../core/tests/suite/prompt_debug_tests.rs | 6 +- .../tests/suite/subagent_notifications.rs | 13 +-- codex-rs/protocol/src/models.rs | 84 ++++++------------- codex-rs/protocol/src/protocol.rs | 30 ++++++- 27 files changed, 317 insertions(+), 167 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e1399a2dd8..28597eff62 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -2922,16 +2922,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 810b8541d4..1b9719f986 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -15594,16 +15594,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/v2/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index de7d46aacb..4ade0622cd 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -11998,16 +11998,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" diff --git a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json index d06d821a7d..01ac63c268 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RawResponseItemCompletedNotification.json @@ -1003,16 +1003,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json index 4ce88a1162..bc9cb524d6 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -1074,16 +1074,6 @@ }, { "properties": { - "internal_chat_message_metadata_passthrough": { - "anyOf": [ - { - "$ref": "#/definitions/InternalChatMessageMetadataPassthrough" - }, - { - "type": "null" - } - ] - }, "type": { "enum": [ "compaction_trigger" diff --git a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts index 074e11a16e..62547d3e40 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ResponseItem.ts @@ -20,4 +20,4 @@ id?: string, /** * Set when using the Responses API. */ -call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; +call_id: string | null, status: LocalShellStatus, action: LocalShellAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call", id?: string, name: string, namespace?: string, arguments: string, call_id: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_call", id?: string, call_id: string | null, status?: string, execution: string, arguments: unknown, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "function_call_output", id?: string, call_id: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call", id?: string, status?: string, call_id: string, name: string, input: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "custom_tool_call_output", id?: string, call_id: string, name?: string, output: FunctionCallOutputBody, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "tool_search_output", id?: string, call_id: string | null, status: string, execution: string, tools: unknown[], internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "web_search_call", id?: string, status?: string, action?: WebSearchAction, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "image_generation_call", id?: string, status: string, revised_prompt?: string, result: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction", id?: string, encrypted_content: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "compaction_trigger", } | { "type": "context_compaction", id?: string, encrypted_content?: string, internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough, } | { "type": "other" }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs index 00b90e64d8..aa4908a378 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_inject_items.rs @@ -86,7 +86,7 @@ async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Resu resumed_history .history .iter() - .any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if response_item == &injected_item)), + .any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if responses::strip_metadata(response_item.clone()) == injected_item)), "injected item should be persisted in rollout history" ); diff --git a/codex-rs/app-server/tests/suite/v2/web_search.rs b/codex-rs/app-server/tests/suite/v2/web_search.rs index 626767132b..f8c115d746 100644 --- a/codex-rs/app-server/tests/suite/v2/web_search.rs +++ b/codex-rs/app-server/tests/suite/v2/web_search.rs @@ -156,8 +156,10 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { search_body["input"] .as_array() .context("search input should be an array")? - .last(), - Some(&json!({ + .last() + .cloned() + .map(responses::strip_metadata_from_json), + Some(json!({ "type": "message", "role": "user", "content": [{"type": "input_text", "text": "Search the web"}], @@ -165,7 +167,7 @@ async fn standalone_web_search_round_trips_output() -> Result<()> { ); assert_eq!( - requests[1].function_call_output(call_id), + responses::strip_metadata_from_json(requests[1].function_call_output(call_id)), json!({ "type": "function_call_output", "call_id": call_id, diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 17532bd785..e37e9b04db 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -887,6 +887,15 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { parent_thread .inject_user_message_without_turn("parent seed context".to_string()) .await; + let expected_parent_seed = parent_thread + .codex + .session + .clone_history() + .await + .raw_items() + .first() + .cloned() + .expect("parent seed should be recorded"); let turn_context = parent_thread.codex.session.new_default_turn().await; let parent_spawn_call_id = "spawn-call-history".to_string(); let trigger_message = InterAgentCommunication::new( @@ -988,17 +997,12 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { ); assert_ne!(child_thread_id, parent_thread_id); let history = child_thread.codex.session.clone_history().await; + let mut expected_final_answer = + assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)); + expected_final_answer.set_turn_id_if_missing(&turn_context.sub_id); let expected_history = [ - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { - text: "parent seed context".to_string(), - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, - assistant_message("parent final answer", Some(MessagePhase::FinalAnswer)), + expected_parent_seed, + expected_final_answer, ResponseItem::Message { id: None, role: "developer".to_string(), diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 94c26b8ad7..c60bd155e8 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -302,6 +302,11 @@ async fn run_compact_task_inner_impl( let user_messages = collect_user_messages(history_items); let mut new_history = build_compacted_history(Vec::new(), &user_messages, &summary_text); + if let Some(summary_item) = new_history.last_mut() { + // This replacement history skips `record_conversation_items`; only the appended summary + // belongs to this compaction turn. + summary_item.set_turn_id_if_missing(&turn_context.sub_id); + } let (window_number, window_ids) = sess.advance_auto_compact_window().await; if matches!( diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0778c74729..54d139a0e1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -234,9 +234,7 @@ async fn run_remote_compact_task_inner_impl( ) .await?; let mut input = prompt_input.clone(); - input.push(ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - }); + input.push(ResponseItem::CompactionTrigger {}); let prompt = Prompt { input, tools: tool_router.model_visible_specs(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 4a7c7a9e4a..1bd5bae8bf 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2693,6 +2693,10 @@ impl Session { ) -> Cow<'a, [ResponseItem]> { let mut items = Cow::Borrowed(items); prepare_response_items(items.to_mut()); + // Most response items get their passthrough turn ID at the durable history boundary. + for item in items.to_mut() { + item.set_turn_id_if_missing(&turn_context.sub_id); + } if turn_context.config.features.enabled(Feature::ItemIds) { Self::assign_missing_response_item_ids(items) } else { @@ -2784,8 +2788,9 @@ impl Session { pub(crate) async fn record_inter_agent_communication( &self, turn_context: &TurnContext, - communication: InterAgentCommunication, + mut communication: InterAgentCommunication, ) { + communication.set_turn_id_if_missing(&turn_context.sub_id); let response_item = communication.to_model_input_item(); let items = self.prepare_conversation_items_for_history( turn_context, @@ -3355,6 +3360,10 @@ impl Session { { items.push(guardian_developer_message); } + // New context windows and compaction install these items directly into replacement history. + for item in &mut items { + item.set_turn_id_if_missing(&turn_context.sub_id); + } items } diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ff71775aa5..378dff58c9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -152,6 +152,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_items; use core_test_support::test_codex::local; use core_test_support::test_codex::test_codex; use core_test_support::test_path_buf; @@ -1692,6 +1693,67 @@ async fn record_initial_history_reconstructs_resumed_transcript() { assert_eq!(expected, history.raw_items()); } +#[tokio::test] +async fn record_conversation_items_stamps_missing_turn_id_and_preserves_existing_turn_id() { + let (session, turn_context) = make_session_and_context().await; + let fresh_item = user_message("fresh"); + let mut existing_item = assistant_message("existing"); + existing_item.set_turn_id_if_missing("older-turn"); + + session + .record_conversation_items(&turn_context, &[fresh_item.clone(), existing_item.clone()]) + .await; + + let mut expected_fresh_item = fresh_item; + expected_fresh_item.set_turn_id_if_missing(&turn_context.sub_id); + let expected_items = vec![expected_fresh_item, existing_item]; + assert_eq!( + session.clone_history().await.raw_items(), + expected_items.as_slice() + ); +} + +#[tokio::test] +async fn record_inter_agent_communication_sets_turn_id_in_rollout_and_resume() { + let (mut session, turn_context) = make_session_and_context().await; + let rollout_path = attach_thread_persistence(&mut session).await; + let communication = InterAgentCommunication::new( + AgentPath::root().join("worker").expect("worker path"), + AgentPath::root(), + Vec::new(), + "child done".to_string(), + /*trigger_turn*/ false, + ); + let mut expected_item = communication.to_model_input_item(); + expected_item.set_turn_id_if_missing(&turn_context.sub_id); + + session + .record_inter_agent_communication(&turn_context, communication) + .await; + + assert_eq!( + session.clone_history().await.raw_items(), + std::slice::from_ref(&expected_item) + ); + + session.flush_rollout().await.expect("rollout should flush"); + let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path) + .await + .expect("read rollout history") + else { + panic!("expected resumed rollout history"); + }; + + let (resumed_session, _resumed_turn_context) = make_session_and_context().await; + resumed_session + .record_initial_history(InitialHistory::Resumed(resumed)) + .await; + assert_eq!( + resumed_session.clone_history().await.raw_items(), + std::slice::from_ref(&expected_item) + ); +} + #[tokio::test] async fn prepares_image_failures_before_history_insertion() { let (session, turn_context, _rx) = make_session_and_context_with_auth_and_config_and_rx( @@ -1758,7 +1820,7 @@ async fn prepares_image_failures_before_history_insertion() { }, internal_chat_message_metadata_passthrough: None, }]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); } #[tokio::test] @@ -8224,7 +8286,7 @@ async fn handle_output_item_done_records_image_save_history_message() { ), ); let expected = vec![image_message, item]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); assert_eq!( std::fs::read(&expected_saved_path).expect("saved file"), b"foo" @@ -8267,7 +8329,7 @@ async fn handle_output_item_done_skips_image_save_message_when_save_fails() { let history = session.clone_history().await; let expected = vec![item]; - assert_eq!(history.raw_items(), expected.as_slice()); + assert_eq!(strip_metadata_from_items(history.raw_items()), expected); assert!(!expected_saved_path.exists()); } @@ -9019,7 +9081,7 @@ async fn task_finish_emits_turn_item_lifecycle_for_leftover_pending_user_input() internal_chat_message_metadata_passthrough: None, }; assert!( - history.raw_items().iter().any(|item| item == &expected), + strip_metadata_from_items(history.raw_items()).contains(&expected), "expected pending input to be persisted into history on turn completion" ); diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index 808a9c123d..025cde231c 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -334,11 +334,13 @@ mod tests { session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let mut expected_history_item = history_item.clone(); + expected_history_item.set_turn_id_if_missing(&turn_id); let raw_history_event = rx.recv().await.expect("history raw response item event"); let EventMsg::RawResponseItem(raw_history_item) = raw_history_event.msg else { panic!("expected raw response item event"); }; - assert_eq!(raw_history_item.item, history_item); + assert_eq!(raw_history_item.item, expected_history_item); let invocation = ToolInvocation { session, turn, @@ -377,7 +379,7 @@ mod tests { ); assert_eq!( captured_call.conversation_history.items(), - std::slice::from_ref(&history_item) + std::slice::from_ref(&expected_history_item) ); match captured_call.payload { ToolPayload::Function { arguments } => { diff --git a/codex-rs/core/src/tools/router_tests.rs b/codex-rs/core/src/tools/router_tests.rs index 9cf237477f..77ddfe48d5 100644 --- a/codex-rs/core/src/tools/router_tests.rs +++ b/codex-rs/core/src/tools/router_tests.rs @@ -345,6 +345,8 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow session .record_conversation_items(&turn, std::slice::from_ref(&history_item)) .await; + let mut expected_history_item = history_item.clone(); + expected_history_item.set_turn_id_if_missing(&turn.sub_id); let router = ToolRouter::from_turn_context( &turn, @@ -404,7 +406,7 @@ async fn extension_tool_executors_are_model_visible_and_dispatchable() -> anyhow json!({ "arguments": { "message": "hello" }, "callId": "call-extension", - "conversationHistory": [history_item], + "conversationHistory": [expected_history_item], "ok": true, }) ); diff --git a/codex-rs/core/tests/common/context_snapshot.rs b/codex-rs/core/tests/common/context_snapshot.rs index 06e153da33..1a236e1aac 100644 --- a/codex-rs/core/tests/common/context_snapshot.rs +++ b/codex-rs/core/tests/common/context_snapshot.rs @@ -266,7 +266,7 @@ fn format_request_body_snapshot( request: &ResponsesRequest, options: &ContextSnapshotOptions, ) -> String { - let mut body = request.body_json(); + let mut body = crate::responses::strip_metadata_from_json(request.body_json()); canonicalize_json_snapshot_value(&mut body, options); serde_json::to_string_pretty(&body).expect("request body should serialize") } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 10abf64b6b..6a605a80b3 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -98,6 +98,35 @@ fn decode_body_bytes(body: &[u8], content_encoding: Option<&str>) -> Vec { } } +/// Returns a response item without internal transport metadata for semantic assertions. +pub fn strip_metadata(mut item: ResponseItem) -> ResponseItem { + item.clear_internal_chat_message_metadata_passthrough(); + item +} + +/// Returns response items without internal transport metadata for semantic assertions. +pub fn strip_metadata_from_items(items: &[ResponseItem]) -> Vec { + items.iter().cloned().map(strip_metadata).collect() +} + +/// Returns JSON without internal transport metadata for semantic assertions. +pub fn strip_metadata_from_json(value: Value) -> Value { + match value { + Value::Array(values) => { + Value::Array(values.into_iter().map(strip_metadata_from_json).collect()) + } + Value::Object(mut map) => { + map.remove("internal_chat_message_metadata_passthrough"); + Value::Object( + map.into_iter() + .map(|(key, value)| (key, strip_metadata_from_json(value))) + .collect(), + ) + } + value => value, + } +} + impl ResponsesRequest { pub fn body_json(&self) -> Value { let body = decode_body_bytes( @@ -1092,11 +1121,17 @@ pub async fn mount_compact_user_history_with_summary_sequence( ) }) .collect::>(); - // Append a synthetic compaction item as the newest item. - output.push(serde_json::json!({ + let compaction_turn_id = body_json["client_metadata"]["turn_id"].as_str(); + // Match Responses API: generated compaction items inherit the compact request turn. + let mut compaction_item = serde_json::json!({ "type": "compaction", "encrypted_content": summary_text, - })); + }); + if let Some(turn_id) = compaction_turn_id { + compaction_item["internal_chat_message_metadata_passthrough"] = + serde_json::json!({ "turn_id": turn_id }); + } + output.push(compaction_item); ResponseTemplate::new(200) .insert_header("content-type", "application/json") .set_body_json(serde_json::json!({ "output": output })) diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index c405e35995..f3e1047784 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -175,11 +175,14 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); let call_id = "shell-command-call"; + let mut shell_command_call = ev_shell_command_call(call_id, "echo websocket"); + shell_command_call["item"]["internal_chat_message_metadata_passthrough"] = + serde_json::json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ vec![ev_response_created("warm-1"), ev_completed("warm-1")], vec![ ev_response_created("resp-1"), - ev_shell_command_call(call_id, "echo websocket"), + shell_command_call, ev_completed("resp-1"), ], vec![ diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index d0e68c8a95..aa2026be1c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -66,6 +66,7 @@ use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::responses::sse_failed; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::responses_metadata as test_responses_metadata; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; @@ -185,6 +186,65 @@ fn assert_codex_client_metadata( ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn openai_stateless_responses_requests_preserve_item_turn_metadata_across_turns() { + let server = MockServer::start().await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp1"), + ev_assistant_message("msg-1", "first answer"), + ev_completed("resp1"), + ]), + sse(vec![ev_response_created("resp2"), ev_completed("resp2")]), + ], + ) + .await; + let test = test_codex().build(&server).await.unwrap(); + + test.submit_turn("turn one").await.unwrap(); + test.submit_turn("turn two").await.unwrap(); + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let first = requests[0].body_json(); + let second = requests[1].body_json(); + let first_turn_id = first["client_metadata"]["turn_id"] + .as_str() + .expect("first request should include turn id"); + let second_turn_id = second["client_metadata"]["turn_id"] + .as_str() + .expect("second request should include turn id"); + assert_ne!(first_turn_id, second_turn_id); + + let first_input = first["input"].as_array().expect("first input"); + let second_input = second["input"].as_array().expect("second input"); + assert_eq!(&second_input[..first_input.len()], first_input.as_slice()); + for item in first_input { + assert_eq!( + item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str(), + Some(first_turn_id) + ); + } + + let item_turn_id = |text: &str| { + second_input + .iter() + .find(|item| { + item["content"].as_array().is_some_and(|content| { + content + .iter() + .any(|content_item| content_item["text"].as_str() == Some(text)) + }) + }) + .and_then(|item| item["internal_chat_message_metadata_passthrough"]["turn_id"].as_str()) + }; + assert_eq!(item_turn_id("turn one"), Some(first_turn_id)); + assert_eq!(item_turn_id("first answer"), Some(first_turn_id)); + assert_eq!(item_turn_id("turn two"), Some(second_turn_id)); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn non_openai_responses_requests_omit_item_passthrough_metadata() { let server = MockServer::start().await; @@ -3612,7 +3672,7 @@ async fn history_dedupes_streamed_and_final_messages_across_turns() { let tail_len = r3_tail_expected.as_array().unwrap().len(); let actual_tail = &r3_input_array[r3_input_array.len() - tail_len..]; assert_eq!( - serde_json::Value::Array(actual_tail.to_vec()), + strip_metadata_from_json(serde_json::Value::Array(actual_tail.to_vec())), r3_tail_expected, "request 3 tail mismatch", ); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 5d1721ee75..d35714ea45 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -1564,10 +1564,13 @@ async fn responses_websocket_uses_incremental_create_on_prefix() { async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create() { skip_if_no_network!(); + let mut first_output_item = ev_assistant_message("msg-1", "assistant output"); + first_output_item["item"]["internal_chat_message_metadata_passthrough"] = + json!({"turn_id": "turn-123"}); let server = start_websocket_server(vec![vec![ vec![ ev_response_created("resp-1"), - ev_assistant_message("msg-1", "assistant output"), + first_output_item, ev_completed("resp-1"), ], vec![ev_response_created("resp-2"), ev_completed("resp-2")], @@ -1577,9 +1580,11 @@ async fn responses_websocket_forwards_turn_metadata_on_initial_and_incremental_c let harness = websocket_harness(&server).await; let mut client_session = harness.client.new_session(); let prompt_one = prompt_with_input(vec![message_item("hello")]); + let mut prior_assistant_output = assistant_message_item("msg-1", "assistant output"); + prior_assistant_output.set_turn_id_if_missing("turn-123"); let prompt_two = prompt_with_input(vec![ message_item("hello"), - assistant_message_item("msg-1", "assistant output"), + prior_assistant_output, message_item("second"), ]); let first_responses_metadata = turn_metadata(&harness, Some("turn-123")); diff --git a/codex-rs/core/tests/suite/compact_remote_parity.rs b/codex-rs/core/tests/suite/compact_remote_parity.rs index 432e5815aa..6f56ce016d 100644 --- a/codex-rs/core/tests/suite/compact_remote_parity.rs +++ b/codex-rs/core/tests/suite/compact_remote_parity.rs @@ -924,6 +924,7 @@ fn normalize_value(value: Value) -> Value { Value::Array(values) => Value::Array(values.into_iter().map(normalize_value).collect()), Value::Object(map) => Value::Object( map.into_iter() + .filter(|(key, _value)| key != "internal_chat_message_metadata_passthrough") .map(|(key, value)| (key, normalize_value(value))) .collect(), ), diff --git a/codex-rs/core/tests/suite/image_rollout.rs b/codex-rs/core/tests/suite/image_rollout.rs index f644f33404..d84a5501cc 100644 --- a/codex-rs/core/tests/suite/image_rollout.rs +++ b/codex-rs/core/tests/suite/image_rollout.rs @@ -181,7 +181,7 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu internal_chat_message_metadata_passthrough: None, }; - assert_eq!(actual, expected); + assert_eq!(responses::strip_metadata(actual), expected); Ok(()) } @@ -272,7 +272,7 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()> internal_chat_message_metadata_passthrough: None, }; - assert_eq!(actual, expected); + assert_eq!(responses::strip_metadata(actual), expected); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index a455512321..90d48d77a4 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -25,6 +25,7 @@ use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; @@ -54,6 +55,13 @@ fn text_user_input_parts(texts: Vec) -> serde_json::Value { }) } +fn assert_eq_without_metadata(left: serde_json::Value, right: serde_json::Value) { + assert_eq!( + strip_metadata_from_json(left), + strip_metadata_from_json(right) + ); +} + fn assert_default_env_context(text: &str, cwd: &str) { assert_env_context_fragment(text); assert!( @@ -384,16 +392,18 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests Some("input_text"), "expected environment context bundled after UI message in cached contextual message" ); - assert_eq!(input1[2], text_user_input("hello 1".to_string())); + assert_eq_without_metadata(input1[2].clone(), text_user_input("hello 1".to_string())); let body2 = req2.single_request().body_json(); let input2 = body2["input"].as_array().expect("input array"); - assert_eq!( - &input2[..input1.len()], - input1.as_slice(), - "expected cached prefix to be reused" + assert_eq_without_metadata( + serde_json::Value::Array(input2[..input1.len()].to_vec()), + serde_json::Value::Array(input1.to_vec()), + ); + assert_eq_without_metadata( + input2[input1.len()].clone(), + text_user_input("hello 2".to_string()), ); - assert_eq!(input2[input1.len()], text_user_input("hello 2".to_string())); Ok(()) } @@ -523,7 +533,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() -> an expected_body2.push(expected_permissions_msg_2); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); - assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); + assert_eq_without_metadata( + body2["input"].clone(), + serde_json::Value::Array(expected_body2), + ); Ok(()) } @@ -810,7 +823,10 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res expected_body2.push(expected_settings_update_msg); expected_body2.push(expected_env_msg_2); expected_body2.push(expected_user_message_2); - assert_eq!(body2["input"], serde_json::Value::Array(expected_body2)); + assert_eq_without_metadata( + body2["input"].clone(), + serde_json::Value::Array(expected_body2), + ); Ok(()) } @@ -818,7 +834,6 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); - use pretty_assertions::assert_eq; let server = start_mock_server().await; let req1 = mount_sse_once( @@ -940,7 +955,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); - assert_eq!(body1["input"], expected_input_1); + assert_eq_without_metadata(body1["input"].clone(), expected_input_1); let expected_user_message_2 = text_user_input("hello 2".to_string()); let expected_input_2 = serde_json::Value::Array(vec![ @@ -949,7 +964,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a expected_user_message_1, expected_user_message_2, ]); - assert_eq!(body2["input"], expected_input_2); + assert_eq_without_metadata(body2["input"].clone(), expected_input_2); Ok(()) } @@ -1079,7 +1094,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu expected_contextual_user_msg_1.clone(), expected_user_message_1.clone(), ]); - assert_eq!(body1["input"], expected_input_1); + assert_eq_without_metadata(body1["input"].clone(), expected_input_1); let body1_input = body1["input"].as_array().expect("input array"); let expected_settings_update_msg = body2["input"][body1_input.len()].clone(); @@ -1118,7 +1133,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu expected_env_update_msg, expected_user_message_2, ]); - assert_eq!(body2["input"], expected_input_2); + assert_eq_without_metadata(body2["input"].clone(), expected_input_2); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_debug_tests.rs b/codex-rs/core/tests/suite/prompt_debug_tests.rs index 0628f8ff98..74aeece04d 100644 --- a/codex-rs/core/tests/suite/prompt_debug_tests.rs +++ b/codex-rs/core/tests/suite/prompt_debug_tests.rs @@ -8,6 +8,7 @@ use codex_home::CodexHomeUserInstructionsProvider; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use core_test_support::responses::strip_metadata; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -51,7 +52,10 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> { phase: None, internal_chat_message_metadata_passthrough: None, }; - assert_eq!(input.last(), Some(&expected_user_message)); + assert_eq!( + input.last().cloned().map(strip_metadata), + Some(expected_user_message) + ); assert!(input.iter().any(|item| { let ResponseItem::Message { content, .. } = item else { return false; diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index b24f1c1654..c49e474965 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -28,6 +28,7 @@ use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; +use core_test_support::responses::strip_metadata_from_json; use core_test_support::skip_if_no_network; use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::local_selections; @@ -1085,8 +1086,8 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result .pop() .expect("child request"); assert_eq!( - child_request.inputs_of_type("agent_message"), - vec![json!({ + strip_metadata_from_json(Value::Array(child_request.inputs_of_type("agent_message"))), + Value::Array(vec![json!({ "type": "agent_message", "author": "/root", "recipient": "/root/worker", @@ -1100,7 +1101,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result "encrypted_content": encrypted_message, }, ], - })] + })]) ); Ok(()) @@ -1228,8 +1229,8 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( .pop() .expect("agent message request"); assert_eq!( - request.inputs_of_type("agent_message"), - vec![json!({ + strip_metadata_from_json(Value::Array(request.inputs_of_type("agent_message"))), + Value::Array(vec![json!({ "type": "agent_message", "author": "/root/worker", "recipient": "/root", @@ -1237,7 +1238,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( "type": "input_text", "text": notification, }], - })] + })]) ); Ok(()) diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index f7d9099508..e107013a78 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -915,6 +915,20 @@ pub struct InternalChatMessageMetadataPassthrough { pub turn_id: Option, } +impl InternalChatMessageMetadataPassthrough { + pub(crate) fn set_turn_id_if_missing(metadata: &mut Option, turn_id: &str) { + if turn_id.is_empty() + || metadata + .as_ref() + .and_then(|metadata| metadata.turn_id.as_deref()) + .is_some_and(|turn_id| !turn_id.is_empty()) + { + return; + } + metadata.get_or_insert_with(Self::default).turn_id = Some(turn_id.to_string()); + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { @@ -1120,13 +1134,8 @@ pub enum ResponseItem { #[ts(optional)] internal_chat_message_metadata_passthrough: Option, }, - // Compaction triggers are request controls, and the Responses API does not - // accept an `id` field for them. - CompactionTrigger { - #[serde(default, skip_serializing_if = "Option::is_none")] - #[ts(optional)] - internal_chat_message_metadata_passthrough: Option, - }, + // Compaction triggers are request controls, not durable response items. + CompactionTrigger {}, ContextCompaction { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1198,16 +1207,11 @@ impl ResponseItem { } /// Stamps the item with `turn_id` unless it already has a non-empty turn ID. - pub fn stamp_turn_id_if_missing(&mut self, turn_id: &str) { - if turn_id.is_empty() || self.turn_id().is_some() { - return; - } + pub fn set_turn_id_if_missing(&mut self, turn_id: &str) { let Some(metadata) = self.internal_chat_message_metadata_passthrough_mut() else { return; }; - metadata - .get_or_insert_with(InternalChatMessageMetadataPassthrough::default) - .turn_id = Some(turn_id.to_string()); + InternalChatMessageMetadataPassthrough::set_turn_id_if_missing(metadata, turn_id); } /// Removes internal chat message metadata passthrough before sending to a provider that does @@ -1274,15 +1278,11 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } - | Self::CompactionTrigger { - internal_chat_message_metadata_passthrough: metadata, - .. - } | Self::ContextCompaction { internal_chat_message_metadata_passthrough: metadata, .. } => metadata.as_ref(), - Self::Other => None, + Self::CompactionTrigger { .. } | Self::Other => None, } } @@ -1342,15 +1342,11 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } - | Self::CompactionTrigger { - internal_chat_message_metadata_passthrough: metadata, - .. - } | Self::ContextCompaction { internal_chat_message_metadata_passthrough: metadata, .. } => Some(metadata), - Self::Other => None, + Self::CompactionTrigger { .. } | Self::Other => None, } } } @@ -2206,23 +2202,23 @@ mod tests { }))?; assert_eq!(unknown_metadata, item); - item.stamp_turn_id_if_missing("turn-2"); + item.set_turn_id_if_missing("turn-2"); assert_eq!(item.turn_id(), Some("turn-1")); let mut empty_turn_id = response_item_with_passthrough_metadata(Some(passthrough_metadata(""))); - empty_turn_id.stamp_turn_id_if_missing("turn-1"); + empty_turn_id.set_turn_id_if_missing("turn-1"); assert_eq!(empty_turn_id.turn_id(), Some("turn-1")); let mut missing_turn_id = response_item_with_passthrough_metadata( /*internal_chat_message_metadata_passthrough*/ None, ); - missing_turn_id.stamp_turn_id_if_missing(""); - missing_turn_id.stamp_turn_id_if_missing("turn-1"); + missing_turn_id.set_turn_id_if_missing(""); + missing_turn_id.set_turn_id_if_missing("turn-1"); assert_eq!(missing_turn_id.turn_id(), Some("turn-1")); let mut other = ResponseItem::Other; - other.stamp_turn_id_if_missing("turn-1"); + other.set_turn_id_if_missing("turn-1"); assert_eq!(other.turn_id(), None); Ok(()) } @@ -3111,9 +3107,7 @@ mod tests { #[test] fn serializes_compaction_trigger_without_payload() -> Result<()> { - let item = ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - }; + let item = ResponseItem::CompactionTrigger {}; assert_eq!( serde_json::to_value(item)?, @@ -3124,37 +3118,13 @@ mod tests { Ok(()) } - #[test] - fn serializes_stamped_compaction_trigger_passthrough_metadata() -> Result<()> { - let mut item = ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - }; - item.stamp_turn_id_if_missing("turn-1"); - - assert_eq!( - serde_json::to_value(item)?, - serde_json::json!({ - "type": "compaction_trigger", - "internal_chat_message_metadata_passthrough": { - "turn_id": "turn-1", - }, - }) - ); - Ok(()) - } - #[test] fn deserializes_compaction_trigger_without_payload() -> Result<()> { let json = r#"{"type":"compaction_trigger"}"#; let item: ResponseItem = serde_json::from_str(json)?; - assert_eq!( - item, - ResponseItem::CompactionTrigger { - internal_chat_message_metadata_passthrough: None, - } - ); + assert_eq!(item, ResponseItem::CompactionTrigger {}); Ok(()) } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 600aa475bd..5a73ac4c90 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -38,6 +38,7 @@ use crate::models::AgentMessageInputContent; use crate::models::BaseInstructions; use crate::models::ContentItem; use crate::models::ImageDetail; +use crate::models::InternalChatMessageMetadataPassthrough; use crate::models::MessagePhase; use crate::models::PermissionProfile; use crate::models::ResponseInputItem; @@ -698,6 +699,9 @@ pub struct InterAgentCommunication { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] pub encrypted_content: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub internal_chat_message_metadata_passthrough: Option, pub trigger_turn: bool, } @@ -715,6 +719,7 @@ impl InterAgentCommunication { other_recipients, content, encrypted_content: None, + internal_chat_message_metadata_passthrough: None, trigger_turn, } } @@ -732,15 +737,25 @@ impl InterAgentCommunication { other_recipients, content: String::new(), encrypted_content: Some(encrypted_content), + internal_chat_message_metadata_passthrough: None, trigger_turn, } } + pub fn set_turn_id_if_missing(&mut self, turn_id: &str) { + InternalChatMessageMetadataPassthrough::set_turn_id_if_missing( + &mut self.internal_chat_message_metadata_passthrough, + turn_id, + ); + } + pub fn to_response_input_item(&self) -> ResponseInputItem { + let mut communication = self.clone(); + communication.internal_chat_message_metadata_passthrough = None; ResponseInputItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: serde_json::to_string(self).unwrap_or_default(), + text: serde_json::to_string(&communication).unwrap_or_default(), }], phase: Some(MessagePhase::Commentary), } @@ -775,7 +790,9 @@ impl InterAgentCommunication { author: self.author.to_string(), recipient: self.recipient.to_string(), content, - internal_chat_message_metadata_passthrough: None, + internal_chat_message_metadata_passthrough: self + .internal_chat_message_metadata_passthrough + .clone(), } } @@ -4339,21 +4356,26 @@ mod tests { #[test] fn inter_agent_communication_response_input_item_preserves_commentary_phase() { - let communication = InterAgentCommunication { + let mut communication = InterAgentCommunication { author: AgentPath::root(), recipient: AgentPath::root().join("reviewer").expect("recipient path"), other_recipients: vec![AgentPath::root().join("worker").expect("recipient path")], content: "review the diff".to_string(), encrypted_content: None, + internal_chat_message_metadata_passthrough: None, trigger_turn: true, }; + communication.set_turn_id_if_missing("turn-1"); + let mut serialized_communication = communication.clone(); + serialized_communication.internal_chat_message_metadata_passthrough = None; assert_eq!( communication.to_response_input_item(), ResponseInputItem::Message { role: "assistant".to_string(), content: vec![ContentItem::OutputText { - text: serde_json::to_string(&communication).expect("serialize communication"), + text: serde_json::to_string(&serialized_communication) + .expect("serialize communication"), }], phase: Some(MessagePhase::Commentary), } From c53b1dae09db40902c59f6a0d57d0dcc334926db Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 22 Jun 2026 16:45:23 -0700 Subject: [PATCH 59/86] [codex] Use tool search for MCP tools by default (#29486) ## Why MCP tools were only placed behind `tool_search` when a feature flag was enabled or when there were at least 100 tools. That made the model's tool flow depend on both rollout configuration and the number of installed tools. The searched-tool flow is now the intended behavior. Making it unconditional when the model and provider support it gives every supported setup the same behavior and lets us retire the feature flag safely. ## What changed - Defer all effective MCP tools when `tool_search` and namespaced tools are supported. - Keep exposing MCP tools directly when search cannot be used, so older or unsupported model/provider combinations still work. - Mark `tool_search_always_defer_mcp_tools` as removed and ignore old configured values. - Keep plugin filtering, app-only filtering, file handling, and MCP calls working through the searched-tool flow. ## Why many tests changed Many tests used to act as if the model could see MCP tools in its first request and call them immediately. That is no longer the real flow: the model first receives `tool_search`, searches for a tool, receives the matching MCP tool, and then calls it in the next request. The tests therefore needed an extra search step, and checks for tool names, descriptions, and input fields had to move from the first request to the search result. These are not separate product changes; they make the tests follow what the model will actually see after this change. The plugin tests still check which tools are allowed and where they came from, the file tests still check upload fields and behavior, and the MCP round-trip test still checks a successful call from start to finish. ## Tests - `just test -p codex-features` - Focused `codex-core` tests for MCP exposure and tool planning - `just test -p codex-core explicit_plugin_mentions` - `just test -p codex-core stdio_server_round_trip` - Focused `codex-core` tests for tool search, app-only tools, and MCP file uploads --- codex-rs/core/src/mcp_tool_exposure.rs | 11 +- codex-rs/core/src/mcp_tool_exposure_test.rs | 21 +-- codex-rs/core/src/tools/spec_plan.rs | 16 +- .../core/tests/suite/mcp_tool_exposure.rs | 4 - codex-rs/core/tests/suite/openai_file_mcp.rs | 32 +++- codex-rs/core/tests/suite/plugins.rs | 173 +++++++++--------- codex-rs/core/tests/suite/rmcp_client.rs | 39 ++-- codex-rs/core/tests/suite/search_tool.rs | 70 ++++--- codex-rs/features/src/lib.rs | 8 +- codex-rs/features/src/tests.rs | 19 ++ 10 files changed, 207 insertions(+), 186 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_exposure.rs b/codex-rs/core/src/mcp_tool_exposure.rs index d62880615f..fda2b04c9b 100644 --- a/codex-rs/core/src/mcp_tool_exposure.rs +++ b/codex-rs/core/src/mcp_tool_exposure.rs @@ -2,7 +2,6 @@ use std::collections::HashSet; use codex_connectors::AppToolPolicyEvaluator; use codex_connectors::AppToolPolicyInput; -use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo as McpToolInfo; use codex_mcp::tool_is_model_visible; @@ -11,8 +10,6 @@ use tracing::instrument; use crate::config::Config; use crate::connectors; -pub(crate) const DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD: usize = 100; - pub(crate) struct McpToolExposure { pub(crate) direct_tools: Vec, pub(crate) deferred_tools: Option>, @@ -34,13 +31,7 @@ pub(crate) fn build_mcp_tool_exposure( )); } - let should_defer = search_tool_enabled - && (config - .features - .enabled(Feature::ToolSearchAlwaysDeferMcpTools) - || deferred_tools.len() >= DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD); - - if !should_defer { + if !search_tool_enabled { return McpToolExposure { direct_tools: deferred_tools, deferred_tools: None, diff --git a/codex-rs/core/src/mcp_tool_exposure_test.rs b/codex-rs/core/src/mcp_tool_exposure_test.rs index 63a2e33f31..71c92b5580 100644 --- a/codex-rs/core/src/mcp_tool_exposure_test.rs +++ b/codex-rs/core/src/mcp_tool_exposure_test.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use std::sync::Arc; -use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_mcp::ToolInfo; use codex_tools::ToolName; @@ -95,12 +94,12 @@ fn with_visibility(mut tool: ToolInfo, visibility: &[&str]) -> ToolInfo { } #[tokio::test] -async fn directly_exposes_small_effective_tool_sets() { +async fn directly_exposes_effective_tool_sets_when_search_is_unavailable() { let config = test_config().await; - let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1); + let mcp_tools = numbered_mcp_tools(/*count*/ 2); let exposure = build_mcp_tool_exposure( - &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, + &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ false, ); assert_eq!(tool_names(&exposure.direct_tools), tool_names(&mcp_tools)); @@ -237,9 +236,9 @@ enabled = true } #[tokio::test] -async fn searches_large_effective_tool_sets() { +async fn defers_effective_tool_sets_when_search_is_available() { let config = test_config().await; - let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD); + let mcp_tools = numbered_mcp_tools(/*count*/ 2); let exposure = build_mcp_tool_exposure( &mcp_tools, /*connectors*/ None, &config, /*search_tool_enabled*/ true, @@ -249,17 +248,13 @@ async fn searches_large_effective_tool_sets() { let deferred_tools = exposure .deferred_tools .as_ref() - .expect("large tool sets should be discoverable through tool_search"); + .expect("MCP tools should be discoverable through tool_search"); assert_eq!(tool_names(deferred_tools), tool_names(&mcp_tools)); } #[tokio::test] -async fn always_defer_feature_defers_apps_too() { - let mut config = test_config().await; - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); +async fn defers_apps_and_non_app_mcp_tools() { + let config = test_config().await; let mcp_tools = vec![ make_mcp_tool( "rmcp", diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index c83422f1a0..51cd436503 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -326,7 +326,7 @@ fn hosted_model_tool_specs(context: &CoreToolPlanContext<'_>) -> Vec { } pub(crate) fn search_tool_enabled(turn_context: &TurnContext) -> bool { - turn_context.model_info.supports_search_tool + turn_context.model_info.supports_search_tool && namespace_tools_enabled(turn_context) } pub(crate) fn tool_suggest_enabled(turn_context: &TurnContext) -> bool { @@ -820,12 +820,11 @@ fn add_collaboration_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mu } else { let agent_type_description = agent_type_description(turn_context, context.default_agent_type_description); - let exposure = - if search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context) { - ToolExposure::Deferred - } else { - ToolExposure::Direct - }; + let exposure = if search_tool_enabled(turn_context) { + ToolExposure::Deferred + } else { + ToolExposure::Direct + }; planned_tools.add_with_exposure( SpawnAgentHandler::new(SpawnAgentToolOptions { available_models: turn_context.available_models.clone(), @@ -943,7 +942,7 @@ fn append_tool_search_executor( planned_tools: &mut PlannedTools, ) { let turn_context = context.turn_context; - if !(search_tool_enabled(turn_context) && namespace_tools_enabled(turn_context)) { + if !search_tool_enabled(turn_context) { return; } @@ -990,7 +989,6 @@ fn append_extension_tool_executors( reserved_tool_names.insert(ToolName::plain(codex_code_mode::WAIT_TOOL_NAME)); } if search_tool_enabled(turn_context) - && namespace_tools_enabled(turn_context) && planned_tools .runtimes() .iter() diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index 44f2f87c3e..b86862edc2 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -35,10 +35,6 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> .features .enable(Feature::CodeModeOnly) .expect("test config should allow feature update"); - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); config.code_mode.direct_only_tool_namespaces = vec![SEARCH_CALENDAR_NAMESPACE.to_string()]; }); diff --git a/codex-rs/core/tests/suite/openai_file_mcp.rs b/codex-rs/core/tests/suite/openai_file_mcp.rs index edc14cead0..9068f5a566 100644 --- a/codex-rs/core/tests/suite/openai_file_mcp.rs +++ b/codex-rs/core/tests/suite/openai_file_mcp.rs @@ -22,7 +22,9 @@ use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; +use core_test_support::responses::ev_tool_search_call; use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::namespace_child_tool; use core_test_support::responses::sse; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::TestCodex; @@ -142,18 +144,29 @@ async fn run_extract_turn(test: &TestCodex, server: &MockServer) -> Result std::path::PathBuf { home.path().join("plugins/cache/test/sample/local") @@ -145,21 +155,53 @@ async fn build_apps_enabled_plugin_test_codex( .expect("create new conversation")) } -fn tool_names(body: &serde_json::Value) -> Vec { - body.get("tools") - .and_then(serde_json::Value::as_array) - .map(|tools| { - tools - .iter() - .filter_map(|tool| { - tool.get("name") - .or_else(|| tool.get("type")) - .and_then(serde_json::Value::as_str) - .map(str::to_string) - }) - .collect() - }) - .unwrap_or_default() +async fn mount_plugin_tool_search_turn(server: &MockServer) -> ResponseMock { + mount_sse_sequence( + server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_tool_search_call( + PLUGIN_APP_SEARCH_CALL_ID, + &serde_json::json!({"query": "create calendar event"}), + ), + ev_tool_search_call( + PLUGIN_MCP_SEARCH_CALL_ID, + &serde_json::json!({"query": "echo"}), + ), + ev_completed("resp-1"), + ]), + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ], + ) + .await +} + +fn assert_plugin_provenance(tool: &serde_json::Value) { + let description = tool + .get("description") + .and_then(serde_json::Value::as_str) + .expect("plugin tool description should be present"); + assert!( + description.contains("This tool is part of plugin `sample`."), + "expected plugin provenance in tool description: {description:?}" + ); +} + +fn searched_plugin_tools( + request: &ResponsesRequest, +) -> (Option, Option) { + let app_output = request.tool_search_output(PLUGIN_APP_SEARCH_CALL_ID); + let mcp_output = request.tool_search_output(PLUGIN_MCP_SEARCH_CALL_ID); + ( + namespace_child_tool( + &app_output, + SAMPLE_PLUGIN_APP_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + ) + .cloned(), + namespace_child_tool(&mcp_output, SAMPLE_PLUGIN_MCP_NAMESPACE, "echo").cloned(), + ) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -237,11 +279,7 @@ async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; - let mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - ) - .await; + let mock = mount_plugin_tool_search_turn(&server).await; let codex_home = Arc::new(TempDir::new()?); let rmcp_test_server_bin = match stdio_server_bin() { @@ -275,7 +313,8 @@ async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let request = mock.single_request(); + let requests = mock.requests(); + let request = &requests[0]; let developer_messages = request.message_input_texts("developer"); assert!( developer_messages @@ -295,28 +334,18 @@ async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> .any(|text| text.contains("Apps from this plugin")), "expected visible plugin app guidance: {developer_messages:?}" ); - let request_body = request.body_json(); - let request_tools = tool_names(&request_body); assert!( - request_tools - .iter() - .any(|name| name == "mcp__codex_apps__google_calendar"), - "expected plugin app tools to become visible for this turn: {request_tools:?}" + request + .tool_by_name(SAMPLE_PLUGIN_MCP_NAMESPACE, "echo") + .is_none(), + "plugin MCP tool should not leak into the request for ChatGPT auth" ); + let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]); + let calendar_tool = calendar_tool.expect("plugin app tool should be searchable"); + assert_plugin_provenance(&calendar_tool); assert!( - request.tool_by_name("mcp__sample", "echo").is_none(), - "expected plugin MCP tool to be suppressed for ChatGPT auth" - ); - let calendar_tool = request - .tool_by_name("mcp__codex_apps__google_calendar", "_create_event") - .expect("plugin app tool should be present"); - let calendar_description = calendar_tool - .get("description") - .and_then(serde_json::Value::as_str) - .expect("plugin app tool description should be present"); - assert!( - calendar_description.contains("This tool is part of plugin `sample`."), - "expected plugin app provenance in tool description: {calendar_description:?}" + echo_tool.is_none(), + "plugin MCP tool should be suppressed for ChatGPT auth" ); Ok(()) @@ -327,11 +356,7 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> skip_if_no_network!(Ok(())); let server = start_mock_server().await; let apps_server = AppsTestServer::mount_with_connector_name(&server, "Google Calendar").await?; - let mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - ) - .await; + let mock = mount_plugin_tool_search_turn(&server).await; let codex_home = Arc::new(TempDir::new()?); let rmcp_test_server_bin = match stdio_server_bin() { @@ -365,7 +390,8 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let request = mock.single_request(); + let requests = mock.requests(); + let request = &requests[0]; let developer_messages = request.message_input_texts("developer"); assert!( developer_messages @@ -379,25 +405,13 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> .any(|text| text.contains("Apps from this plugin")), "expected plugin app guidance: {developer_messages:?}" ); - let request_body = request.body_json(); - let request_tools = tool_names(&request_body); + let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]); assert!( - request_tools - .iter() - .any(|name| name == "mcp__codex_apps__google_calendar"), - "expected plugin app tools to become visible for this turn: {request_tools:?}" - ); - let echo_tool = request - .tool_by_name("mcp__sample", "echo") - .expect("plugin MCP tool should remain present"); - let echo_description = echo_tool - .get("description") - .and_then(serde_json::Value::as_str) - .expect("plugin MCP tool description should be present"); - assert!( - echo_description.contains("This tool is part of plugin `sample`."), - "expected plugin MCP provenance in tool description: {echo_description:?}" + calendar_tool.is_some(), + "plugin app tool should be searchable" ); + let echo_tool = echo_tool.expect("plugin MCP tool should remain searchable"); + assert_plugin_provenance(&echo_tool); Ok(()) } @@ -406,11 +420,7 @@ async fn explicit_plugin_mentions_keep_non_conflicting_mcp_for_chatgpt_auth() -> async fn explicit_plugin_mentions_use_mcp_for_api_key_dual_surface_plugins() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let mock = mount_sse_once( - &server, - sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), - ) - .await; + let mock = mount_plugin_tool_search_turn(&server).await; let codex_home = Arc::new(TempDir::new()?); let rmcp_test_server_bin = match stdio_server_bin() { @@ -454,7 +464,8 @@ async fn explicit_plugin_mentions_use_mcp_for_api_key_dual_surface_plugins() -> .await?; wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let request = mock.single_request(); + let requests = mock.requests(); + let request = &requests[0]; let developer_messages = request.message_input_texts("developer"); assert!( developer_messages @@ -474,25 +485,19 @@ async fn explicit_plugin_mentions_use_mcp_for_api_key_dual_surface_plugins() -> .any(|text| text.contains("Apps from this plugin")), "expected plugin app guidance to be suppressed for API-key auth: {developer_messages:?}" ); - let request_body = request.body_json(); - let request_tools = tool_names(&request_body); assert!( - !request_tools - .iter() - .any(|name| name == "mcp__codex_apps__google_calendar"), - "expected plugin app tools to be hidden for API-key auth: {request_tools:?}" + request + .tool_by_name(SAMPLE_PLUGIN_APP_NAMESPACE, SEARCH_CALENDAR_CREATE_TOOL) + .is_none(), + "plugin app tool should not leak into the request for API-key auth" ); - let echo_tool = request - .tool_by_name("mcp__sample", "echo") - .expect("plugin MCP tool should be present"); - let echo_description = echo_tool - .get("description") - .and_then(serde_json::Value::as_str) - .expect("plugin MCP tool description should be present"); + let (calendar_tool, echo_tool) = searched_plugin_tools(&requests[1]); assert!( - echo_description.contains("This tool is part of plugin `sample`."), - "expected plugin MCP provenance in tool description: {echo_description:?}" + calendar_tool.is_none(), + "plugin app tool should be hidden for API-key auth" ); + let echo_tool = echo_tool.expect("plugin MCP tool should be searchable"); + assert_plugin_provenance(&echo_tool); Ok(()) } diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2b4e09bd1c..236fbee4e5 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -539,20 +539,33 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { let server = responses::start_mock_server().await; let call_id = "call-123"; + let search_call_id = "search-rmcp-echo"; let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); - let call_mock = mount_sse_once( + mount_sse_once( &server, responses::sse(vec![ responses::ev_response_created("resp-1"), + responses::ev_tool_search_call( + search_call_id, + &json!({"query": "echo message and environment data"}), + ), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let call_mock = mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-2"), responses::ev_function_call_with_namespace( call_id, &namespace, "echo", "{\"message\":\"ping\"}", ), - responses::ev_completed("resp-1"), + responses::ev_completed("resp-2"), ]), ) .await; @@ -560,7 +573,7 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { &server, responses::sse(vec![ responses::ev_assistant_message("msg-1", "rmcp echo tool completed successfully."), - responses::ev_completed("resp-2"), + responses::ev_completed("resp-3"), ]), ) .await; @@ -645,13 +658,14 @@ async fn stdio_server_round_trip() -> anyhow::Result<()> { wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; - let output_item = final_mock.single_request().function_call_output(call_id); - let request = call_mock.single_request(); + let search_output = call_mock + .single_request() + .tool_search_output(search_call_id); assert!( - request.tool_by_name(&namespace, "echo").is_some(), - "direct MCP tool should be sent as a namespace child tool: {:?}", - request.body_json() + responses::namespace_child_tool(&search_output, &namespace, "echo").is_some(), + "tool_search should surface the RMCP echo tool: {search_output:?}" ); + let output_item = final_mock.single_request().function_call_output(call_id); let output_text = output_item .get("output") @@ -860,7 +874,7 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let server_name = "rmcp"; let namespace = format!("mcp__{server_name}"); - let call_mock = mount_sse_once( + mount_sse_once( &server, responses::sse(vec![ responses::ev_response_created("resp-1"), @@ -903,13 +917,6 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> ) .await?; - let request = call_mock.single_request(); - assert!( - request.tool_by_name(&namespace, "sandbox_meta").is_some(), - "direct MCP tool should be sent as a namespace child tool: {:?}", - request.body_json() - ); - let output_item = final_mock.single_request().function_call_output(call_id); let output_text = output_item .get("output") diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 7228547725..af67827927 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -4,7 +4,6 @@ use anyhow::Result; use codex_config::types::McpServerConfig; use codex_config::types::McpServerTransportConfig; -use codex_features::Feature; use codex_login::CodexAuth; use codex_protocol::dynamic_tools::DynamicToolCallOutputContentItem; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; @@ -30,7 +29,6 @@ use core_test_support::apps_test_server::SEARCH_CALENDAR_APP_ONLY_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_LIST_TOOL; use core_test_support::apps_test_server::SEARCH_CALENDAR_NAMESPACE; -use core_test_support::apps_test_server::apps_enabled_builder; use core_test_support::apps_test_server::configure_search_capable_apps; use core_test_support::apps_test_server::configure_search_capable_model; use core_test_support::apps_test_server::recorded_apps_tool_call_by_call_id; @@ -180,7 +178,7 @@ async fn search_tool_enabled_by_default_adds_tool_search() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { +async fn small_app_tool_sets_are_deferred_by_default() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -195,13 +193,7 @@ async fn always_defer_feature_hides_small_app_tool_sets() -> Result<()> { ) .await; - let mut builder = - configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -232,30 +224,42 @@ async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> R let server = start_mock_server().await; let apps_server = AppsTestServer::mount_with_app_only_tool(&server, AppsTestToolLoading::Direct).await?; + let search_call_id = "app-only-search"; let call_id = "app-only-direct-call"; let mock = mount_sse_sequence( &server, vec![ sse(vec![ ev_response_created("resp-1"), + ev_tool_search_call( + search_call_id, + &json!({ + "query": "create calendar event", + "limit": 8, + }), + ), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), ev_function_call_with_namespace( call_id, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_APP_ONLY_TOOL, "{}", ), - ev_completed("resp-1"), + ev_completed("resp-2"), ]), sse(vec![ - ev_response_created("resp-2"), + ev_response_created("resp-3"), ev_assistant_message("msg-1", "done"), - ev_completed("resp-2"), + ev_completed("resp-3"), ]), ], ) .await; - let mut builder = apps_enabled_builder(apps_server.chatgpt_base_url.clone()); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( "Try to call the app-only calendar tool.", @@ -266,25 +270,25 @@ async fn app_only_tools_are_not_visible_or_runnable_by_direct_model_calls() -> R let requests = mock.requests(); assert!( - namespace_child_tool( - &requests[0].body_json(), + tool_search_output_has_namespace_child( + &requests[1], + search_call_id, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_CREATE_TOOL - ) - .is_some(), - "visible tool from the app-only tool's connector should be declared" + ), + "visible tool from the app-only tool's connector should be searchable" ); assert!( - namespace_child_tool( - &requests[0].body_json(), + !tool_search_output_has_namespace_child( + &requests[1], + search_call_id, SEARCH_CALENDAR_NAMESPACE, SEARCH_CALENDAR_APP_ONLY_TOOL - ) - .is_none(), - "app-only tool should not be declared to a direct model" + ), + "app-only tool should not be returned to the model by tool_search" ); assert!( - requests[1] + requests[2] .function_call_output(call_id) .get("output") .and_then(Value::as_str) @@ -423,7 +427,7 @@ async fn search_tool_hides_apps_tools_without_search() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn explicit_app_mentions_respect_always_defer() -> Result<()> { +async fn explicit_app_mentions_leave_app_tools_deferred() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -438,13 +442,7 @@ async fn explicit_app_mentions_respect_always_defer() -> Result<()> { ) .await; - let mut builder = - configured_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); - }); + let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()); let test = builder.build(&server).await?; test.submit_turn_with_approval_and_permission_profile( @@ -458,7 +456,7 @@ async fn explicit_app_mentions_respect_always_defer() -> Result<()> { let tools = tool_names(&body); assert!( tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), - "explicit app mentions should leave app tools deferred when always-defer is active: {tools:?}" + "explicit app mentions should leave app tools deferred: {tools:?}" ); assert!( namespace_child_tool( @@ -1214,10 +1212,6 @@ async fn tool_search_surfaced_mcp_tool_errors_are_returned_to_model() -> Result< let rmcp_test_server_bin = stdio_server_bin()?; let mut builder = configured_builder(apps_server.chatgpt_base_url.clone()).with_config(move |config| { - config - .features - .enable(Feature::ToolSearchAlwaysDeferMcpTools) - .expect("test config should allow feature update"); let mut servers = config.mcp_servers.get().clone(); servers.insert( "rmcp".to_string(), diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 210b8689af..86fdd2e29e 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -157,7 +157,7 @@ pub enum Feature { AppsMcpPathOverride, /// Removed compatibility flag retained as a no-op now that tool_search is always enabled. ToolSearch, - /// Always defer MCP tools behind tool_search instead of exposing small sets directly. + /// Removed compatibility flag. MCP tools are always deferred when tool_search is available. ToolSearchAlwaysDeferMcpTools, /// Expose MCP model-visible namespaces without the legacy `mcp__` prefix. NonPrefixedMcpToolNames, @@ -473,7 +473,7 @@ impl Features { "apply_patch_freeform" => { continue; } - "tool_search" | "apps_mcp_path_override" => { + "tool_search" | "tool_search_always_defer_mcp_tools" | "apps_mcp_path_override" => { continue; } "image_detail_original" | "resize_all_images" => { @@ -1074,8 +1074,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolSearchAlwaysDeferMcpTools, key: "tool_search_always_defer_mcp_tools", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Removed, + default_enabled: true, }, FeatureSpec { id: Feature::NonPrefixedMcpToolNames, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index b9902320bb..69fd59e13a 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -561,6 +561,25 @@ fn from_sources_ignores_removed_plugin_hooks_feature_key() { assert_eq!(features, Features::with_defaults()); } +#[test] +fn from_sources_ignores_removed_tool_search_always_defer_mcp_tools_feature_key() { + let features_toml = FeaturesToml::from(BTreeMap::from([( + "tool_search_always_defer_mcp_tools".to_string(), + false, + )])); + + let features = Features::from_sources( + FeatureConfigSource { + features: Some(&features_toml), + ..Default::default() + }, + FeatureConfigSource::default(), + FeatureOverrides::default(), + ); + + assert_eq!(features, Features::with_defaults()); +} + #[test] fn multi_agent_v2_feature_config_deserializes_boolean_toggle() { let features: FeaturesToml = toml::from_str( From 11fab432be5a873fea97688ef67e8af896f5ea8c Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 22 Jun 2026 17:02:33 -0700 Subject: [PATCH 60/86] path-uri: clarify host-native path conversion (#29501) ## Why Downstream refactors are producing confusing code with this functionality having a very generic name. Encoding the specific conversion approach in the method name makes it clearer. ## What Rename `PathUri::from_path` to `PathUri::from_host_native_path` and update its Rust call sites. --- codex-rs/apply-patch/src/invocation.rs | 28 +++---- codex-rs/apply-patch/src/lib.rs | 38 +++++----- codex-rs/apply-patch/src/parser.rs | 2 +- codex-rs/core/src/apply_patch_tests.rs | 2 +- codex-rs/core/src/tools/events.rs | 6 +- codex-rs/core/src/turn_diff_tracker_tests.rs | 2 +- codex-rs/core/tests/common/test_codex.rs | 8 +- codex-rs/core/tests/suite/agents_md.rs | 35 ++++----- codex-rs/core/tests/suite/apply_patch_cli.rs | 12 +-- codex-rs/core/tests/suite/network_approval.rs | 2 +- codex-rs/core/tests/suite/remote_env.rs | 47 ++++++------ codex-rs/core/tests/suite/rmcp_client.rs | 2 +- codex-rs/core/tests/suite/skills.rs | 4 +- codex-rs/core/tests/suite/unified_exec.rs | 8 +- codex-rs/core/tests/suite/view_image.rs | 10 +-- codex-rs/exec-server/src/environment.rs | 12 ++- codex-rs/exec-server/src/fs_helper.rs | 5 +- codex-rs/exec-server/src/local_process.rs | 3 +- codex-rs/exec-server/src/protocol.rs | 4 +- .../src/server/file_system_handler.rs | 7 +- .../exec-server/src/server/handler/tests.rs | 3 +- codex-rs/exec-server/src/server/processor.rs | 3 +- codex-rs/exec-server/tests/exec_process.rs | 24 +++--- codex-rs/exec-server/tests/file_stream.rs | 26 ++++--- .../exec-server/tests/file_system/shared.rs | 76 ++++++++++++------- .../exec-server/tests/file_system_unix.rs | 59 ++++++++------ .../exec-server/tests/file_system_windows.rs | 9 ++- codex-rs/exec-server/tests/relay.rs | 4 +- .../rmcp-client/src/stdio_server_launcher.rs | 2 +- codex-rs/utils/path-uri/src/lib.rs | 2 +- codex-rs/utils/path-uri/src/tests.rs | 3 +- 31 files changed, 250 insertions(+), 198 deletions(-) diff --git a/codex-rs/apply-patch/src/invocation.rs b/codex-rs/apply-patch/src/invocation.rs index de9c735c59..8ac2084e4f 100644 --- a/codex-rs/apply-patch/src/invocation.rs +++ b/codex-rs/apply-patch/src/invocation.rs @@ -500,7 +500,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -517,7 +517,7 @@ mod tests { assert_matches!( maybe_parse_apply_patch_verified( &args, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -752,7 +752,7 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -792,7 +792,7 @@ PATCH"#, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -832,7 +832,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &PathUri::from_path(session_dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -844,7 +844,7 @@ PATCH"#, result, MaybeApplyPatchVerified::Body(ApplyPatchAction { changes: HashMap::from([( - PathUri::from_path(session_dir.path().join(relative_path)) + PathUri::from_host_native_path(session_dir.path().join(relative_path)) .expect("absolute test path"), ApplyPatchFileChange::Update { unified_diff: r#"@@ -1 +1 @@ @@ -857,7 +857,8 @@ PATCH"#, }, )]), patch: argv[1].clone(), - cwd: PathUri::from_path(session_dir.path()).expect("absolute test path"), + cwd: PathUri::from_host_native_path(session_dir.path()) + .expect("absolute test path"), }) ); } @@ -887,7 +888,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &PathUri::from_path(session_dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) @@ -902,8 +903,8 @@ PATCH"#, worktree_dir.as_path() ); - let source_path = - PathUri::from_path(worktree_dir.join(source_name)).expect("absolute test path"); + let source_path = PathUri::from_host_native_path(worktree_dir.join(source_name)) + .expect("absolute test path"); let change = action .changes() .get(&source_path) @@ -912,7 +913,8 @@ PATCH"#, match change { ApplyPatchFileChange::Update { move_path, .. } => { let expected_move_path = - PathUri::from_path(worktree_dir.join(dest_name)).expect("absolute test path"); + PathUri::from_host_native_path(worktree_dir.join(dest_name)) + .expect("absolute test path"); assert_eq!(move_path.as_ref(), Some(&expected_move_path)); } other => panic!("expected update change, got {other:?}"), @@ -923,7 +925,7 @@ PATCH"#, async fn test_unreadable_destinations_still_verify() { let session_dir = tempdir().unwrap(); fs::write(session_dir.path().join("binary.dat"), [0xff, 0xfe, 0xfd]).unwrap(); - let cwd = PathUri::from_path(session_dir.path()).expect("absolute test path"); + let cwd = PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"); let add_argv = vec![ "apply_patch".to_string(), "*** Begin Patch\n*** Add File: binary.dat\n+text\n*** End Patch".to_string(), @@ -966,7 +968,7 @@ PATCH"#, let result = maybe_parse_apply_patch_verified( &argv, - &PathUri::from_path(session_dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(session_dir.path()).expect("absolute test path"), LOCAL_FS.as_ref(), /*sandbox*/ None, ) diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index f27610d6cc..81ad1f0781 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -913,7 +913,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -937,7 +937,7 @@ mod tests { #[tokio::test] async fn test_apply_patch_hunks_accept_relative_and_absolute_paths() { let dir = tempdir().unwrap(); - let cwd = PathUri::from_path(dir.path()).expect("absolute test path"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path"); let relative_add = dir.path().join("relative-add.txt"); let absolute_add = dir.path().join("absolute-add.txt"); let relative_delete = dir.path().join("relative-delete.txt"); @@ -1016,7 +1016,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1052,7 +1052,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1092,7 +1092,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1136,7 +1136,7 @@ mod tests { let mut stderr = Vec::new(); let failure = apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1196,7 +1196,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1254,7 +1254,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1298,7 +1298,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1341,7 +1341,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1391,7 +1391,7 @@ mod tests { [Hunk::UpdateFile { chunks, .. }] => chunks, _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( &path_uri, update_file_chunks, @@ -1439,7 +1439,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( &resolved_path, chunks, @@ -1485,7 +1485,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let resolved_path = PathUri::from_path(&path).expect("absolute test path"); + let resolved_path = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks( &resolved_path, chunks, @@ -1529,7 +1529,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -1580,7 +1580,7 @@ mod tests { _ => panic!("Expected a single UpdateFile hunk"), }; - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let diff = unified_diff_from_chunks(&path_uri, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None) .await @@ -1610,7 +1610,7 @@ mod tests { let mut stderr = Vec::new(); apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1648,7 +1648,7 @@ g let mut stderr = Vec::new(); let result = apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), @@ -1667,7 +1667,7 @@ g let dir = tempdir().unwrap(); let path = dir.path().join("binary.dat"); fs::write(dir.path().join("source.txt"), "before\n").unwrap(); - let cwd = PathUri::from_path(dir.path()).expect("absolute test path"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute test path"); for patch in [ wrap_patch("*** Add File: binary.dat\n+text"), @@ -1705,7 +1705,7 @@ g let mut stderr = Vec::new(); let delta = apply_patch( &patch, - &PathUri::from_path(dir.path()).expect("absolute test path"), + &PathUri::from_host_native_path(dir.path()).expect("absolute test path"), &mut stdout, &mut stderr, LOCAL_FS.as_ref(), diff --git a/codex-rs/apply-patch/src/parser.rs b/codex-rs/apply-patch/src/parser.rs index 04e0053a3a..ec2d97c477 100644 --- a/codex-rs/apply-patch/src/parser.rs +++ b/codex-rs/apply-patch/src/parser.rs @@ -480,7 +480,7 @@ fn test_parse_patch_accepts_relative_and_absolute_hunk_paths() { #[test] fn test_hunk_resolve_path_accepts_relative_and_absolute_paths() { let cwd_dir = tempfile::tempdir().unwrap(); - let cwd = PathUri::from_path(cwd_dir.path()).unwrap(); + let cwd = PathUri::from_host_native_path(cwd_dir.path()).unwrap(); let absolute_dir = tempfile::tempdir().unwrap(); let absolute_add = absolute_dir.path().join("absolute-add.py").abs(); let absolute_delete = absolute_dir.path().join("absolute-delete.py").abs(); diff --git a/codex-rs/core/src/apply_patch_tests.rs b/codex-rs/core/src/apply_patch_tests.rs index dfc48f8a63..d845ca92b4 100644 --- a/codex-rs/core/src/apply_patch_tests.rs +++ b/codex-rs/core/src/apply_patch_tests.rs @@ -8,7 +8,7 @@ use tempfile::tempdir; fn convert_apply_patch_maps_add_variant() { let tmp = tempdir().expect("tmp"); let path = tmp.path().join("a.txt"); - let path_uri = PathUri::from_path(&path).expect("absolute test path"); + let path_uri = PathUri::from_host_native_path(&path).expect("absolute test path"); let action = ApplyPatchAction::new_add_for_test(&path_uri, "hello".to_string()); let got = convert_apply_patch_to_protocol(&action); diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 34c41d835d..757125a03d 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -645,7 +645,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = codex_apply_patch::apply_patch( @@ -729,7 +729,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd"); for patch in [ "*** Begin Patch\n*** Add File: a.txt\n+one\n*** End Patch", @@ -782,7 +782,7 @@ mod tests { make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await; let tracker = Arc::new(Mutex::new(TurnDiffTracker::new())); let dir = tempdir().expect("tempdir"); - let cwd = PathUri::from_path(dir.path()).expect("absolute cwd"); + let cwd = PathUri::from_host_native_path(dir.path()).expect("absolute cwd"); let mut stdout = Vec::new(); let mut stderr = Vec::new(); let delta = codex_apply_patch::apply_patch( diff --git a/codex-rs/core/src/turn_diff_tracker_tests.rs b/codex-rs/core/src/turn_diff_tracker_tests.rs index 9c58de913b..5f984a2c4f 100644 --- a/codex-rs/core/src/turn_diff_tracker_tests.rs +++ b/codex-rs/core/src/turn_diff_tracker_tests.rs @@ -18,7 +18,7 @@ fn git_blob_sha1_hex(data: &str) -> String { } async fn apply_verified_patch(root: &Path, patch: &str) -> AppliedPatchDelta { - let cwd = PathUri::from_path(root).expect("absolute tempdir path"); + let cwd = PathUri::from_host_native_path(root).expect("absolute tempdir path"); let argv = vec!["apply_patch".to_string(), patch.to_string()]; match codex_apply_patch::maybe_parse_apply_patch_verified( &argv, diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index d843e0097b..8784a4d4a0 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -998,7 +998,7 @@ impl TestCodexHarness { ) -> Result<()> { let abs_path = self.path_abs(rel); if let Some(parent) = abs_path.parent() { - let parent_uri = PathUri::from_path(&parent)?; + let parent_uri = PathUri::from_host_native_path(&parent)?; self.test .fs() .create_directory( @@ -1008,7 +1008,7 @@ impl TestCodexHarness { ) .await?; } - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; self.test .fs() .write_file( @@ -1022,7 +1022,7 @@ impl TestCodexHarness { pub async fn read_file_text(&self, rel: impl AsRef) -> Result { let path = self.path_abs(rel); - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; Ok(self .test .fs() @@ -1032,7 +1032,7 @@ impl TestCodexHarness { pub async fn create_dir_all(&self, rel: impl AsRef) -> Result<()> { let path = self.path_abs(rel); - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; self.test .fs() .create_directory( diff --git a/codex-rs/core/tests/suite/agents_md.rs b/codex-rs/core/tests/suite/agents_md.rs index 498a666079..9094461aae 100644 --- a/codex-rs/core/tests/suite/agents_md.rs +++ b/codex-rs/core/tests/suite/agents_md.rs @@ -141,8 +141,8 @@ async fn agents_override_is_preferred_over_agents_md() -> Result<()> { agents_instructions(test_codex().with_workspace_setup(|cwd, fs| async move { let agents_md = cwd.join("AGENTS.md"); let override_md = cwd.join("AGENTS.override.md"); - let agents_md_uri = PathUri::from_path(&agents_md)?; - let override_md_uri = PathUri::from_path(&override_md)?; + let agents_md_uri = PathUri::from_host_native_path(&agents_md)?; + let override_md_uri = PathUri::from_host_native_path(&override_md)?; fs.write_file(&agents_md_uri, b"base doc".to_vec(), /*sandbox*/ None) .await?; fs.write_file( @@ -177,8 +177,8 @@ async fn configured_fallback_is_used_when_agents_candidate_is_directory() -> Res .with_workspace_setup(|cwd, fs| async move { let agents_dir = cwd.join("AGENTS.md"); let fallback = cwd.join("WORKFLOW.md"); - let agents_dir_uri = PathUri::from_path(&agents_dir)?; - let fallback_uri = PathUri::from_path(&fallback)?; + let agents_dir_uri = PathUri::from_host_native_path(&agents_dir)?; + let fallback_uri = PathUri::from_host_native_path(&fallback)?; fs.create_directory( &agents_dir_uri, CreateDirectoryOptions { recursive: true }, @@ -220,10 +220,10 @@ async fn agents_docs_are_concatenated_from_project_root_to_cwd() -> Result<()> { let root_agents = root.join("AGENTS.md"); let git_marker = root.join(".git"); let nested_agents = nested.join("AGENTS.md"); - let nested_uri = PathUri::from_path(&nested)?; - let root_agents_uri = PathUri::from_path(&root_agents)?; - let git_marker_uri = PathUri::from_path(&git_marker)?; - let nested_agents_uri = PathUri::from_path(&nested_agents)?; + let nested_uri = PathUri::from_host_native_path(&nested)?; + let root_agents_uri = PathUri::from_host_native_path(&root_agents)?; + let git_marker_uri = PathUri::from_host_native_path(&git_marker)?; + let nested_agents_uri = PathUri::from_host_native_path(&nested_agents)?; fs.create_directory( &nested_uri, @@ -363,7 +363,7 @@ async fn selected_environment_sources_match_model_visible_instructions() -> Resu let mut builder = test_codex() .with_home(home) .with_workspace_setup(|cwd, fs| async move { - let agents_md_uri = PathUri::from_path(cwd.join("AGENTS.md"))?; + let agents_md_uri = PathUri::from_host_native_path(cwd.join("AGENTS.md"))?; fs.write_file( &agents_md_uri, b"project doc".to_vec(), @@ -420,7 +420,8 @@ async fn loads_user_instructions_without_a_primary_environment() -> Result<()> { .with_home(Arc::clone(&home)) .with_user_instructions_provider(provider.clone()) .with_workspace_setup(|cwd, fs| async move { - let project_agents_uri = PathUri::from_path(cwd.join(GLOBAL_AGENTS_FILENAME))?; + let project_agents_uri = + PathUri::from_host_native_path(cwd.join(GLOBAL_AGENTS_FILENAME))?; fs.write_file( &project_agents_uri, PROJECT_INSTRUCTIONS.as_bytes().to_vec(), @@ -505,7 +506,7 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re let mut builder = test_codex() .with_home(Arc::clone(&home)) .with_workspace_setup(|cwd, fs| async move { - let agents_md_uri = PathUri::from_path(cwd.join("AGENTS.md"))?; + let agents_md_uri = PathUri::from_host_native_path(cwd.join("AGENTS.md"))?; fs.write_file( &agents_md_uri, PROJECT_INSTRUCTIONS.as_bytes().to_vec(), @@ -534,7 +535,7 @@ async fn fresh_thread_composes_global_before_project_and_reports_sources() -> Re )?; test.fs() .write_file( - &PathUri::from_path(&project_source)?, + &PathUri::from_host_native_path(&project_source)?, NEW_PROJECT_INSTRUCTIONS.as_bytes().to_vec(), /*sandbox*/ None, ) @@ -631,7 +632,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho .with_user_instructions_provider(provider.clone()) .with_workspace_setup(|cwd, fs| async move { fs.write_file( - &PathUri::from_path(cwd.join(GLOBAL_AGENTS_FILENAME))?, + &PathUri::from_host_native_path(cwd.join(GLOBAL_AGENTS_FILENAME))?, b"remote project instructions".to_vec(), /*sandbox*/ None, ) @@ -658,7 +659,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho }, TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: PathUri::from_path(local_root.path())?, + cwd: PathUri::from_host_native_path(local_root.path())?, }, ], thread_extension_init: Default::default(), @@ -671,7 +672,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho vec![ PathUri::from_abs_path(&global_source), PathUri::from_abs_path(&remote_source), - PathUri::from_path(&local_source)?, + PathUri::from_host_native_path(&local_source)?, ] ); @@ -684,7 +685,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho )?; test.fs() .write_file( - &PathUri::from_path(test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME))?, + &PathUri::from_host_native_path(test.config.cwd.join(GLOBAL_AGENTS_OVERRIDE_FILENAME))?, b"new remote project instructions".to_vec(), /*sandbox*/ None, ) @@ -712,7 +713,7 @@ async fn multi_environment_thread_loads_every_project_and_keeps_creation_snapsho vec![ PathUri::from_abs_path(&global_source), PathUri::from_abs_path(&remote_source), - PathUri::from_path(&local_source)?, + PathUri::from_host_native_path(&local_source)?, ] ); diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 2527e4cb93..c348dd6f9e 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1329,7 +1329,7 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest config.cwd = config.cwd.join("subdir"); }) .with_workspace_setup(|cwd, fs| async move { - let cwd_uri = PathUri::from_path(&cwd)?; + let cwd_uri = PathUri::from_host_native_path(&cwd)?; fs.create_directory( &cwd_uri, CreateDirectoryOptions { recursive: true }, @@ -1337,8 +1337,8 @@ async fn apply_patch_turn_diff_paths_stay_repo_relative_when_session_cwd_is_nest ) .await?; let repo_root = cwd.parent().expect("nested cwd should have parent"); - let git_uri = PathUri::from_path(repo_root.join(".git"))?; - let repo_file_uri = PathUri::from_path(repo_root.join("repo.txt"))?; + let git_uri = PathUri::from_host_native_path(repo_root.join(".git"))?; + let repo_file_uri = PathUri::from_host_native_path(repo_root.join("repo.txt"))?; fs.write_file( &git_uri, b"gitdir: /tmp/fake-worktree\n".to_vec(), @@ -1586,7 +1586,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let shared_cwd_uri = PathUri::from_path(&shared_cwd)?; + let shared_cwd_uri = PathUri::from_host_native_path(&shared_cwd)?; let _ = fs::remove_dir_all(shared_cwd.as_path()); test.fs() .remove( @@ -1689,7 +1689,7 @@ async fn apply_patch_turn_diff_tracks_local_and_remote_environment_paths() -> Re assert_eq!( test.fs() .read_file_text( - &PathUri::from_path(shared_cwd.join(file_name))?, + &PathUri::from_host_native_path(shared_cwd.join(file_name))?, /*sandbox*/ None, ) .await?, @@ -1859,7 +1859,7 @@ async fn apply_patch_clears_aggregated_diff_after_inexact_delta() -> Result<()> let harness = apply_patch_harness_with(|builder| { builder.with_workspace_setup(|cwd, fs| async move { - let binary_path_uri = PathUri::from_path(cwd.join("binary.dat"))?; + let binary_path_uri = PathUri::from_host_native_path(cwd.join("binary.dat"))?; fs.write_file( &binary_path_uri, vec![0xff, 0xfe, 0xfd], diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 14db16c60a..f476fb7287 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -74,7 +74,7 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, diff --git a/codex-rs/core/tests/suite/remote_env.rs b/codex-rs/core/tests/suite/remote_env.rs index fa805fa446..2df03f92a8 100644 --- a/codex-rs/core/tests/suite/remote_env.rs +++ b/codex-rs/core/tests/suite/remote_env.rs @@ -173,7 +173,7 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> { let file_system = test_env.environment().get_filesystem(); let file_path_abs = test_env.cwd().join("remote-test-env-ok"); - let file_path_uri = PathUri::from_path(&file_path_abs)?; + let file_path_uri = PathUri::from_host_native_path(&file_path_abs)?; let payload = b"remote-test-env-ok".to_vec(); file_system @@ -329,8 +329,8 @@ async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> { let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis(); let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs(); let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; - let target_uri = PathUri::from_path(&target_path)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; + let target_uri = PathUri::from_host_native_path(&target_path)?; test.fs() .create_directory( &remote_cwd_uri, @@ -801,8 +801,8 @@ async fn exec_command_routes_to_selected_remote_environment() -> Result<()> { )) .abs(); let remote_marker_name = "marker.txt"; - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; - let remote_marker_uri = PathUri::from_path(remote_cwd.join(remote_marker_name))?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; + let remote_marker_uri = PathUri::from_host_native_path(remote_cwd.join(remote_marker_name))?; test.fs() .create_directory( &remote_cwd_uri, @@ -900,7 +900,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result let local_write_root = local_cwd.path().join(relative_write_root); let local_target_path = local_cwd.path().join(relative_target_path); fs::create_dir(&local_write_root)?; - let remote_write_root_uri = PathUri::from_path(&remote_write_root)?; + let remote_write_root_uri = PathUri::from_host_native_path(&remote_write_root)?; test.fs() .create_directory( &remote_write_root_uri, @@ -1040,7 +1040,7 @@ async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result assert_eq!( test.fs() .read_file_text( - &PathUri::from_path(&remote_target_path)?, + &PathUri::from_host_native_path(&remote_target_path)?, /*sandbox*/ None, ) .await?, @@ -1084,7 +1084,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -1129,7 +1129,7 @@ async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result< let remote_contents = test .fs() .read_file_text( - &PathUri::from_path(remote_cwd.join(file_name))?, + &PathUri::from_host_native_path(remote_cwd.join(file_name))?, /*sandbox*/ None, ) .await?; @@ -1174,7 +1174,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -1188,7 +1188,7 @@ async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> { SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let target_path_uri = PathUri::from_path(&target_path)?; + let target_path_uri = PathUri::from_host_native_path(&target_path)?; let _ = fs::remove_file(&target_path); test.fs() .remove( @@ -1362,7 +1362,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis() )) .abs(); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -1417,7 +1417,7 @@ async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environm let remote_contents = test .fs() .read_file_text( - &PathUri::from_path(remote_cwd.join(file_name))?, + &PathUri::from_host_native_path(remote_cwd.join(file_name))?, /*sandbox*/ None, ) .await?; @@ -1455,8 +1455,8 @@ async fn remote_test_env_sandboxed_read_allows_readable_root() -> Result<()> { let allowed_dir = PathBuf::from(format!("/tmp/codex-remote-readable-{}", std::process::id())); let file_path = allowed_dir.join("note.txt"); - let allowed_dir_uri = PathUri::from_path(&allowed_dir)?; - let file_path_uri = PathUri::from_path(&file_path)?; + let allowed_dir_uri = PathUri::from_host_native_path(&allowed_dir)?; + let file_path_uri = PathUri::from_host_native_path(&file_path)?; file_system .create_directory( &allowed_dir_uri, @@ -1516,7 +1516,7 @@ async fn remote_test_env_sandboxed_read_rejects_symlink_parent_dotdot_escape() - ))?; let requested_path = - PathUri::from_path(allowed_dir.join("link").join("..").join("secret.txt"))?; + PathUri::from_host_native_path(allowed_dir.join("link").join("..").join("secret.txt"))?; let sandbox = read_only_sandbox(allowed_dir.clone()); let error = match file_system.read_file(&requested_path, Some(&sandbox)).await { Ok(_) => anyhow::bail!("read should fail after path normalization"), @@ -1564,7 +1564,7 @@ async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> { let sandbox = workspace_write_sandbox(allowed_dir.clone()); file_system .remove( - &PathUri::from_path(&symlink_path)?, + &PathUri::from_host_native_path(&symlink_path)?, RemoveOptions { recursive: false, force: false, @@ -1582,13 +1582,16 @@ async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> { .is_ok(); assert!(!symlink_exists); let outside = file_system - .read_file_text(&PathUri::from_path(&outside_file)?, /*sandbox*/ None) + .read_file_text( + &PathUri::from_host_native_path(&outside_file)?, + /*sandbox*/ None, + ) .await?; assert_eq!(outside, "outside"); file_system .remove( - &PathUri::from_path(&root)?, + &PathUri::from_host_native_path(&root)?, RemoveOptions { recursive: true, force: true, @@ -1630,8 +1633,8 @@ async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> { let sandbox = workspace_write_sandbox(allowed_dir.clone()); file_system .copy( - &PathUri::from_path(&source_symlink)?, - &PathUri::from_path(&copied_symlink)?, + &PathUri::from_host_native_path(&source_symlink)?, + &PathUri::from_host_native_path(&copied_symlink)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -1664,7 +1667,7 @@ async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> { file_system .remove( - &PathUri::from_path(&root)?, + &PathUri::from_host_native_path(&root)?, RemoveOptions { recursive: true, force: true, diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 236fbee4e5..95f7fbb931 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -747,7 +747,7 @@ async fn stdio_server_uses_configured_cwd_before_runtime_fallback() -> anyhow::R let fixture = test_codex() .with_workspace_setup(|cwd, fs| async move { let configured_cwd = cwd.join("mcp-configured-cwd"); - let configured_cwd_uri = PathUri::from_path(&configured_cwd)?; + let configured_cwd_uri = PathUri::from_host_native_path(&configured_cwd)?; fs.create_directory( &configured_cwd_uri, CreateDirectoryOptions { recursive: true }, diff --git a/codex-rs/core/tests/suite/skills.rs b/codex-rs/core/tests/suite/skills.rs index 2de9bf26cb..7beaa17101 100644 --- a/codex-rs/core/tests/suite/skills.rs +++ b/codex-rs/core/tests/suite/skills.rs @@ -31,7 +31,7 @@ async fn write_repo_skill( body: &str, ) -> Result<()> { let skill_dir = cwd.join(".agents").join("skills").join(name); - let skill_dir_uri = PathUri::from_path(&skill_dir)?; + let skill_dir_uri = PathUri::from_host_native_path(&skill_dir)?; fs.create_directory( &skill_dir_uri, CreateDirectoryOptions { recursive: true }, @@ -40,7 +40,7 @@ async fn write_repo_skill( .await?; let contents = format!("---\nname: {name}\ndescription: {description}\n---\n\n{body}\n"); let path = skill_dir.join("SKILL.md"); - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; fs.write_file(&path_uri, contents.into_bytes(), /*sandbox*/ None) .await?; Ok(()) diff --git a/codex-rs/core/tests/suite/unified_exec.rs b/codex-rs/core/tests/suite/unified_exec.rs index 8187fc0cb1..fe6ce7fbb7 100644 --- a/codex-rs/core/tests/suite/unified_exec.rs +++ b/codex-rs/core/tests/suite/unified_exec.rs @@ -229,7 +229,7 @@ async fn create_workspace_directory( rel_path: impl AsRef, ) -> Result { let abs_path = test.config.cwd.join(rel_path.as_ref()); - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; test.fs() .create_directory( &abs_path_uri, @@ -437,7 +437,7 @@ async fn unified_exec_emits_exec_command_begin_event() -> Result<()> { assert_command(&begin_event.command, "-lc", "/bin/echo hello unified exec"); - assert_eq!(begin_event.cwd, PathUri::from_path(&cwd)?); + assert_eq!(begin_event.cwd, PathUri::from_host_native_path(&cwd)?); wait_for_event(&test.codex, |event| { matches!(event, EventMsg::TurnComplete(_)) @@ -508,7 +508,7 @@ async fn unified_exec_resolves_relative_workdir() -> Result<()> { assert_eq!( begin_event.cwd, - PathUri::from_path(&workdir)?, + PathUri::from_host_native_path(&workdir)?, "exec_command cwd should resolve relative workdir against turn cwd", ); @@ -570,7 +570,7 @@ async fn unified_exec_respects_workdir_override() -> Result<()> { assert_eq!( begin_event.cwd, - PathUri::from_path(&workdir)?, + PathUri::from_host_native_path(&workdir)?, "exec_command cwd should reflect the requested workdir override" ); diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index c2c7b562d8..a660ec4da6 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -133,7 +133,7 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result { let abs_path = test.config.cwd.join(rel_path); - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; test.fs() .create_directory( &abs_path_uri, @@ -151,7 +151,7 @@ async fn write_workspace_file( ) -> anyhow::Result { let abs_path = test.config.cwd.join(rel_path); if let Some(parent) = abs_path.parent() { - let parent_uri = PathUri::from_path(&parent)?; + let parent_uri = PathUri::from_host_native_path(&parent)?; test.fs() .create_directory( &parent_uri, @@ -160,7 +160,7 @@ async fn write_workspace_file( ) .await?; } - let abs_path_uri = PathUri::from_path(&abs_path)?; + let abs_path_uri = PathUri::from_host_native_path(&abs_path)?; test.fs() .write_file(&abs_path_uri, contents, /*sandbox*/ None) .await?; @@ -589,7 +589,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() )) .abs(); let image_path = remote_cwd.join("remote.png"); - let remote_cwd_uri = PathUri::from_path(&remote_cwd)?; + let remote_cwd_uri = PathUri::from_host_native_path(&remote_cwd)?; test.fs() .create_directory( &remote_cwd_uri, @@ -598,7 +598,7 @@ async fn view_image_routes_to_selected_remote_environment() -> anyhow::Result<() ) .await?; let png = png_bytes(/*width*/ 1, /*height*/ 1, [0, 255, 0, 255])?; - let image_path_uri = PathUri::from_path(&image_path)?; + let image_path_uri = PathUri::from_host_native_path(&image_path)?; test.fs() .write_file(&image_path_uri, png, /*sandbox*/ None) .await?; diff --git a/codex-rs/exec-server/src/environment.rs b/codex-rs/exec-server/src/environment.rs index 5990d8811d..40d73cade5 100644 --- a/codex-rs/exec-server/src/environment.rs +++ b/codex-rs/exec-server/src/environment.rs @@ -1151,8 +1151,10 @@ mod tests { .start(crate::ExecParams { process_id: ProcessId::from("default-env-proc"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir().expect("read current dir")) - .expect("cwd URI"), + cwd: PathUri::from_host_native_path( + std::env::current_dir().expect("read current dir"), + ) + .expect("cwd URI"), env_policy: None, env: Default::default(), tty: false, @@ -1188,8 +1190,10 @@ mod tests { .start(crate::ExecParams { process_id: ProcessId::from("local-sandbox-proc"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir().expect("read current dir")) - .expect("cwd URI"), + cwd: PathUri::from_host_native_path( + std::env::current_dir().expect("read current dir"), + ) + .expect("cwd URI"), env_policy: None, env: Default::default(), tty: false, diff --git a/codex-rs/exec-server/src/fs_helper.rs b/codex-rs/exec-server/src/fs_helper.rs index f4db844b5b..9fa74bf3d1 100644 --- a/codex-rs/exec-server/src/fs_helper.rs +++ b/codex-rs/exec-server/src/fs_helper.rs @@ -314,8 +314,9 @@ mod tests { #[test] fn helper_protocol_uses_path_uris() -> serde_json::Result<()> { - let local_path = PathUri::from_path(std::env::current_dir().expect("cwd").join("file")) - .expect("path URI"); + let local_path = + PathUri::from_host_native_path(std::env::current_dir().expect("cwd").join("file")) + .expect("path URI"); let paths = [ local_path, PathUri::parse("file://server/share/file").expect("path URI"), diff --git a/codex-rs/exec-server/src/local_process.rs b/codex-rs/exec-server/src/local_process.rs index c7e99de53c..1f0dc5fec9 100644 --- a/codex-rs/exec-server/src/local_process.rs +++ b/codex-rs/exec-server/src/local_process.rs @@ -956,7 +956,8 @@ mod tests { ExecParams { process_id: ProcessId::from("env-test"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"), + cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd")) + .expect("cwd URI"), env_policy: None, env, tty: false, diff --git a/codex-rs/exec-server/src/protocol.rs b/codex-rs/exec-server/src/protocol.rs index 99ee3431aa..8771e869df 100644 --- a/codex-rs/exec-server/src/protocol.rs +++ b/codex-rs/exec-server/src/protocol.rs @@ -519,7 +519,7 @@ mod tests { let legacy_cwd = std::env::current_dir().expect("current directory"); let native_sandbox = FileSystemSandboxContext::from_permission_profile_with_cwd( PermissionProfile::default(), - PathUri::from_path(&legacy_cwd).expect("cwd URI"), + PathUri::from_host_native_path(&legacy_cwd).expect("cwd URI"), ); let mut legacy_sandbox = serde_json::to_value(&native_sandbox).expect("sandbox should serialize"); @@ -531,7 +531,7 @@ mod tests { .expect("legacy absolute path should deserialize"); let expected_sandbox = native_sandbox; let expected = FsReadFileParams { - path: PathUri::from_path(legacy_path).expect("path URI"), + path: PathUri::from_host_native_path(legacy_path).expect("path URI"), sandbox: Some(expected_sandbox.clone()), }; diff --git a/codex-rs/exec-server/src/server/file_system_handler.rs b/codex-rs/exec-server/src/server/file_system_handler.rs index ddf17f21cd..1a6e073936 100644 --- a/codex-rs/exec-server/src/server/file_system_handler.rs +++ b/codex-rs/exec-server/src/server/file_system_handler.rs @@ -274,7 +274,7 @@ mod tests { ) .expect("runtime paths"); let handler = FileSystemHandler::new(runtime_paths); - let sandbox_cwd = PathUri::from_path(temp_dir.path()).expect("tempdir URI"); + let sandbox_cwd = PathUri::from_host_native_path(temp_dir.path()).expect("tempdir URI"); let sandbox_context = |sandbox_policy| { FileSystemSandboxContext::from_legacy_sandbox_policy( sandbox_policy, @@ -292,7 +292,8 @@ mod tests { }, ), ] { - let path = PathUri::from_path(temp_dir.path().join(file_name)).expect("path URI"); + let path = + PathUri::from_host_native_path(temp_dir.path().join(file_name)).expect("path URI"); handler .write_file(FsWriteFileParams { @@ -312,7 +313,7 @@ mod tests { .expect("canonicalize file"); assert_eq!( canonicalized.path, - PathUri::from_path( + PathUri::from_host_native_path( std::fs::canonicalize(temp_dir.path().join(file_name)).expect("canonical path"), ) .expect("canonical path URI"), diff --git a/codex-rs/exec-server/src/server/handler/tests.rs b/codex-rs/exec-server/src/server/handler/tests.rs index 9cbbf78d00..ebb2b6e6ea 100644 --- a/codex-rs/exec-server/src/server/handler/tests.rs +++ b/codex-rs/exec-server/src/server/handler/tests.rs @@ -27,7 +27,8 @@ fn exec_params_with_argv(process_id: &str, argv: Vec) -> ExecParams { ExecParams { process_id: ProcessId::from(process_id), argv, - cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"), + cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd")) + .expect("cwd URI"), env_policy: None, env: inherited_path_env(), tty: false, diff --git a/codex-rs/exec-server/src/server/processor.rs b/codex-rs/exec-server/src/server/processor.rs index f5c2ba760d..e9316e0d54 100644 --- a/codex-rs/exec-server/src/server/processor.rs +++ b/codex-rs/exec-server/src/server/processor.rs @@ -432,7 +432,8 @@ mod tests { ExecParams { process_id, argv: sleep_then_print_argv(), - cwd: PathUri::from_path(std::env::current_dir().expect("cwd")).expect("cwd URI"), + cwd: PathUri::from_host_native_path(std::env::current_dir().expect("cwd")) + .expect("cwd URI"), env_policy: None, env, tty: false, diff --git a/codex-rs/exec-server/tests/exec_process.rs b/codex-rs/exec-server/tests/exec_process.rs index f35923815d..2f9b9c1dbe 100644 --- a/codex-rs/exec-server/tests/exec_process.rs +++ b/codex-rs/exec-server/tests/exec_process.rs @@ -75,7 +75,7 @@ async fn assert_exec_process_starts_and_exits(use_remote: bool) -> Result<()> { .start(ExecParams { process_id: ProcessId::from("proc-1"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -218,7 +218,7 @@ async fn assert_exec_process_streams_output(use_remote: bool) -> Result<()> { "-c".to_string(), "sleep 0.05; printf 'session output\\n'".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -251,7 +251,7 @@ async fn assert_exec_process_pushes_events(use_remote: bool) -> Result<()> { "-c".to_string(), "printf 'event output\\n'; sleep 0.1; printf 'event err\\n' >&2; sleep 0.1; exit 7".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -300,7 +300,7 @@ async fn assert_exec_process_replays_events_after_close(use_remote: bool) -> Res "-c".to_string(), "printf 'late one\\n'; printf 'late two\\n'".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -350,7 +350,7 @@ async fn assert_exec_process_retains_output_after_exit_until_streams_close( DELAYED_OUTPUT_AFTER_EXIT_PARENT_ARG.to_string(), release_path.to_string_lossy().into_owned(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -425,7 +425,7 @@ async fn assert_exec_process_write_then_read(use_remote: bool) -> Result<()> { "-c".to_string(), "IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: true, @@ -464,7 +464,7 @@ async fn assert_exec_process_write_then_read_without_tty(use_remote: bool) -> Re "-c".to_string(), "IFS= read line; printf 'from-stdin:%s\\n' \"$line\"".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -499,7 +499,7 @@ async fn assert_exec_process_rejects_write_without_pipe_stdin(use_remote: bool) "-c".to_string(), "sleep 0.3; if IFS= read -r line; then printf 'read:%s\\n' \"$line\"; else printf 'eof\\n'; fi".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -535,7 +535,7 @@ async fn assert_exec_process_signal_interrupts_process(use_remote: bool) -> Resu "-c".to_string(), "trap 'printf \"signal:2\\n\"; exit 7' INT; printf 'ready\\n'; while :; do :; done".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -590,7 +590,7 @@ async fn assert_exec_process_signal_reports_unsupported_on_windows(use_remote: b "/C".to_string(), "echo ready && ping -n 30 127.0.0.1 >NUL".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -632,7 +632,7 @@ async fn assert_exec_process_preserves_queued_events_before_subscribe( "-c".to_string(), "printf 'queued output\\n'".to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: Default::default(), tty: false, @@ -683,7 +683,7 @@ async fn remote_exec_process_recovers_after_transport_disconnect() -> Result<()> ) .to_string(), ], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: /*env_policy*/ None, env: HashMap::from([ ( diff --git a/codex-rs/exec-server/tests/file_stream.rs b/codex-rs/exec-server/tests/file_stream.rs index e30e8c4689..89162daca4 100644 --- a/codex-rs/exec-server/tests/file_stream.rs +++ b/codex-rs/exec-server/tests/file_stream.rs @@ -45,7 +45,10 @@ async fn stream_stops_after_an_exact_block_boundary() -> Result<()> { std::fs::write(&path, vec![b'x'; BLOCK_SIZE * 2])?; let chunks = file_system - .read_file_stream(&PathUri::from_path(path)?, /*sandbox*/ None) + .read_file_stream( + &PathUri::from_host_native_path(path)?, + /*sandbox*/ None, + ) .await? .try_collect::>() .await?; @@ -64,7 +67,7 @@ async fn completed_streams_release_handle_capacity() -> Result<()> { let tmp = TempDir::new()?; let path = tmp.path().join("repeated.txt"); std::fs::write(&path, b"repeated")?; - let path = PathUri::from_path(path)?; + let path = PathUri::from_host_native_path(path)?; for _ in 0..=OPEN_FILE_LIMIT { let chunks = file_system @@ -88,7 +91,7 @@ async fn stream_rejects_platform_sandbox() -> Result<()> { let result = file_system .read_file_stream( - &PathUri::from_path(&path)?, + &PathUri::from_host_native_path(&path)?, Some(&read_only_sandbox(tmp.path().to_path_buf())), ) .await; @@ -120,7 +123,7 @@ async fn file_reads_reject_fifo_without_waiting_for_a_writer() -> Result<()> { ); } - let path_uri = PathUri::from_path(&path)?; + let path_uri = PathUri::from_host_native_path(&path)?; let read_error = timeout( Duration::from_secs(1), file_system.read_file(&path_uri, /*sandbox*/ None), @@ -158,7 +161,7 @@ async fn file_reads_reject_named_pipes() -> Result<()> { let read_error = timeout( Duration::from_secs(1), file_system.read_file( - &PathUri::from_path(std::path::Path::new(&read_path))?, + &PathUri::from_host_native_path(std::path::Path::new(&read_path))?, /*sandbox*/ None, ), ) @@ -173,7 +176,7 @@ async fn file_reads_reject_named_pipes() -> Result<()> { let stream_result = timeout( Duration::from_secs(1), file_system.read_file_stream( - &PathUri::from_path(std::path::Path::new(&stream_path))?, + &PathUri::from_host_native_path(std::path::Path::new(&stream_path))?, /*sandbox*/ None, ), ) @@ -202,7 +205,10 @@ async fn stream_keeps_reading_the_open_file_after_path_replacement() -> Result<( let path = tmp.path().join("replaceable.bin"); std::fs::write(&path, vec![b'a'; BLOCK_SIZE + 1])?; let mut stream = file_system - .read_file_stream(&PathUri::from_path(&path)?, /*sandbox*/ None) + .read_file_stream( + &PathUri::from_host_native_path(&path)?, + /*sandbox*/ None, + ) .await?; assert_eq!( @@ -236,7 +242,7 @@ async fn read_block_supports_non_sequential_offsets_and_lengths() -> Result<()> let open = client .fs_open(FsOpenParams { handle_id: Uuid::new_v4().simple().to_string(), - path: PathUri::from_path(path)?, + path: PathUri::from_host_native_path(path)?, sandbox: None, }) .await?; @@ -295,7 +301,7 @@ async fn open_enforces_the_per_connection_limit_and_close_releases_capacity() -> let tmp = TempDir::new()?; let path = tmp.path().join("limited.bin"); std::fs::write(&path, b"limited")?; - let path = PathUri::from_path(path)?; + let path = PathUri::from_host_native_path(path)?; let mut handles = Vec::with_capacity(OPEN_FILE_LIMIT); for _ in 0..OPEN_FILE_LIMIT { let open = client @@ -359,7 +365,7 @@ async fn open_rejects_handle_ids_longer_than_32_bytes() -> Result<()> { let error = client .fs_open(FsOpenParams { handle_id: "x".repeat(33), - path: PathUri::from_path(path)?, + path: PathUri::from_host_native_path(path)?, sandbox: None, }) .await diff --git a/codex-rs/exec-server/tests/file_system/shared.rs b/codex-rs/exec-server/tests/file_system/shared.rs index 08ba2efdd8..8eb17f39fb 100644 --- a/codex-rs/exec-server/tests/file_system/shared.rs +++ b/codex-rs/exec-server/tests/file_system/shared.rs @@ -66,7 +66,10 @@ async fn file_system_get_metadata_reports_files_and_directories( std::fs::create_dir(&directory_path)?; let file_metadata = file_system - .get_metadata(&PathUri::from_path(&file_path)?, /*sandbox*/ None) + .get_metadata( + &PathUri::from_host_native_path(&file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -83,7 +86,10 @@ async fn file_system_get_metadata_reports_files_and_directories( assert!(file_metadata.modified_at_ms > 0); let directory_metadata = file_system - .get_metadata(&PathUri::from_path(&directory_path)?, /*sandbox*/ None) + .get_metadata( + &PathUri::from_host_native_path(&directory_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -116,7 +122,7 @@ async fn file_system_create_directory_creates_nested_directories( file_system .create_directory( - &PathUri::from_path(&nested_dir)?, + &PathUri::from_host_native_path(&nested_dir)?, CreateDirectoryOptions { recursive: true }, /*sandbox*/ None, ) @@ -140,7 +146,7 @@ async fn file_system_write_file_writes_bytes( let file_path = tmp.path().join("note.txt"); file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"hello from trait".to_vec(), /*sandbox*/ None, ) @@ -155,21 +161,21 @@ async fn file_system_write_file_writes_bytes( fn path_uri_join_and_parent_preserve_lexical_paths() -> Result<()> { let tmp = TempDir::new()?; let source_dir = tmp.path().join("source"); - let source_dir_uri = PathUri::from_path(&source_dir)?; + let source_dir_uri = PathUri::from_host_native_path(&source_dir)?; let joined_nested = source_dir_uri.join("nested/note.txt")?; assert_eq!( joined_nested, - PathUri::from_path(source_dir.join("nested").join("note.txt"))? + PathUri::from_host_native_path(source_dir.join("nested").join("note.txt"))? ); let joined_parent = joined_nested.parent(); assert_eq!( joined_parent, - Some(PathUri::from_path(source_dir.join("nested"))?) + Some(PathUri::from_host_native_path(source_dir.join("nested"))?) ); let joined_parent_traversal = source_dir_uri.join("../outside")?; assert_eq!( joined_parent_traversal, - PathUri::from_path(source_dir.join("../outside"))? + PathUri::from_host_native_path(source_dir.join("../outside"))? ); Ok(()) } @@ -188,7 +194,10 @@ async fn file_system_read_file_returns_bytes( std::fs::write(&file_path, "hello from trait")?; let contents = file_system - .read_file(&PathUri::from_path(&file_path)?, /*sandbox*/ None) + .read_file( + &PathUri::from_host_native_path(&file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!(contents, b"hello from trait"); @@ -213,7 +222,10 @@ async fn file_system_read_file_stream_returns_bounded_chunks( std::fs::write(&file_path, &contents)?; let chunks = file_system - .read_file_stream(&PathUri::from_path(file_path)?, /*sandbox*/ None) + .read_file_stream( + &PathUri::from_host_native_path(file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))? .try_collect::>() @@ -249,7 +261,10 @@ async fn file_system_read_file_text_returns_string( std::fs::write(&file_path, "hello from trait")?; let contents = file_system - .read_file_text(&PathUri::from_path(&file_path)?, /*sandbox*/ None) + .read_file_text( + &PathUri::from_host_native_path(&file_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!(contents, "hello from trait"); @@ -271,8 +286,8 @@ async fn file_system_copy_copies_file(implementation: FileSystemImplementation) file_system .copy( - &PathUri::from_path(&source_file)?, - &PathUri::from_path(&copied_file)?, + &PathUri::from_host_native_path(&source_file)?, + &PathUri::from_host_native_path(&copied_file)?, CopyOptions { recursive: false }, /*sandbox*/ None, ) @@ -302,8 +317,8 @@ async fn file_system_copy_copies_directory_recursively( file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(&copied_dir)?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(&copied_dir)?, CopyOptions { recursive: true }, /*sandbox*/ None, ) @@ -332,7 +347,10 @@ async fn file_system_read_directory_lists_entries( std::fs::write(source_dir.join("root.txt"), "hello")?; let mut entries = file_system - .read_directory(&PathUri::from_path(&source_dir)?, /*sandbox*/ None) + .read_directory( + &PathUri::from_host_native_path(&source_dir)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; entries.sort_by(|left, right| left.file_name.cmp(&right.file_name)); @@ -370,7 +388,7 @@ async fn file_system_remove_removes_directory( file_system .remove( - &PathUri::from_path(&directory_path)?, + &PathUri::from_host_native_path(&directory_path)?, RemoveOptions { recursive: true, force: true, @@ -398,7 +416,7 @@ async fn file_system_write_file_reports_missing_parent( let error = match file_system .write_file( - &PathUri::from_path(&missing_parent_path)?, + &PathUri::from_host_native_path(&missing_parent_path)?, b"hello from trait".to_vec(), /*sandbox*/ None, ) @@ -432,8 +450,8 @@ async fn file_system_copy_rejects_directory_without_recursive( let error = file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(tmp.path().join("dest"))?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(tmp.path().join("dest"))?, CopyOptions { recursive: false }, /*sandbox*/ None, ) @@ -465,7 +483,7 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root( let sandbox = read_only_sandbox(allowed_dir); let metadata = file_system - .get_metadata(&PathUri::from_path(&file_path)?, Some(&sandbox)) + .get_metadata(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox)) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -481,7 +499,7 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root( ); let contents = file_system - .read_file(&PathUri::from_path(&file_path)?, Some(&sandbox)) + .read_file(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox)) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!(contents, b"sandboxed hello"); @@ -505,8 +523,8 @@ pub(crate) async fn assert_canonicalize_resolves_directory_alias( std::fs::write(&file_path, "canonical hello")?; create_directory_alias(&source_dir, &alias_dir)?; - let requested_path = PathUri::from_path(alias_dir.join("nested").join("note.txt"))?; - let expected_path = PathUri::from_path(std::fs::canonicalize(&file_path)?)?; + let requested_path = PathUri::from_host_native_path(alias_dir.join("nested").join("note.txt"))?; + let expected_path = PathUri::from_host_native_path(std::fs::canonicalize(&file_path)?)?; assert_ne!(requested_path, expected_path); let canonical_path = file_system @@ -535,8 +553,8 @@ pub(crate) async fn assert_sandboxed_canonicalize_resolves_directory_alias( create_directory_alias(&source_dir, &alias_dir)?; let sandbox = read_only_sandbox(tmp.path().to_path_buf()); - let requested_path = PathUri::from_path(alias_dir.join("nested").join("note.txt"))?; - let expected_path = PathUri::from_path(std::fs::canonicalize(&file_path)?)?; + let requested_path = PathUri::from_host_native_path(alias_dir.join("nested").join("note.txt"))?; + let expected_path = PathUri::from_host_native_path(std::fs::canonicalize(&file_path)?)?; assert_ne!(requested_path, expected_path); let canonical_path = file_system @@ -591,7 +609,7 @@ async fn file_system_sandboxed_write_allows_additional_write_root( file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"created".to_vec(), Some(&sandbox), ) @@ -617,8 +635,8 @@ async fn file_system_copy_rejects_copying_directory_into_descendant( let error = file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(source_dir.join("nested").join("copy"))?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(source_dir.join("nested").join("copy"))?, CopyOptions { recursive: true }, /*sandbox*/ None, ) diff --git a/codex-rs/exec-server/tests/file_system_unix.rs b/codex-rs/exec-server/tests/file_system_unix.rs index 57c3a82e97..0287844a70 100644 --- a/codex-rs/exec-server/tests/file_system_unix.rs +++ b/codex-rs/exec-server/tests/file_system_unix.rs @@ -187,7 +187,7 @@ async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result< file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"written through fs helper".to_vec(), Some(&sandbox), ) @@ -221,7 +221,10 @@ async fn file_system_get_metadata_reports_symlink_targets( let symlink_path = tmp.path().join("note-link.txt"); symlink(&file_path, &symlink_path)?; let symlink_metadata = file_system - .get_metadata(&PathUri::from_path(&symlink_path)?, /*sandbox*/ None) + .get_metadata( + &PathUri::from_host_native_path(&symlink_path)?, + /*sandbox*/ None, + ) .await .with_context(|| format!("mode={implementation}"))?; assert_eq!( @@ -243,7 +246,7 @@ async fn file_system_get_metadata_reports_symlink_targets( symlink(&dir_path, &dir_symlink_path)?; let dir_symlink_metadata = file_system .get_metadata( - &PathUri::from_path(&dir_symlink_path)?, + &PathUri::from_host_native_path(&dir_symlink_path)?, /*sandbox*/ None, ) .await @@ -278,7 +281,7 @@ async fn file_system_sandboxed_write_rejects_unwritable_path( let sandbox = read_only_sandbox(tmp.path().to_path_buf()); let error = match file_system .write_file( - &PathUri::from_path(&blocked_path)?, + &PathUri::from_host_native_path(&blocked_path)?, b"nope".to_vec(), Some(&sandbox), ) @@ -314,7 +317,7 @@ async fn file_system_sandboxed_write_allows_explicit_alias_roots( file_system .write_file( - &PathUri::from_path(&file_path)?, + &PathUri::from_host_native_path(&file_path)?, b"created".to_vec(), Some(&sandbox), ) @@ -345,7 +348,10 @@ async fn file_system_sandboxed_read_rejects_symlink_escape( let requested_path = allowed_dir.join("link").join("secret.txt"); let sandbox = read_only_sandbox(allowed_dir); let error = match file_system - .read_file(&PathUri::from_path(&requested_path)?, Some(&sandbox)) + .read_file( + &PathUri::from_host_native_path(&requested_path)?, + Some(&sandbox), + ) .await { Ok(_) => anyhow::bail!("read should be blocked"), @@ -375,7 +381,7 @@ async fn file_system_sandboxed_read_rejects_symlink_parent_dotdot_escape( symlink(&outside_dir, allowed_dir.join("link"))?; let requested_path = - PathUri::from_path(allowed_dir.join("link").join("..").join("secret.txt"))?; + PathUri::from_host_native_path(allowed_dir.join("link").join("..").join("secret.txt"))?; let sandbox = read_only_sandbox(allowed_dir); let error = match file_system.read_file(&requested_path, Some(&sandbox)).await { Ok(_) => anyhow::bail!("read should fail after path normalization"), @@ -411,7 +417,7 @@ async fn file_system_sandboxed_write_rejects_symlink_escape( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .write_file( - &PathUri::from_path(&requested_path)?, + &PathUri::from_host_native_path(&requested_path)?, b"nope".to_vec(), Some(&sandbox), ) @@ -449,7 +455,7 @@ async fn file_system_sandboxed_write_preserves_existing_hard_link( let sandbox = workspace_write_sandbox(allowed_dir); file_system .write_file( - &PathUri::from_path(&hard_link)?, + &PathUri::from_host_native_path(&hard_link)?, b"updated through existing hard link\n".to_vec(), Some(&sandbox), ) @@ -495,7 +501,7 @@ async fn file_system_create_directory_rejects_symlink_escape( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .create_directory( - &PathUri::from_path(&requested_path)?, + &PathUri::from_host_native_path(&requested_path)?, CreateDirectoryOptions { recursive: false }, Some(&sandbox), ) @@ -530,7 +536,10 @@ async fn file_system_read_directory_rejects_symlink_escape( let requested_path = allowed_dir.join("link"); let sandbox = read_only_sandbox(allowed_dir); let error = match file_system - .read_directory(&PathUri::from_path(&requested_path)?, Some(&sandbox)) + .read_directory( + &PathUri::from_host_native_path(&requested_path)?, + Some(&sandbox), + ) .await { Ok(_) => anyhow::bail!("read_directory should be blocked"), @@ -562,8 +571,8 @@ async fn file_system_copy_rejects_symlink_escape_destination( let sandbox = workspace_write_sandbox(allowed_dir.clone()); let error = match file_system .copy( - &PathUri::from_path(allowed_dir.join("source.txt"))?, - &PathUri::from_path(&requested_destination)?, + &PathUri::from_host_native_path(allowed_dir.join("source.txt"))?, + &PathUri::from_host_native_path(&requested_destination)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -600,7 +609,7 @@ async fn file_system_remove_removes_symlink_not_target( let sandbox = workspace_write_sandbox(allowed_dir); file_system .remove( - &PathUri::from_path(&symlink_path)?, + &PathUri::from_host_native_path(&symlink_path)?, RemoveOptions { recursive: false, force: false, @@ -640,8 +649,8 @@ async fn file_system_copy_preserves_symlink_source( let sandbox = workspace_write_sandbox(allowed_dir.clone()); file_system .copy( - &PathUri::from_path(&source_symlink)?, - &PathUri::from_path(&copied_symlink)?, + &PathUri::from_host_native_path(&source_symlink)?, + &PathUri::from_host_native_path(&copied_symlink)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -677,7 +686,7 @@ async fn file_system_remove_rejects_symlink_escape( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .remove( - &PathUri::from_path(&requested_path)?, + &PathUri::from_host_native_path(&requested_path)?, RemoveOptions { recursive: false, force: false, @@ -718,8 +727,8 @@ async fn file_system_copy_rejects_symlink_escape_source( let sandbox = workspace_write_sandbox(allowed_dir); let error = match file_system .copy( - &PathUri::from_path(&requested_source)?, - &PathUri::from_path(&requested_destination)?, + &PathUri::from_host_native_path(&requested_source)?, + &PathUri::from_host_native_path(&requested_destination)?, CopyOptions { recursive: false }, Some(&sandbox), ) @@ -752,8 +761,8 @@ async fn file_system_copy_preserves_symlinks_in_recursive_copy( file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(&copied_dir)?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(&copied_dir)?, CopyOptions { recursive: true }, /*sandbox*/ None, ) @@ -798,8 +807,8 @@ async fn file_system_copy_ignores_unknown_special_files_in_recursive_copy( file_system .copy( - &PathUri::from_path(&source_dir)?, - &PathUri::from_path(&copied_dir)?, + &PathUri::from_host_native_path(&source_dir)?, + &PathUri::from_host_native_path(&copied_dir)?, CopyOptions { recursive: true }, /*sandbox*/ None, ) @@ -837,8 +846,8 @@ async fn file_system_copy_rejects_standalone_fifo_source( let error = file_system .copy( - &PathUri::from_path(&fifo_path)?, - &PathUri::from_path(tmp.path().join("copied"))?, + &PathUri::from_host_native_path(&fifo_path)?, + &PathUri::from_host_native_path(tmp.path().join("copied"))?, CopyOptions { recursive: false }, /*sandbox*/ None, ) diff --git a/codex-rs/exec-server/tests/file_system_windows.rs b/codex-rs/exec-server/tests/file_system_windows.rs index fbf8b07330..1d5b4a13fe 100644 --- a/codex-rs/exec-server/tests/file_system_windows.rs +++ b/codex-rs/exec-server/tests/file_system_windows.rs @@ -74,7 +74,10 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> let readable_file = readonly_dir.join("readable.txt"); std::fs::write(&readable_file, b"readable")?; let read_result = file_system - .read_file(&PathUri::from_path(&readable_file)?, Some(&sandbox)) + .read_file( + &PathUri::from_host_native_path(&readable_file)?, + Some(&sandbox), + ) .await; // Some local Windows hosts cannot create restricted tokens. Reaching that // error still proves the remote fs helper went through the Windows sandbox @@ -87,7 +90,7 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> let blocked_file = readonly_dir.join("blocked.txt"); let error = file_system .write_file( - &PathUri::from_path(&blocked_file)?, + &PathUri::from_host_native_path(&blocked_file)?, b"blocked".to_vec(), Some(&sandbox), ) @@ -104,7 +107,7 @@ async fn file_system_remote_fs_helper_respects_windows_sandbox_write_policy() -> fn read_only_sandbox_for_cwd(cwd: std::path::PathBuf) -> Result { Ok(FileSystemSandboxContext::from_legacy_sandbox_policy( SandboxPolicy::new_read_only_policy(), - PathUri::from_path(cwd)?, + PathUri::from_host_native_path(cwd)?, )?) } diff --git a/codex-rs/exec-server/tests/relay.rs b/codex-rs/exec-server/tests/relay.rs index c9f873c158..30ce1459b7 100644 --- a/codex-rs/exec-server/tests/relay.rs +++ b/codex-rs/exec-server/tests/relay.rs @@ -144,7 +144,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { .exec(ExecParams { process_id: ProcessId::from("proc-1"), argv: vec!["true".to_string()], - cwd: PathUri::from_path(std::env::current_dir()?)?, + cwd: PathUri::from_host_native_path(std::env::current_dir()?)?, env_policy: None, env: HashMap::new(), tty: false, @@ -167,7 +167,7 @@ async fn remote_environment_routes_encrypted_exec_server_rpc() -> Result<()> { std::fs::write(&large_file_path, &large_file_contents)?; let read_response = client .fs_read_file(FsReadFileParams { - path: PathUri::from_path(large_file_path)?, + path: PathUri::from_host_native_path(large_file_path)?, sandbox: None, }) .await?; diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index 94aa45c1e7..dfd2420dbd 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -488,7 +488,7 @@ impl ExecutorStdioServerLauncher { // before sending an executor request. let argv = Self::process_api_argv(&program, &args).map_err(io::Error::other)?; let env = Self::process_api_env(envs).map_err(io::Error::other)?; - let cwd = PathUri::from_path(cwd)?; + let cwd = PathUri::from_host_native_path(cwd)?; let process_id = ExecutorProcessTransport::next_process_id(); // Start the MCP server process on the executor with raw pipes. `tty=false` // keeps stdout as a clean protocol stream, while `pipe_stdin=true` lets diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 5ef1df2fa2..6c92c5e1df 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -121,7 +121,7 @@ impl PathUri { /// Relative paths are reported as invalid input. Absolute paths without a /// valid URI representation use the fallback documented on /// [`Self::from_abs_path`]. - pub fn from_path(path: impl AsRef) -> io::Result { + pub fn from_host_native_path(path: impl AsRef) -> io::Result { let path = AbsolutePathBuf::from_absolute_path_checked(path) .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; Ok(Self::from_abs_path(&path)) diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 075d343d01..0e45baaa12 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -423,7 +423,8 @@ fn path_uri_deserializes_legacy_absolute_paths() { #[test] fn path_uri_rejects_relative_native_paths() { - let error = PathUri::from_path("src/lib.rs").expect_err("relative path should be rejected"); + let error = + PathUri::from_host_native_path("src/lib.rs").expect_err("relative path should be rejected"); assert_eq!(error.kind(), io::ErrorKind::InvalidInput); } From 97dc6abb11a1e5e15acb5b8a499e853418ea4902 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 22 Jun 2026 17:19:03 -0700 Subject: [PATCH 61/86] fix: world state response item test (#29504) seems to be a merge conflict on main: > pakrym-oai introduced the stale initializer in commit 3b32d861c5, PR #29249. > Context: Owen Lin renamed metadata to internal_chat_message_metadata_passthrough in PR #28968. PR #29249 then landed afterward with the old field name, causing the compile/Clippy failure. --- codex-rs/core/src/context/world_state/environment_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codex-rs/core/src/context/world_state/environment_tests.rs b/codex-rs/core/src/context/world_state/environment_tests.rs index 77a3adde17..961ee5ed73 100644 --- a/codex-rs/core/src/context/world_state/environment_tests.rs +++ b/codex-rs/core/src/context/world_state/environment_tests.rs @@ -251,6 +251,6 @@ fn user_message(text: &str) -> ResponseItem { text: text.to_string(), }], phase: None, - metadata: None, + internal_chat_message_metadata_passthrough: None, } } From fbd575ab4a8f786a535223856e4b8fd8beb29458 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 17:31:14 -0700 Subject: [PATCH 62/86] TUI Plugin Sharing 4 - cover remote plugin catalog flows (#26704) Remote plugin catalogs now span workspace, shared, and local sources, so their TUI behavior needs focused regression coverage across loading, navigation, actions, and refreshes. This PR: - Covers product labels and rendered loading/error states for Workspace, Shared with me, Shared with me (link), and Local tabs, including tab persistence across refresh and detail navigation. - Covers remote/local deduplication, Installed-tab remote detail routing, marketplace load-error handling, and disabled install/uninstall navigation. - Adds a full detail snapshot for local shared-plugin metadata plus focused snapshots for marketplace labels and admin-disabled status. - Verifies shared plugins remain eligible for mentions and successful uninstalls trigger a catalog refresh. --- codex-rs/tui/src/app/plugin_mentions.rs | 52 +++- codex-rs/tui/src/app/tests.rs | 1 + codex-rs/tui/src/app/tests/plugin_catalog.rs | 49 +++ ...in_detail_popup_local_share_read_only.snap | 19 ++ ...ts__plugins_popup_curated_marketplace.snap | 4 +- ..._tests__plugins_popup_search_filtered.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 2 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 72 ++++- .../chatwidget/tests/plugin_catalog_tests.rs | 288 ++++++++++++++++++ .../chatwidget/tests/popups_and_settings.rs | 204 ++++++++----- codex-rs/tui/src/custom_terminal.rs | 2 +- codex-rs/tui/src/tui.rs | 2 + codex-rs/tui/src/tui/test_support.rs | 28 ++ 13 files changed, 643 insertions(+), 82 deletions(-) create mode 100644 codex-rs/tui/src/app/tests/plugin_catalog.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap create mode 100644 codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs create mode 100644 codex-rs/tui/src/tui/test_support.rs diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index 5d94f51c08..f12dee72cc 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -90,12 +90,15 @@ mod tests { use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListResponse; use codex_app_server_protocol::PluginMarketplaceEntry; + use codex_app_server_protocol::PluginShareContext; + use codex_app_server_protocol::PluginShareDiscoverability; use codex_app_server_protocol::PluginSource; use pretty_assertions::assert_eq; #[test] fn plugin_mentions_use_plugin_list_summaries_and_gui_eligibility() { let active = plugin_summary("active"); + let active_shared = shared_plugin_summary("active-shared"); let mut disabled_by_admin = plugin_summary("disabled-by-admin"); disabled_by_admin.availability = PluginAvailability::DisabledByAdmin; let mut disabled = plugin_summary("disabled"); @@ -108,7 +111,13 @@ mod tests { name: "server-marketplace".to_string(), path: None, interface: None, - plugins: vec![active, disabled_by_admin, disabled, uninstalled], + plugins: vec![ + active, + active_shared, + disabled_by_admin, + disabled, + uninstalled, + ], }], marketplace_load_errors: Vec::new(), featured_plugin_ids: Vec::new(), @@ -116,17 +125,42 @@ mod tests { assert_eq!( plugin_mentions_from_list_response(response), - vec![PluginCapabilitySummary { - config_name: "active@server-marketplace".to_string(), - display_name: "active".to_string(), - description: Some("server-marketplace".to_string()), - has_skills: false, - mcp_server_names: Vec::new(), - app_connector_ids: Vec::new(), - }] + vec![ + PluginCapabilitySummary { + config_name: "active@server-marketplace".to_string(), + display_name: "active".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + }, + PluginCapabilitySummary { + config_name: "active-shared@server-marketplace".to_string(), + display_name: "active-shared".to_string(), + description: Some("server-marketplace".to_string()), + has_skills: false, + mcp_server_names: Vec::new(), + app_connector_ids: Vec::new(), + } + ] ); } + fn shared_plugin_summary(name: &str) -> PluginSummary { + PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: format!("plugins~{name}"), + remote_version: Some("7".to_string()), + discoverability: Some(PluginShareDiscoverability::Private), + share_url: Some(format!("https://chatgpt.com/codex/plugins/share/{name}")), + creator_account_user_id: None, + creator_name: Some("Test User".to_string()), + share_principals: None, + }), + ..plugin_summary(name) + } + } + fn plugin_summary(name: &str) -> PluginSummary { PluginSummary { id: format!("{name}@server-marketplace"), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0fc561cb2a..6c55accab6 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1,6 +1,7 @@ //! App-level orchestration tests for the TUI. mod model_catalog; +mod plugin_catalog; mod session_summary; mod startup; diff --git a/codex-rs/tui/src/app/tests/plugin_catalog.rs b/codex-rs/tui/src/app/tests/plugin_catalog.rs new file mode 100644 index 0000000000..251b8817bd --- /dev/null +++ b/codex-rs/tui/src/app/tests/plugin_catalog.rs @@ -0,0 +1,49 @@ +use std::time::Duration; + +use codex_app_server_protocol::PluginUninstallResponse; + +use super::*; + +#[tokio::test] +async fn successful_plugin_uninstall_dispatches_plugin_list_refresh() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let cwd = app.chat_widget.config_ref().cwd.to_path_buf(); + while app_event_rx.try_recv().is_ok() {} + + let mut tui = crate::tui::test_support::make_test_tui()?; + let mut app_server = Box::pin(crate::start_embedded_app_server_for_picker( + app.chat_widget.config_ref(), + )) + .await?; + let control = Box::pin(app.handle_event( + &mut tui, + &mut app_server, + AppEvent::PluginUninstallLoaded { + cwd: cwd.clone(), + plugin_id: "plugin-docs".to_string(), + plugin_display_name: "Docs".to_string(), + result: Ok(PluginUninstallResponse {}), + }, + )) + .await?; + assert!(matches!(control, AppRunControl::Continue)); + + let refresh_result = tokio::time::timeout(Duration::from_secs(5), async { + loop { + match app_event_rx.recv().await { + Some(AppEvent::PluginsLoaded { + cwd: event_cwd, + result, + }) if event_cwd == cwd => break result, + Some(_) => {} + None => panic!("app event channel closed before plugin refresh completed"), + } + } + }) + .await + .expect("dispatcher should initiate a plugin list refresh"); + refresh_result.expect("plugin list refresh should succeed"); + + app_server.shutdown().await?; + Ok(()) +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap new file mode 100644 index 0000000000..31ff7ba462 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_local_share_read_only.snap @@ -0,0 +1,19 @@ +--- +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) +--- + Plugins + Docs · Can be installed · Local + Data shared with this app is subject to the app's terms of service and privacy policy. Learn more. + Workspace docs. + +› 1. Back to plugins Return to the plugin list. + 2. Install plugin Install this plugin now. + Source Local + Auth Auth on install + Version remote 7 + Sharing Private · creator Test User · https://chatgpt.com/codex/plugins/share/docs + Skills No plugin skills. + Hooks No plugin hooks. + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap index 9f828ab3bc..2ec91937fb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_curated_marketplace.snap @@ -6,12 +6,12 @@ expression: popup Browse plugins from available marketplaces. Installed 1 of 4 available plugins. - [All Plugins] Installed (1) OpenAI Curated Repo Marketplace Add Marketplace + [All Plugins] Installed (1) OpenAI Curated Workspace Shared with me Repo Marketplace Add Marketplace Type to search plugins › [ ] Alpha Sync Disabled Space to enable; Enter view details. [-] Bravo Search Available · OpenAI Curated · Search docs and tickets. - [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugi… + [-] Hidden Repo Plugin Available · Repo Marketplace · Should not be shown in /plugins. [-] Starter Available by default · OpenAI Curated · Included by default. space enable/disable · ←/→ select marketplace · enter view details · esc close diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap index 849b90b2ce..bfb0affb21 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_search_filtered.snap @@ -7,7 +7,7 @@ expression: popup Browse plugins from available marketplaces. Installed 0 of 3 available plugins. - [All Plugins] Installed (0) OpenAI Curated Add Marketplace + [All Plugins] Installed (0) OpenAI Curated Workspace Shared with me Add Marketplace sla › [-] Slack Available Press Enter to install or view plugin details. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e4196224dd..1af814eb76 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -244,6 +244,8 @@ mod history_replay; mod mcp_startup; mod permissions; mod plan_mode; +#[path = "tests/plugin_catalog_tests.rs"] +mod plugin_catalog; mod popups_and_settings; mod review_mode; mod side; diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index abedbe567f..06c440bfb2 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1284,6 +1284,13 @@ pub(super) fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { .abs() } +pub(super) fn plugins_test_personal_marketplace_path() -> AbsolutePathBuf { + dirs::home_dir() + .expect("home directory should be available") + .join(".agents/plugins/marketplace.json") + .abs() +} + pub(super) fn plugins_test_interface( display_name: Option<&str>, short_description: Option<&str>, @@ -1372,6 +1379,21 @@ pub(super) fn plugins_test_remote_summary( } } +pub(super) fn plugins_test_remote_marketplace( + name: &str, + display_name: &str, + plugins: Vec, +) -> PluginMarketplaceEntry { + PluginMarketplaceEntry { + name: name.to_string(), + path: None, + interface: Some(MarketplaceInterface { + display_name: Some(display_name.to_string()), + }), + plugins, + } +} + pub(super) fn plugins_test_curated_marketplace( plugins: Vec, ) -> PluginMarketplaceEntry { @@ -1408,11 +1430,23 @@ pub(super) fn plugins_test_response( pub(super) fn render_loaded_plugins_popup( chat: &mut ChatWidget, - response: PluginListResponse, + mut response: PluginListResponse, ) -> String { let cwd = chat.config.cwd.clone(); + let remote_marketplaces = response + .marketplaces + .iter() + .filter(|marketplace| marketplace.path.is_none()) + .cloned() + .collect(); + response + .marketplaces + .retain(|marketplace| marketplace.path.is_some()); + let response_for_refresh = response.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response_for_refresh)); + chat.on_plugin_remote_sections_loaded(cwd.to_path_buf(), remote_marketplaces, Vec::new()); render_bottom_popup(chat, /*width*/ 100) } @@ -1470,12 +1504,48 @@ pub(super) fn plugins_test_detail( } } +pub(super) fn plugins_test_remote_detail( + marketplace_name: &str, + summary: PluginSummary, + description: Option<&str>, +) -> PluginDetail { + PluginDetail { + marketplace_name: marketplace_name.to_string(), + marketplace_path: None, + summary, + share_url: None, + description: description.map(str::to_string), + skills: Vec::new(), + hooks: Vec::new(), + apps: Vec::new(), + app_templates: Vec::new(), + mcp_servers: Vec::new(), + } +} + pub(super) fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { popup .find(needle) .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}")) } +pub(super) fn select_plugins_tab_containing( + chat: &mut ChatWidget, + width: u16, + visible_text: &str, +) -> String { + for _ in 0..8 { + let popup = render_bottom_popup(chat, width); + if popup.contains(visible_text) { + return popup; + } + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + } + + let popup = render_bottom_popup(chat, width); + panic!("expected plugins tab containing {visible_text:?}, got:\n{popup}"); +} + pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { for ch in query.chars() { chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); diff --git a/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs b/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs new file mode 100644 index 0000000000..b133c7ed04 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/plugin_catalog_tests.rs @@ -0,0 +1,288 @@ +use super::*; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn plugins_popup_uses_product_labels_for_remote_and_personal_tabs() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-directory", + "Raw Workspace Directory", + vec![plugins_test_remote_summary( + "plugins~Plugin_buildkite", + "buildkite", + Some("Buildkite"), + Some("Workspace CI."), + /*installed*/ false, + )], + ), + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Raw Shared Private", + vec![plugins_test_remote_summary( + "plugins~Plugin_docs", + "docs", + Some("Docs"), + Some("Shared docs."), + /*installed*/ false, + )], + ), + plugins_test_remote_marketplace( + "workspace-shared-with-me-unlisted", + "Raw Shared Link", + vec![plugins_test_remote_summary( + "plugins~Plugin_link", + "link", + Some("Link Share"), + Some("Shared by link."), + /*installed*/ false, + )], + ), + PluginMarketplaceEntry { + name: "codex-curated".to_string(), + path: Some(plugins_test_personal_marketplace_path()), + interface: Some(MarketplaceInterface { + display_name: Some("Personal".to_string()), + }), + plugins: vec![plugins_test_summary( + "plugin-local-docs", + "local-docs", + Some("Local Docs"), + Some("Local editable docs."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + )], + }, + ]), + ); + + let rows = [ + ( + "[Workspace]", + "Workspace.", + "Buildkite", + "Raw Workspace Directory.", + ), + ( + "[Shared with me]", + "Shared with me.", + "Docs", + "Raw Shared Private.", + ), + ( + "[Shared with me (link)]", + "Shared with me (link).", + "Link Share", + "Raw Shared Link.", + ), + ("[Local]", "Local.", "Local Docs", "Personal."), + ] + .into_iter() + .map(|(selected_tab, product_label, plugin_name, raw_label)| { + let popup = select_plugins_tab_containing(&mut chat, /*width*/ 120, selected_tab); + assert!( + popup.contains(product_label) + && popup.contains(plugin_name) + && !popup.contains(raw_label), + "expected {selected_tab} to use its product label, got:\n{popup}" + ); + popup + .lines() + .find(|line| line.contains(plugin_name)) + .expect("expected plugin row") + .split_whitespace() + .collect::>() + .join(" ") + }) + .collect::>() + .join("\n"); + + insta::assert_snapshot!( + rows, + @r###" + › [-] Buildkite Available Press Enter to install or view plugin details. + › [-] Docs Available Press Enter to install or view plugin details. + › [-] Link Share Available Press Enter to install or view plugin details. + › [-] Local Docs Available Press Enter to install or view plugin details. + "### + ); +} + +#[tokio::test] +async fn plugins_popup_preserves_workspace_tab_across_load_and_detail_navigation() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let workspace_marketplace = plugins_test_remote_marketplace( + "workspace-directory", + "Raw Workspace Directory", + vec![plugins_test_remote_summary( + "plugins~Plugin_buildkite", + "buildkite", + Some("Buildkite"), + Some("Buildkite pipelines."), + /*installed*/ false, + )], + ); + chat.add_plugins_output(); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + ])), + ); + let loading_popup = + select_plugins_tab_containing(&mut chat, /*width*/ 100, "Loading Workspace plugins."); + assert!( + loading_popup.contains("Loading Workspace plugins."), + "expected Workspace loading tab before remote sections resolve, got:\n{loading_popup}" + ); + + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + vec![workspace_marketplace.clone()], + Vec::new(), + ); + let workspace_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + workspace_popup.contains("Workspace.") + && workspace_popup.contains("Buildkite") + && !workspace_popup.contains("Loading Workspace plugins."), + "expected remote section refresh to keep the Workspace tab active, got:\n{workspace_popup}" + ); + + chat.open_plugin_detail_loading_popup("Buildkite"); + chat.open_plugins_list( + cwd.to_path_buf(), + plugins_test_response(vec![ + plugins_test_curated_marketplace(Vec::new()), + workspace_marketplace, + ]), + ); + let reopened_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + reopened_popup.contains("Workspace.") && reopened_popup.contains("Buildkite"), + "expected Back to plugins to preserve the Workspace tab, got:\n{reopened_popup}" + ); +} + +#[tokio::test] +async fn plugins_popup_remote_local_dedupe_prefers_installed_remote_after_mapped_shares() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let remote_plugin_id = "plugins~Plugin_docs"; + let local_summary = PluginSummary { + remote_plugin_id: Some(remote_plugin_id.to_string()), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Local curated docs plugin."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let cwd = chat.config.cwd.clone(); + render_loaded_plugins_popup( + &mut chat, + plugins_test_response(vec![plugins_test_curated_marketplace(vec![local_summary])]), + ); + chat.on_plugin_remote_sections_loaded( + cwd.to_path_buf(), + vec![plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![plugins_test_remote_summary( + remote_plugin_id, + "docs", + Some("Docs"), + Some("Remote installed docs plugin."), + /*installed*/ true, + )], + )], + Vec::new(), + ); + let popup = render_bottom_popup(&chat, /*width*/ 100); + let PluginsCacheState::Ready(response) = &chat.plugins_cache else { + panic!("expected cached plugins after remote section refresh"); + }; + assert_eq!( + response + .marketplaces + .iter() + .map(|marketplace| marketplace.name.as_str()) + .collect::>(), + vec![ + OPENAI_CURATED_MARKETPLACE_NAME, + "workspace-shared-with-me-private" + ] + ); + let all_plugins_row = popup + .lines() + .find(|line| line.contains("Docs")) + .expect("expected all-plugins row"); + assert!( + popup.contains("Installed 1 of 1 available plugins."), + "expected header count to reflect deduped plugin rows, got:\n{popup}" + ); + assert!( + all_plugins_row.contains("Installed") + && !all_plugins_row.contains("Local curated docs plugin."), + "expected installed remote duplicate to win when local row is not a mapped share, got:\n{all_plugins_row}" + ); +} + +#[tokio::test] +async fn plugin_detail_not_installable_plugin_disables_install_action() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = plugins_test_summary( + "plugin-internal", + "internal", + Some("Internal"), + Some("Internal only."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::NotAvailable, + ); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded( + cwd.to_path_buf(), + Ok(plugins_test_response(vec![ + plugins_test_curated_marketplace(vec![summary.clone()]), + ])), + ); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd.to_path_buf(), + Ok(PluginReadResponse { + plugin: plugins_test_detail(summary, Some("Internal only."), &[], &[], &[], &[]), + }), + ); + + let popup = render_bottom_popup(&chat, /*width*/ 100); + let install_row = popup + .lines() + .find(|line| line.contains("Install plugin")) + .expect("expected install row"); + assert!( + install_row.contains("This plugin is not installable from this marketplace."), + "expected disabled not-installable row, got:\n{install_row}" + ); + + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 100), + popup, + "expected navigation to skip the disabled install row" + ); +} diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 281d97305e..f97d93a0c8 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -5,9 +5,12 @@ use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; use codex_app_server_protocol::HooksListResponse; +use codex_app_server_protocol::MarketplaceLoadErrorInfo; use codex_app_server_protocol::MarketplaceRemoveResponse; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginShareContext; +use codex_app_server_protocol::PluginShareDiscoverability; +use codex_app_server_protocol::PluginSource; use codex_features::Stage; use pretty_assertions::assert_eq; @@ -197,7 +200,8 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ PluginInstallPolicy::Available, )]), ]); - let popup = render_loaded_plugins_popup(&mut chat, response); + render_loaded_plugins_popup(&mut chat, response); + let popup = render_bottom_popup(&chat, /*width*/ 320); assert_chatwidget_snapshot!("plugins_popup_curated_marketplace", popup); assert!( popup.contains("Hidden Repo Plugin"), @@ -214,6 +218,27 @@ async fn plugins_popup_snapshot_shows_all_marketplaces_and_sorts_installed_then_ ); } +#[tokio::test] +async fn plugins_popup_keeps_loaded_marketplace_state_with_load_errors() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let mut response = plugins_test_response(vec![plugins_test_curated_marketplace(Vec::new())]); + response + .marketplace_load_errors + .push(MarketplaceLoadErrorInfo { + marketplace_path: plugins_test_absolute_path("marketplaces/broken"), + message: "failed to load marketplace manifest".to_string(), + }); + + let popup = render_loaded_plugins_popup(&mut chat, response); + assert!( + popup.contains("No marketplace plugins available") + && !popup.contains("Marketplace unavailable"), + "expected /plugins to keep the loaded marketplace state, got:\n{popup}" + ); +} + #[tokio::test] async fn plugins_popup_truncates_long_descriptions_in_list_rows() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -272,14 +297,14 @@ async fn plugins_popup_add_marketplace_tab_opens_prompt_and_submits_source() { ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = select_plugins_tab_containing( + &mut chat, + /*width*/ 100, + "Add a marketplace from a Git repo or local root.", + ); assert!( popup.contains("Add a marketplace from a Git repo or local root."), - "expected Add Marketplace tab, got:\n{popup}" + "expected Add marketplace tab, got:\n{popup}" ); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -349,11 +374,7 @@ async fn plugins_popup_upgrades_user_configured_git_marketplace_from_marketplace ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace."); assert!( popup.contains("Repo Marketplace.") && popup.contains("ctrl + u upgrade") @@ -513,10 +534,8 @@ async fn plugins_popup_removes_user_configured_marketplace_flow() { ); while rx.try_recv().is_ok() {} - for _ in 0..3 { - chat.handle_key_event(KeyEvent::from(KeyCode::Right)); - } - let repo_tab = render_bottom_popup(&chat, /*width*/ 100); + let repo_tab = + select_plugins_tab_containing(&mut chat, /*width*/ 100, "Repo Marketplace."); assert!( repo_tab.contains("Repo Marketplace.") && repo_tab.contains("ctrl + u upgrade") @@ -839,31 +858,23 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded( cwd.to_path_buf(), - Ok(plugins_test_response(vec![PluginMarketplaceEntry { - name: "workspace-shared-with-me-private".to_string(), - path: None, - interface: Some(MarketplaceInterface { - display_name: Some("Shared with me".to_string()), - }), - plugins: vec![summary.clone()], - }])), + Ok(plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![summary.clone()], + ), + ])), ); chat.add_plugins_output(); chat.on_plugin_detail_loaded( cwd.to_path_buf(), Ok(PluginReadResponse { - plugin: PluginDetail { - marketplace_name: "workspace-shared-with-me-private".to_string(), - marketplace_path: None, + plugin: plugins_test_remote_detail( + "workspace-shared-with-me-private", summary, - share_url: None, - description: Some("Installed shared Linear plugin.".to_string()), - skills: Vec::new(), - hooks: Vec::new(), - apps: Vec::new(), - app_templates: Vec::new(), - mcp_servers: Vec::new(), - }, + Some("Installed shared Linear plugin."), + ), }), ); @@ -894,7 +905,7 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() { #[tokio::test] async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { - let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); let summary = PluginSummary { @@ -912,31 +923,23 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded( cwd.to_path_buf(), - Ok(plugins_test_response(vec![PluginMarketplaceEntry { - name: "workspace-shared-with-me-private".to_string(), - path: None, - interface: Some(MarketplaceInterface { - display_name: Some("Shared with me".to_string()), - }), - plugins: vec![summary.clone()], - }])), + Ok(plugins_test_response(vec![ + plugins_test_remote_marketplace( + "workspace-shared-with-me-private", + "Shared with me", + vec![summary.clone()], + ), + ])), ); chat.add_plugins_output(); chat.on_plugin_detail_loaded( cwd.to_path_buf(), Ok(PluginReadResponse { - plugin: PluginDetail { - marketplace_name: "workspace-shared-with-me-private".to_string(), - marketplace_path: None, + plugin: plugins_test_remote_detail( + "workspace-shared-with-me-private", summary, - share_url: None, - description: Some("Installed shared Linear plugin.".to_string()), - skills: Vec::new(), - hooks: Vec::new(), - apps: Vec::new(), - app_templates: Vec::new(), - mcp_servers: Vec::new(), - }, + Some("Installed shared Linear plugin."), + ), }), ); @@ -947,15 +950,58 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() { "expected missing remote ID to disable uninstall, got:\n{popup}" ); - while rx.try_recv().is_ok() {} - assert!( - rx.try_recv().is_err(), - "expected no action after rendering disabled uninstall state" + chat.handle_key_event(KeyEvent::from(KeyCode::Down)); + assert_eq!( + render_bottom_popup(&chat, /*width*/ 120), + popup, + "expected navigation to skip the disabled uninstall row" ); } #[tokio::test] -async fn plugin_detail_admin_disabled_plugin_blocks_install() { +async fn plugin_detail_popup_shows_local_share_context_as_read_only_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); + + let summary = PluginSummary { + share_context: Some(PluginShareContext { + remote_plugin_id: "plugins~Plugin_docs".to_string(), + remote_version: Some("7".to_string()), + discoverability: Some(PluginShareDiscoverability::Private), + share_url: Some("https://chatgpt.com/codex/plugins/share/docs".to_string()), + creator_account_user_id: None, + creator_name: Some("Test User".to_string()), + share_principals: None, + }), + ..plugins_test_summary( + "plugin-docs", + "docs", + Some("Docs"), + Some("Workspace docs."), + /*installed*/ false, + /*enabled*/ true, + PluginInstallPolicy::Available, + ) + }; + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); + chat.add_plugins_output(); + let mut detail = plugins_test_detail(summary, Some("Workspace docs."), &[], &[], &[], &[]); + detail.marketplace_path = Some(plugins_test_personal_marketplace_path()); + chat.on_plugin_detail_loaded(cwd.to_path_buf(), Ok(PluginReadResponse { plugin: detail })); + + let popup = render_bottom_popup(&chat, /*width*/ 120); + assert_chatwidget_snapshot!( + "plugin_detail_popup_local_share_read_only", + strip_osc8_for_snapshot(&popup) + ); +} + +#[tokio::test] +async fn plugin_detail_popup_shows_admin_disabled_status_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::Plugins, /*enabled*/ true); @@ -984,10 +1030,17 @@ async fn plugin_detail_admin_disabled_plugin_blocks_install() { }), ); - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = render_bottom_popup(&chat, /*width*/ 120); + let status_row = popup + .lines() + .find(|line| line.contains("Disabled by admin")) + .expect("expected admin-disabled status row"); + insta::assert_snapshot!( + status_row, + @" Admin Blocked · Disabled by admin · ChatGPT Marketplace" + ); assert!( - popup.contains("Admin Blocked · Disabled by admin") - && popup.contains("This plugin is disabled by your workspace admin.") + popup.contains("This plugin is disabled by your workspace admin.") && !popup.contains("Install this plugin now."), "expected admin-disabled detail to block install, got:\n{popup}" ); @@ -1021,18 +1074,18 @@ async fn plugins_popup_admin_disabled_installed_plugin_has_no_toggle_hint() { plugins_test_response(vec![plugins_test_curated_marketplace(vec![summary])]), ); - let popup = render_bottom_popup(&chat, /*width*/ 100); + let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( popup.contains("Disabled by admin") && popup.contains("Press Enter to view plugin details.") && !popup.contains("Space to disable"), - "expected admin-disabled installed plugin to omit toggle hint, got:\n{popup}" + "expected admin-disabled installed row to omit toggle hint, got:\n{popup}" ); while rx.try_recv().is_ok() {} - let before = render_bottom_popup(&chat, /*width*/ 100); + let before = render_bottom_popup(&chat, /*width*/ 120); chat.handle_key_event(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); - let after = render_bottom_popup(&chat, /*width*/ 100); + let after = render_bottom_popup(&chat, /*width*/ 120); assert!( rx.try_recv().is_err(), "space should not toggle admin-disabled installed plugins" @@ -1232,6 +1285,14 @@ async fn plugins_popup_installed_remote_row_keeps_remote_detail_when_local_share "expected installed remote duplicate to win over local mapped share, got:\n{popup}" ); + chat.handle_key_event(KeyEvent::from(KeyCode::Right)); + let installed_popup = render_bottom_popup(&chat, /*width*/ 100); + assert!( + installed_popup.contains("Showing 1 installed plugins.") + && installed_popup.contains("Docs"), + "expected installed remote duplicate in the Installed tab, got:\n{installed_popup}" + ); + while rx.try_recv().is_ok() {} chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); @@ -1765,7 +1826,14 @@ async fn plugins_popup_refresh_preserves_duplicate_marketplace_tab_by_path() { chat.on_plugins_loaded(cwd.clone(), Ok(response.clone())); chat.add_plugins_output(); - for _ in 0..4 { + for _ in 0..10 { + let popup = render_bottom_popup(&chat, /*width*/ 100); + if popup.contains("Duplicate Marketplace (2/2).") + && popup.contains("Repo Plugin") + && !popup.contains("Home Plugin") + { + break; + } chat.handle_key_event(KeyEvent::from(KeyCode::Right)); } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 995a0358f6..4800103915 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -223,7 +223,7 @@ where )) } - fn with_screen_size_and_cursor_position( + pub(crate) fn with_screen_size_and_cursor_position( backend: B, screen_size: Size, cursor_pos: Position, diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index a0f7ade044..7bf72f7cdf 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -60,6 +60,8 @@ mod frame_requester; mod job_control; mod keyboard_modes; mod terminal_stderr; +#[cfg(test)] +pub(crate) mod test_support; /// Target frame interval for UI redraw scheduling. pub(crate) const TARGET_FRAME_INTERVAL: Duration = frame_rate_limiter::MIN_FRAME_INTERVAL; diff --git a/codex-rs/tui/src/tui/test_support.rs b/codex-rs/tui/src/tui/test_support.rs new file mode 100644 index 0000000000..5c1f30eec0 --- /dev/null +++ b/codex-rs/tui/src/tui/test_support.rs @@ -0,0 +1,28 @@ +use std::io; +use std::io::stdout; + +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Position; +use ratatui::layout::Size; + +use super::Tui; +use super::terminal_stderr::TerminalStderrGuard; +use crate::custom_terminal::Terminal; + +pub(crate) fn make_test_tui() -> io::Result { + let backend = CrosstermBackend::new(stdout()); + let terminal = Terminal::with_screen_size_and_cursor_position( + backend, + Size { + width: 80, + height: 24, + }, + Position { x: 0, y: 0 }, + ); + let stderr_guard = TerminalStderrGuard::install()?; + Ok(Tui::new( + terminal, + /*enhanced_keys_supported*/ false, + stderr_guard, + )) +} From b294638bb557afe3e8bd1d82aa3ea9f459b2747e Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 17:43:56 -0700 Subject: [PATCH 63/86] [codex] reject remote images at app-server ingress (#29419) ## Stack Stacked on #29417. Review and land that PR first. ## Summary - reject HTTP(S) image URLs in the handlers for `turn/start` and `turn/steer` - validate `thread/inject_items` after its existing JSON-to-`ResponseItem` conversion, so each item is deserialized once - turn invalid dynamic-tool image responses into the existing unsuccessful text fallback; the model receives the validation message as the function output - leave `thread/resume.history` compatible with legacy history; #29417 replaces remote images before model input - continue accepting inline data URLs and `localImage` inputs - keep this policy in app-server; this PR does not add a shared protocol API or change core image preparation ## Test plan - `just test -p codex-app-server -E 'test(/request_handlers_reject_remote_image_urls|dynamic_tool_remote_image_response_becomes_model_visible_error|dynamic_tool_call_round_trip_sends_content_items_to_model|turn_start_tracks_turn_event_analytics|standalone_image_edit_uses_recent_pathless_image/)'` (5 passed) - `just fix -p codex-app-server` - `just fmt` --- codex-rs/app-server/README.md | 8 +- codex-rs/app-server/src/dynamic_tools.rs | 17 +++ codex-rs/app-server/src/image_url.rs | 8 ++ codex-rs/app-server/src/lib.rs | 1 + .../src/request_processors/turn_processor.rs | 59 ++++++++++ .../tests/suite/v2/dynamic_tools.rs | 105 +++++++++++++++-- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/request_validation.rs | 110 ++++++++++++++++++ .../app-server/tests/suite/v2/turn_start.rs | 3 +- 9 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 codex-rs/app-server/src/image_url.rs create mode 100644 codex-rs/app-server/tests/suite/v2/request_validation.rs diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 287b4f89ce..5f25914e4e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -714,9 +714,11 @@ If the thread does not already have an active turn, the server starts a standalo Turns attach user input (text or images) to a thread and trigger Codex generation. The `input` field is a list of discriminated unions: - `{"type":"text","text":"Explain this diff"}` -- `{"type":"image","url":"https://…png"}` +- `{"type":"image","url":"data:image/png;base64,…"}` - `{"type":"localImage","path":"/tmp/screenshot.png"}` +The `image` variant accepts inline data URLs. Remote HTTP(S) image URLs are rejected; use a data URL or `localImage` instead. + You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. `approvalsReviewer` accepts: @@ -828,7 +830,7 @@ Invoke a plugin by including a UI mention token such as `@sample` in the text in ### Example: Inject raw history items -Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. +Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests. Any `input_image` items must use inline data URLs; remote HTTP(S) image URLs are rejected. ```json { "method": "thread/inject_items", "id": 36, "params": { @@ -1585,7 +1587,7 @@ The server also emits item lifecycle notifications around the request: 3. Client response. 4. `item/completed` with `item.type = "dynamicToolCall"`, final `status`, and the returned `contentItems`/`success`. -The client must respond with content items. Use `inputText` for text and `inputImage` for image URLs/data URLs: +The client must respond with content items. Use `inputText` for text and `inputImage` for inline data URLs. Remote HTTP(S) image URLs make the dynamic tool response invalid. ```json { diff --git a/codex-rs/app-server/src/dynamic_tools.rs b/codex-rs/app-server/src/dynamic_tools.rs index c5e7550d9f..0da9cad453 100644 --- a/codex-rs/app-server/src/dynamic_tools.rs +++ b/codex-rs/app-server/src/dynamic_tools.rs @@ -8,6 +8,8 @@ use std::sync::Arc; use tokio::sync::oneshot; use tracing::error; +use crate::image_url::REMOTE_IMAGE_URL_ERROR; +use crate::image_url::is_remote_image_url; use crate::outgoing_message::ClientRequestResult; use crate::server_request_error::is_turn_transition_server_request_error; @@ -54,6 +56,21 @@ pub(crate) async fn on_call_response( fn decode_response(value: serde_json::Value) -> (DynamicToolCallResponse, Option) { match serde_json::from_value::(value) { + Ok(response) + if response.content_items.iter().any(|item| { + matches!( + item, + DynamicToolCallOutputContentItem::InputImage { image_url } + if is_remote_image_url(image_url) + ) + }) => + { + error!( + message = REMOTE_IMAGE_URL_ERROR, + "dynamic tool response was invalid" + ); + fallback_response(REMOTE_IMAGE_URL_ERROR) + } Ok(response) => (response, None), Err(err) => { error!("failed to deserialize DynamicToolCallResponse: {err}"); diff --git a/codex-rs/app-server/src/image_url.rs b/codex-rs/app-server/src/image_url.rs new file mode 100644 index 0000000000..d6e21f791d --- /dev/null +++ b/codex-rs/app-server/src/image_url.rs @@ -0,0 +1,8 @@ +pub(crate) const REMOTE_IMAGE_URL_ERROR: &str = + "remote image URLs are not supported; use an inline data URL instead"; + +pub(crate) fn is_remote_image_url(image_url: &str) -> bool { + image_url.split_once(':').is_some_and(|(scheme, _)| { + scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") + }) +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 264c28e098..9eb0009464 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -96,6 +96,7 @@ mod extensions; mod filters; mod fs_watch; mod fuzzy_file_search; +mod image_url; pub mod in_process; mod mcp_refresh; mod message_processor; diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 7b5cafe0aa..f7b4f7c14e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,14 +1,70 @@ use super::*; use codex_protocol::config_types::MultiAgentMode; +use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputContentItem; use codex_protocol::protocol::AdditionalContextEntry as CoreAdditionalContextEntry; use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; +use crate::image_url::REMOTE_IMAGE_URL_ERROR; +use crate::image_url::is_remote_image_url; + const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str = "direct app-server input is not allowed for multi-agent v2 sub-agents"; +fn validate_user_input_image_urls(input: &[V2UserInput]) -> Result<(), JSONRPCErrorError> { + if input.iter().any(|item| { + matches!( + item, + V2UserInput::Image { url, .. } if is_remote_image_url(url) + ) + }) { + return Err(invalid_request(REMOTE_IMAGE_URL_ERROR)); + } + Ok(()) +} + +fn validate_response_item_image_urls(items: &[ResponseItem]) -> Result<(), JSONRPCErrorError> { + if items.iter().any(|item| match item { + ResponseItem::Message { content, .. } => content.iter().any(|item| { + matches!( + item, + ContentItem::InputImage { image_url, .. } if is_remote_image_url(image_url) + ) + }), + ResponseItem::FunctionCallOutput { output, .. } + | ResponseItem::CustomToolCallOutput { output, .. } => { + output.content_items().is_some_and(|content| { + content.iter().any(|item| { + matches!( + item, + FunctionCallOutputContentItem::InputImage { image_url, .. } + if is_remote_image_url(image_url) + ) + }) + }) + } + ResponseItem::Reasoning { .. } + | ResponseItem::AgentMessage { .. } + | ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::CustomToolCall { .. } + | ResponseItem::ToolSearchOutput { .. } + | ResponseItem::WebSearchCall { .. } + | ResponseItem::ImageGenerationCall { .. } + | ResponseItem::Compaction { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::ContextCompaction { .. } + | ResponseItem::Other => false, + }) { + return Err(invalid_request(REMOTE_IMAGE_URL_ERROR)); + } + Ok(()) +} + #[derive(Clone)] pub(crate) struct TurnRequestProcessor { auth_manager: Arc, @@ -105,6 +161,7 @@ impl TurnRequestProcessor { app_server_client_version: Option, supports_openai_form_elicitation: bool, ) -> Result, JSONRPCErrorError> { + validate_user_input_image_urls(¶ms.input)?; self.turn_start_inner( request_id, params, @@ -140,6 +197,7 @@ impl TurnRequestProcessor { request_id: &ConnectionRequestId, params: TurnSteerParams, ) -> Result, JSONRPCErrorError> { + validate_user_input_image_urls(¶ms.input)?; self.turn_steer_inner(request_id, params) .await .map(|response| Some(response.into())) @@ -764,6 +822,7 @@ impl TurnRequestProcessor { }) .collect::, _>>() .map_err(invalid_request)?; + validate_response_item_image_urls(&items)?; thread .inject_response_items(items) diff --git a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs index 2adc2be0b2..1336a29e3d 100644 --- a/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs +++ b/codex-rs/app-server/tests/suite/v2/dynamic_tools.rs @@ -39,6 +39,8 @@ use tokio::time::timeout; use wiremock::MockServer; const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +const REMOTE_IMAGE_URL_ERROR: &str = + "remote image URLs are not supported; use an inline data URL instead"; // macOS and Windows Bazel CI can spend tens of seconds starting app-server // subprocesses or processing test RPCs under load. @@ -552,15 +554,19 @@ async fn dynamic_tool_call_round_trip_sends_text_content_items_to_model() -> Res Ok(()) } -/// Ensures dynamic tool call responses can include structured content items. -#[tokio::test] -async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> { - let call_id = "dyn-call-items-1"; +struct PendingDynamicToolCall { + mcp: TestAppServer, + server: MockServer, + request_id: RequestId, + params: DynamicToolCallParams, +} + +async fn start_function_dynamic_tool_call(call_id: &str) -> Result { let tool_name = "demo_tool"; let tool_args = json!({ "city": "Paris" }); let tool_call_arguments = serde_json::to_string(&tool_args)?; - let responses = vec![ + let response_sequence = vec![ responses::sse(vec![ responses::ev_response_created("resp-1"), responses::ev_function_call(call_id, tool_name, &tool_call_arguments), @@ -568,7 +574,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( ]), create_final_assistant_message_sse_response("Done")?, ]; - let server = create_mock_responses_server_sequence_unchecked(responses).await; + let server = create_mock_responses_server_sequence_unchecked(response_sequence).await; let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; @@ -632,20 +638,39 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( mcp.read_stream_until_request_message(), ) .await??; - let (request_id, params) = match request { + let (request_id, actual_params) = match request { ServerRequest::DynamicToolCall { request_id, params } => (request_id, params), other => panic!("expected DynamicToolCall request, got {other:?}"), }; - let expected = DynamicToolCallParams { + let params = DynamicToolCallParams { thread_id, - turn_id: turn_id.clone(), + turn_id, call_id: call_id.to_string(), namespace: None, tool: tool_name.to_string(), arguments: tool_args, }; - assert_eq!(params, expected); + assert_eq!(actual_params, params); + + Ok(PendingDynamicToolCall { + mcp, + server, + request_id, + params, + }) +} + +/// Ensures dynamic tool call responses can include structured content items. +#[tokio::test] +async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<()> { + let call_id = "dyn-call-items-1"; + let PendingDynamicToolCall { + mut mcp, + server, + request_id, + params, + } = start_function_dynamic_tool_call(call_id).await?; let response_content_items = vec![ DynamicToolCallOutputContentItem::InputText { @@ -678,8 +703,8 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( .await?; let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; - assert_eq!(completed.thread_id, expected.thread_id.clone()); - assert_eq!(completed.turn_id, turn_id); + assert_eq!(completed.thread_id, params.thread_id); + assert_eq!(completed.turn_id, params.turn_id); let ThreadItem::DynamicToolCall { status, content_items: completed_content_items, @@ -746,6 +771,62 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<( Ok(()) } +#[tokio::test] +async fn dynamic_tool_remote_image_response_becomes_model_visible_error() -> Result<()> { + let call_id = "dyn-call-remote-image"; + let PendingDynamicToolCall { + mut mcp, + server, + request_id, + params, + } = start_function_dynamic_tool_call(call_id).await?; + + let response = DynamicToolCallResponse { + content_items: vec![DynamicToolCallOutputContentItem::InputImage { + image_url: "https://example.com/tool.png".to_string(), + }], + success: true, + }; + mcp.send_response(request_id, serde_json::to_value(response)?) + .await?; + + let completed = wait_for_dynamic_tool_completed(&mut mcp, call_id).await?; + assert_eq!(completed.thread_id, params.thread_id); + assert_eq!(completed.turn_id, params.turn_id); + let ThreadItem::DynamicToolCall { + status, + content_items, + success, + .. + } = completed.item + else { + panic!("expected dynamic tool call item"); + }; + assert_eq!(status, DynamicToolCallStatus::Failed); + assert_eq!( + content_items, + Some(vec![DynamicToolCallOutputContentItem::InputText { + text: REMOTE_IMAGE_URL_ERROR.to_string(), + }]) + ); + assert_eq!(success, Some(false)); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let output = responses_bodies(&server) + .await? + .iter() + .find_map(|body| function_call_output_raw_output(body, call_id)) + .context("expected function_call_output output in follow-up request")?; + assert_eq!(output, json!(REMOTE_IMAGE_URL_ERROR)); + + Ok(()) +} + async fn responses_bodies(server: &MockServer) -> Result> { let requests = server .received_requests() diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index f4400da468..c777142e82 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -52,6 +52,7 @@ mod remote_control; mod remote_thread_store; mod request_permissions; mod request_user_input; +mod request_validation; mod review; mod safety_check_downgrade; mod skills_list; diff --git a/codex-rs/app-server/tests/suite/v2/request_validation.rs b/codex-rs/app-server/tests/suite/v2/request_validation.rs new file mode 100644 index 0000000000..c4382e63c2 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/request_validation.rs @@ -0,0 +1,110 @@ +use anyhow::Result; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_protocol::models::FunctionCallOutputContentItem; +use codex_protocol::models::FunctionCallOutputPayload; +use codex_protocol::models::ImageDetail; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::time::Duration; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); +const REMOTE_IMAGE_URL_ERROR: &str = + "remote image URLs are not supported; use an inline data URL instead"; + +#[tokio::test] +async fn request_handlers_reject_remote_image_urls() -> Result<()> { + let codex_home = TempDir::new()?; + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home.path(), + "http://localhost/unused", + "http://localhost/unused", + )?; + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_request_id = mcp + .send_thread_start_request(ThreadStartParams::default()) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_response)?; + let thread_id = thread.id; + + let remote_tool_output = serde_json::to_value(ResponseItem::FunctionCallOutput { + id: None, + call_id: "call-1".to_string(), + output: FunctionCallOutputPayload::from_content_items(vec![ + FunctionCallOutputContentItem::InputImage { + image_url: "https://example.com/tool.png".to_string(), + detail: Some(ImageDetail::High), + }, + ]), + internal_chat_message_metadata_passthrough: None, + })?; + let requests = [ + ( + "turn/start", + json!({ + "threadId": thread_id, + "input": [{ + "type": "image", + "url": "HTTP://example.com/start.png", + "detail": "high" + }] + }), + ), + ( + "turn/steer", + json!({ + "threadId": thread_id, + "expectedTurnId": "turn-id", + "input": [{ + "type": "image", + "url": "https://example.com/steer.png", + "detail": "high" + }] + }), + ), + ( + "thread/inject_items", + json!({ + "threadId": thread_id, + "items": [remote_tool_output] + }), + ), + ]; + + for (method, params) in requests { + let request_id = mcp.send_raw_request(method, Some(params)).await?; + let actual: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + let expected = JSONRPCError { + id: RequestId::Integer(request_id), + error: JSONRPCErrorError { + code: -32600, + data: None, + message: REMOTE_IMAGE_URL_ERROR.to_string(), + }, + }; + assert_eq!(actual, expected, "unexpected response for {method}"); + } + + Ok(()) +} diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3edd8da6fb..dd211a5d89 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -106,6 +106,7 @@ const TINY_PNG_BYTES: &[u8] = &[ 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73, 68, 65, 84, 120, 156, 99, 96, 0, 2, 0, 0, 5, 0, 1, 122, 94, 171, 63, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, ]; +const TINY_PNG_DATA_URL: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; fn body_contains(req: &wiremock::Request, text: &str) -> bool { String::from_utf8(req.body.clone()) @@ -888,7 +889,7 @@ async fn turn_start_tracks_turn_event_analytics() -> Result<()> { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![V2UserInput::Image { - url: "https://example.com/a.png".to_string(), + url: TINY_PNG_DATA_URL.to_string(), detail: None, }], responsesapi_client_metadata: Some(HashMap::from([( From e65e480e0d67ce5a32667ff3e89143be035a0606 Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 22 Jun 2026 17:53:09 -0700 Subject: [PATCH 64/86] chore: improve expired Bedrock credential errors (#28992) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Amazon Bedrock returns a `401 Unauthorized` response containing `Signature expired:` when an AWS credential, including a short-lived `AWS_BEARER_TOKEN_BEDROCK`, has expired. Codex currently surfaces that response as a generic `unexpected status` error, which does not explain how to recover. Environment-provided bearer tokens cannot be refreshed automatically, so the error should direct users to refresh their AWS credentials or replace or remove the environment token and restart Codex. This classification belongs to the Amazon Bedrock provider so similar responses from other providers retain their existing behavior. ## What changed - Add a synchronous `ModelProvider::map_api_error` hook that defaults to the existing provider-neutral API error mapping, and route model request, stream, WebSocket, and terminal unauthorized errors through the active provider. - Override the hook for Amazon Bedrock. After preserving the structured status, body, URL, and request metadata, recognize `401` responses containing `Signature expired:` and attach actionable credential guidance. - Keep `codex-protocol` provider-neutral by representing the guidance as an optional `user_message`. Error rendering prefers this message while continuing to append the URL, request ID, Cloudflare ray, and authorization diagnostics. - Add model-provider coverage for expired signatures and negative cases, core coverage for provider dispatch after unauthorized recovery, and a TUI snapshot for the rendered error. ## Testing Tested with a real request with expired bedrock key: Screenshot 2026-06-22 at 3 56 51 PM --- codex-rs/codex-api/src/api_bridge.rs | 36 ++++++--- codex-rs/codex-api/src/api_bridge_tests.rs | 28 +++++++ codex-rs/core/src/client.rs | 27 ++++--- codex-rs/core/src/client_tests.rs | 43 +++++++++++ .../src/amazon_bedrock/error.rs | 20 +++++ .../src/amazon_bedrock/error_tests.rs | 77 +++++++++++++++++++ .../model-provider/src/amazon_bedrock/mod.rs | 11 +++ codex-rs/model-provider/src/provider.rs | 7 ++ codex-rs/protocol/src/error.rs | 55 +++---------- codex-rs/protocol/src/error_tests.rs | 44 ++++++----- ...nt_bedrock_expired_signature_snapshot.snap | 5 ++ codex-rs/tui/src/history_cell/tests.rs | 27 +++++++ 12 files changed, 298 insertions(+), 82 deletions(-) create mode 100644 codex-rs/model-provider/src/amazon_bedrock/error.rs create mode 100644 codex-rs/model-provider/src/amazon_bedrock/error_tests.rs create mode 100644 codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 1c34d8bbf2..ab1b409eb1 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -23,15 +23,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr { ApiError::Retryable { message, delay } => CodexErr::Stream(message, delay), ApiError::Stream(msg) => CodexErr::Stream(msg, None), ApiError::ServerOverloaded => CodexErr::ServerOverloaded, - ApiError::Api { status, message } => CodexErr::UnexpectedStatus(UnexpectedResponseError { - status, - body: message, - url: None, - cf_ray: None, - request_id: None, - identity_authorization_error: None, - identity_error_code: None, - }), + ApiError::Api { status, message } => { + let user_message = api_error_user_message(status, &message); + CodexErr::UnexpectedStatus(UnexpectedResponseError { + status, + body: message, + user_message, + url: None, + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }) + } ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message }, ApiError::Transport(transport) => match transport { @@ -111,6 +115,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { } else { CodexErr::UnexpectedStatus(UnexpectedResponseError { status, + user_message: api_error_user_message(status, &body_text), body: body_text, url, cf_ray: extract_header(headers.as_ref(), CF_RAY_HEADER), @@ -145,6 +150,8 @@ const X_ERROR_JSON_HEADER: &str = "x-error-json"; const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy"; const CYBER_POLICY_FALLBACK_MESSAGE: &str = "This request has been flagged for possible cybersecurity risk."; +const CLOUDFLARE_BLOCKED_MESSAGE: &str = + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region"; #[cfg(test)] #[path = "api_bridge_tests.rs"] @@ -154,6 +161,17 @@ fn extract_request_tracking_id(headers: Option<&HeaderMap>) -> Option { extract_request_id(headers).or_else(|| extract_header(headers, CF_RAY_HEADER)) } +fn api_error_user_message(status: http::StatusCode, body: &str) -> Option { + if status == http::StatusCode::FORBIDDEN + && body.contains("Cloudflare") + && body.contains("blocked") + { + Some(format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})")) + } else { + None + } +} + fn extract_request_id(headers: Option<&HeaderMap>) -> Option { extract_header(headers, REQUEST_ID_HEADER) .or_else(|| extract_header(headers, OAI_REQUEST_ID_HEADER)) diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 101e5566fe..5812fc19fe 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -26,6 +26,34 @@ fn map_api_error_maps_server_overloaded_from_503_body() { assert!(matches!(err, CodexErr::ServerOverloaded)); } +#[test] +fn map_api_error_maps_cloudflare_blocked_response_to_user_message() { + let mut headers = HeaderMap::new(); + headers.insert(CF_RAY_HEADER, http::HeaderValue::from_static("ray-id")); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::FORBIDDEN, + url: Some("http://example.com/blocked".to_string()), + headers: Some(headers), + body: Some( + "Cloudflare error: Sorry, you have been blocked".to_string(), + ), + })); + + let CodexErr::UnexpectedStatus(err) = err else { + panic!("expected CodexErr::UnexpectedStatus, got {err:?}"); + }; + assert_eq!( + err.user_message.as_deref(), + Some( + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden)" + ) + ); + assert_eq!( + err.to_string(), + "Access blocked by Cloudflare. This usually happens when connecting from a restricted region (status 403 Forbidden), url: http://example.com/blocked, cf-ray: ray-id" + ); +} + #[test] fn map_api_error_maps_cyber_policy_from_400_body() { let body = serde_json::json!({ diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index c6cb92907f..8ebea984d6 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -112,7 +112,6 @@ use crate::feedback_tags; use crate::responses_metadata::CodexResponsesMetadata; use crate::responses_metadata::subagent_header_value; use crate::util::emit_feedback_auth_recovery_tags; -use codex_api::map_api_error; use codex_feedback::FeedbackRequestTags; use codex_feedback::emit_feedback_request_tags_with_auth_env; use codex_login::auth_env_telemetry::AuthEnvTelemetry; @@ -577,7 +576,7 @@ impl ModelClient { turn_state.as_deref(), ) .await - .map_err(map_api_error); + .map_err(|error| self.state.provider.map_api_error(error)); trace_attempt.record_result(result.as_deref()); result } @@ -604,7 +603,7 @@ impl ModelClient { let response = ApiRealtimeCallClient::new(transport, api_provider, client_setup.api_auth) .create_with_session_and_headers(sdp, session_config, extra_headers) .await - .map_err(map_api_error)?; + .map_err(|error| self.state.provider.map_api_error(error))?; Ok(RealtimeWebrtcCallStart { sdp: response.sdp, call_id: response.call_id, @@ -658,7 +657,7 @@ impl ModelClient { client .summarize_input(&payload, self.build_subagent_headers()) .await - .map_err(map_api_error) + .map_err(|error| self.state.provider.map_api_error(error)) } fn build_subagent_headers(&self) -> ApiHeaderMap { @@ -1337,6 +1336,7 @@ impl ModelClientSession { stream, session_telemetry.clone(), inference_trace_attempt, + Arc::clone(&self.client.state.provider), ); return Ok(stream); } @@ -1355,6 +1355,7 @@ impl ModelClientSession { unauthorized_transport, &mut auth_recovery, session_telemetry, + &self.client.state.provider, ) .await?, ); @@ -1363,7 +1364,7 @@ impl ModelClientSession { Err(err) => { let response_debug_context = extract_response_debug_context_from_api_error(&err); - let err = map_api_error(err); + let err = self.client.state.provider.map_api_error(err); inference_trace_attempt.record_failed( &err, response_debug_context.request_id.as_deref(), @@ -1469,12 +1470,13 @@ impl ModelClientSession { unauthorized_transport, &mut auth_recovery, session_telemetry, + &self.client.state.provider, ) .await?, ); continue; } - Err(err) => return Err(map_api_error(err)), + Err(err) => return Err(self.client.state.provider.map_api_error(err)), } let (mut ws_request, previous_response_id_from_untraced_warmup) = @@ -1503,7 +1505,7 @@ impl ModelClientSession { self.websocket_session.last_response_from_untraced_warmup = warmup; let websocket_connection = self.websocket_session.connection.as_ref().ok_or_else(|| { - map_api_error(ApiError::Stream( + self.client.state.provider.map_api_error(ApiError::Stream( "websocket connection is unavailable".to_string(), )) })?; @@ -1517,7 +1519,7 @@ impl ModelClientSession { .map_err(|err| { let response_debug_context = extract_response_debug_context_from_api_error(&err); - let err = map_api_error(err); + let err = self.client.state.provider.map_api_error(err); inference_trace_attempt.record_failed( &err, response_debug_context.request_id.as_deref(), @@ -1529,6 +1531,7 @@ impl ModelClientSession { stream_result, session_telemetry.clone(), inference_trace_attempt, + Arc::clone(&self.client.state.provider), ); self.websocket_session.last_response_rx = Some(last_request_rx); return Ok(WebsocketStreamOutcome::Stream(stream)); @@ -1761,6 +1764,7 @@ fn map_response_stream( api_stream: codex_api::ResponseStream, session_telemetry: SessionTelemetry, inference_trace_attempt: InferenceTraceAttempt, + provider: SharedModelProvider, ) -> (ResponseStream, oneshot::Receiver) { let codex_api::ResponseStream { rx_event, @@ -1775,6 +1779,7 @@ fn map_response_stream( api_stream, session_telemetry, inference_trace_attempt, + provider, ) } @@ -1783,6 +1788,7 @@ fn map_response_events( api_stream: S, session_telemetry: SessionTelemetry, inference_trace_attempt: InferenceTraceAttempt, + provider: SharedModelProvider, ) -> (ResponseStream, oneshot::Receiver) where S: futures::Stream> @@ -1893,7 +1899,7 @@ where if let Some(upstream_request_id) = upstream_request_id { feedback_tags!(last_model_request_id = upstream_request_id); } - let mapped = map_api_error(err); + let mapped = provider.map_api_error(err); inference_trace_attempt.record_failed( &mapped, upstream_request_id, @@ -1999,6 +2005,7 @@ async fn handle_unauthorized( transport: TransportError, auth_recovery: &mut Option, session_telemetry: &SessionTelemetry, + provider: &SharedModelProvider, ) -> Result { let debug = extract_response_debug_context(&transport); if let Some(recovery) = auth_recovery @@ -2108,7 +2115,7 @@ async fn handle_unauthorized( debug.auth_error_code.as_deref(), ); - Err(map_api_error(ApiError::Transport(transport))) + Err(provider.map_api_error(ApiError::Transport(transport))) } fn api_error_http_status(error: &ApiError) -> Option { diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 0584e290fc..9df7d4d01d 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -15,10 +15,13 @@ use crate::test_support::TestCodexResponsesRequestKind; use crate::test_support::responses_metadata as test_responses_metadata; use codex_api::ApiError; use codex_api::ResponseEvent; +use codex_api::TransportError; use codex_app_server_protocol::AuthMode; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_model_provider::BearerAuthProvider; +use codex_model_provider::SharedModelProvider; +use codex_model_provider::create_model_provider; use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::WireApi; @@ -81,6 +84,10 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { ) } +fn test_model_provider() -> SharedModelProvider { + test_model_client(SessionSource::Cli).state.provider.clone() +} + fn test_responses_metadata_for_client( client: &ModelClient, turn_id: Option<&str>, @@ -391,6 +398,7 @@ async fn dropped_response_stream_traces_cancelled_partial_output() -> anyhow::Re api_stream, test_session_telemetry(), attempt, + test_model_provider(), ); let observed = stream @@ -440,6 +448,7 @@ async fn response_stream_records_last_model_feedback_ids() { api_stream, test_session_telemetry(), InferenceTraceAttempt::disabled(), + test_model_provider(), ); while stream.next().await.is_some() {} @@ -455,6 +464,39 @@ async fn response_stream_records_last_model_feedback_ids() { ); } +#[tokio::test] +async fn bedrock_unauthorized_error_uses_provider_mapping() { + let provider = create_model_provider( + ModelProviderInfo::create_amazon_bedrock_provider(/*aws*/ None), + /*auth_manager*/ None, + ); + let mut auth_recovery = None; + let url = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses"; + let error = super::handle_unauthorized( + TransportError::Http { + status: http::StatusCode::UNAUTHORIZED, + url: Some(url.to_string()), + headers: None, + body: Some( + "Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z" + .to_string(), + ), + }, + &mut auth_recovery, + &test_session_telemetry(), + &provider, + ) + .await + .expect_err("expired Bedrock signature should fail"); + + assert_eq!( + error.to_string(), + format!( + "Amazon Bedrock rejected the request because its AWS signature has expired. Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or unset it, then restart Codex, url: {url}" + ) + ); +} + #[tokio::test] async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() -> anyhow::Result<()> { @@ -481,6 +523,7 @@ async fn dropped_backpressured_response_stream_traces_cancelled_partial_output() api_stream, test_session_telemetry(), attempt, + test_model_provider(), ); // Fill the mapper channel with non-terminal events, then yield one output diff --git a/codex-rs/model-provider/src/amazon_bedrock/error.rs b/codex-rs/model-provider/src/amazon_bedrock/error.rs new file mode 100644 index 0000000000..1e489352b9 --- /dev/null +++ b/codex-rs/model-provider/src/amazon_bedrock/error.rs @@ -0,0 +1,20 @@ +use codex_api::ApiError; +use codex_protocol::error::CodexErr; +use http::StatusCode; + +pub(super) const BEDROCK_EXPIRED_SIGNATURE_MESSAGE: &str = concat!( + "Amazon Bedrock rejected the request because its AWS signature has expired. ", + "Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, ", + "update or unset it, then restart Codex", +); + +pub(super) fn map_api_error(error: ApiError) -> CodexErr { + let mut error = codex_api::map_api_error(error); + if let CodexErr::UnexpectedStatus(response) = &mut error + && response.status == StatusCode::UNAUTHORIZED + && response.body.contains("Signature expired:") + { + response.user_message = Some(BEDROCK_EXPIRED_SIGNATURE_MESSAGE.to_string()); + } + error +} diff --git a/codex-rs/model-provider/src/amazon_bedrock/error_tests.rs b/codex-rs/model-provider/src/amazon_bedrock/error_tests.rs new file mode 100644 index 0000000000..50291c3eec --- /dev/null +++ b/codex-rs/model-provider/src/amazon_bedrock/error_tests.rs @@ -0,0 +1,77 @@ +use codex_api::ApiError; +use codex_api::TransportError; +use codex_protocol::error::CodexErr; +use http::HeaderMap; +use http::HeaderValue; +use http::StatusCode; +use pretty_assertions::assert_eq; + +use super::error::BEDROCK_EXPIRED_SIGNATURE_MESSAGE; +use super::error::map_api_error; + +const BEDROCK_RESPONSES_URL: &str = "https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses"; + +fn http_error(status: StatusCode, body: &str) -> ApiError { + let mut headers = HeaderMap::new(); + headers.insert("x-request-id", HeaderValue::from_static("req-bedrock")); + ApiError::Transport(TransportError::Http { + status, + url: Some(BEDROCK_RESPONSES_URL.to_string()), + headers: Some(headers), + body: Some(body.to_string()), + }) +} + +#[test] +fn expired_signature_has_actionable_guidance() { + let error = map_api_error(http_error( + StatusCode::UNAUTHORIZED, + "Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z", + )); + + let CodexErr::UnexpectedStatus(response) = &error else { + panic!("expected unexpected status error, got {error:?}"); + }; + assert_eq!( + response.user_message.as_deref(), + Some(BEDROCK_EXPIRED_SIGNATURE_MESSAGE) + ); + assert_eq!( + error.to_string(), + format!( + "{BEDROCK_EXPIRED_SIGNATURE_MESSAGE}, url: {BEDROCK_RESPONSES_URL}, request id: req-bedrock" + ) + ); +} + +#[test] +fn other_unauthorized_errors_remain_generic() { + let error = map_api_error(http_error( + StatusCode::UNAUTHORIZED, + "The security token included in the request is invalid", + )); + + let CodexErr::UnexpectedStatus(response) = &error else { + panic!("expected unexpected status error, got {error:?}"); + }; + assert_eq!(response.user_message, None); + assert_eq!( + error.to_string(), + format!( + "unexpected status 401 Unauthorized: The security token included in the request is invalid, url: {BEDROCK_RESPONSES_URL}, request id: req-bedrock" + ) + ); +} + +#[test] +fn signature_errors_with_other_statuses_remain_generic() { + let error = map_api_error(http_error( + StatusCode::FORBIDDEN, + "Signature expired: old is now earlier than new", + )); + + let CodexErr::UnexpectedStatus(response) = &error else { + panic!("expected unexpected status error, got {error:?}"); + }; + assert_eq!(response.user_message, None); +} diff --git a/codex-rs/model-provider/src/amazon_bedrock/mod.rs b/codex-rs/model-provider/src/amazon_bedrock/mod.rs index 68c94fe4d5..108308c1bf 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/mod.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/mod.rs @@ -1,10 +1,12 @@ mod auth; mod catalog; +mod error; mod mantle; use std::path::PathBuf; use std::sync::Arc; +use codex_api::ApiError; use codex_api::Provider; use codex_api::SharedAuthProvider; use codex_login::AuthManager; @@ -17,6 +19,7 @@ use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::manager::StaticModelsManager; use codex_protocol::account::AmazonBedrockCredentialSource; use codex_protocol::account::ProviderAccount; +use codex_protocol::error::CodexErr; use codex_protocol::error::Result; use codex_protocol::openai_models::ModelsResponse; @@ -142,6 +145,10 @@ impl ModelProvider for AmazonBedrockModelProvider { }) } + fn map_api_error(&self, error: ApiError) -> CodexErr { + error::map_api_error(error) + } + fn api_provider(&self) -> ModelProviderFuture<'_, Result> { Box::pin(AmazonBedrockModelProvider::api_provider(self)) } @@ -166,6 +173,10 @@ impl ModelProvider for AmazonBedrockModelProvider { } } +#[cfg(test)] +#[path = "error_tests.rs"] +mod error_tests; + #[cfg(test)] mod tests { use http::HeaderValue; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 3bd5031362..5df5a88ea1 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use std::pin::Pin; use std::sync::Arc; +use codex_api::ApiError; use codex_api::Provider; use codex_api::SharedAuthProvider; use codex_login::AuthManager; @@ -13,6 +14,7 @@ use codex_models_manager::manager::OpenAiModelsManager; use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::manager::StaticModelsManager; use codex_protocol::account::ProviderAccount; +use codex_protocol::error::CodexErr; use codex_protocol::openai_models::ModelsResponse; use crate::amazon_bedrock::AmazonBedrockModelProvider; @@ -142,6 +144,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync { /// Returns the current app-visible account state for this provider. fn account_state(&self) -> ProviderAccountResult; + /// Maps an API client error into the provider's user-facing error representation. + fn map_api_error(&self, error: ApiError) -> CodexErr { + codex_api::map_api_error(error) + } + /// Returns provider configuration adapted for the API client. fn api_provider(&self) -> ModelProviderFuture<'_, codex_protocol::error::Result> { Box::pin(async move { diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index d7e953af0b..a2ea0eb691 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -305,6 +305,7 @@ impl std::fmt::Display for ResponseStreamFailed { pub struct UnexpectedResponseError { pub status: StatusCode, pub body: String, + pub user_message: Option, pub url: Option, pub cf_ray: Option, pub request_id: Option, @@ -312,8 +313,6 @@ pub struct UnexpectedResponseError { pub identity_error_code: Option, } -const CLOUDFLARE_BLOCKED_MESSAGE: &str = - "Access blocked by Cloudflare. This usually happens when connecting from a restricted region"; const UNEXPECTED_RESPONSE_BODY_MAX_BYTES: usize = 1000; impl UnexpectedResponseError { @@ -343,18 +342,17 @@ impl UnexpectedResponseError { Some(message.to_string()) } } +} - fn friendly_message(&self) -> Option { - if self.status != StatusCode::FORBIDDEN { - return None; - } - - if !self.body.contains("Cloudflare") || !self.body.contains("blocked") { - return None; - } - - let status = self.status; - let mut message = format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status})"); +impl std::fmt::Display for UnexpectedResponseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut message = if let Some(user_message) = &self.user_message { + user_message.clone() + } else { + let status = self.status; + let body = self.display_body(); + format!("unexpected status {status}: {body}") + }; if let Some(url) = &self.url { message.push_str(&format!(", url: {url}")); } @@ -370,36 +368,7 @@ impl UnexpectedResponseError { if let Some(error_code) = &self.identity_error_code { message.push_str(&format!(", auth error code: {error_code}")); } - - Some(message) - } -} - -impl std::fmt::Display for UnexpectedResponseError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if let Some(friendly) = self.friendly_message() { - write!(f, "{friendly}") - } else { - let status = self.status; - let body = self.display_body(); - let mut message = format!("unexpected status {status}: {body}"); - if let Some(url) = &self.url { - message.push_str(&format!(", url: {url}")); - } - if let Some(cf_ray) = &self.cf_ray { - message.push_str(&format!(", cf-ray: {cf_ray}")); - } - if let Some(id) = &self.request_id { - message.push_str(&format!(", request id: {id}")); - } - if let Some(auth_error) = &self.identity_authorization_error { - message.push_str(&format!(", auth error: {auth_error}")); - } - if let Some(error_code) = &self.identity_error_code { - message.push_str(&format!(", auth error code: {error_code}")); - } - write!(f, "{message}") - } + write!(f, "{message}") } } diff --git a/codex-rs/protocol/src/error_tests.rs b/codex-rs/protocol/src/error_tests.rs index bd7a5f0058..0adbd029cd 100644 --- a/codex-rs/protocol/src/error_tests.rs +++ b/codex-rs/protocol/src/error_tests.rs @@ -400,31 +400,12 @@ fn usage_limit_reached_includes_minutes_when_available() { }); } -#[test] -fn unexpected_status_cloudflare_html_is_simplified() { - let err = UnexpectedResponseError { - status: StatusCode::FORBIDDEN, - body: "Cloudflare error: Sorry, you have been blocked" - .to_string(), - url: Some("http://example.com/blocked".to_string()), - cf_ray: Some("ray-id".to_string()), - request_id: None, - identity_authorization_error: None, - identity_error_code: None, - }; - let status = StatusCode::FORBIDDEN.to_string(); - let url = "http://example.com/blocked"; - assert_eq!( - err.to_string(), - format!("{CLOUDFLARE_BLOCKED_MESSAGE} (status {status}), url: {url}, cf-ray: ray-id") - ); -} - #[test] fn unexpected_status_non_html_is_unchanged() { let err = UnexpectedResponseError { status: StatusCode::FORBIDDEN, body: "plain text error".to_string(), + user_message: None, url: Some("http://example.com/plain".to_string()), cf_ray: None, request_id: None, @@ -439,12 +420,32 @@ fn unexpected_status_non_html_is_unchanged() { ); } +#[test] +fn unexpected_status_uses_user_message_and_preserves_response_context() { + let err = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "provider-specific response".to_string(), + user_message: Some("Provider-specific guidance".to_string()), + url: Some("https://example.com/v1/responses".to_string()), + cf_ray: None, + request_id: Some("req-provider".to_string()), + identity_authorization_error: None, + identity_error_code: None, + }; + + assert_eq!( + err.to_string(), + "Provider-specific guidance, url: https://example.com/v1/responses, request id: req-provider" + ); +} + #[test] fn unexpected_status_prefers_error_message_when_present() { let err = UnexpectedResponseError { status: StatusCode::UNAUTHORIZED, body: r#"{"error":{"message":"Workspace is not authorized in this region."},"status":401}"# .to_string(), + user_message: None, url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), cf_ray: None, request_id: Some("req-123".to_string()), @@ -466,6 +467,7 @@ fn unexpected_status_truncates_long_body_with_ellipsis() { let err = UnexpectedResponseError { status: StatusCode::BAD_GATEWAY, body: long_body, + user_message: None, url: Some("http://example.com/long".to_string()), cf_ray: None, request_id: Some("req-long".to_string()), @@ -487,6 +489,7 @@ fn unexpected_status_includes_cf_ray_and_request_id() { let err = UnexpectedResponseError { status: StatusCode::UNAUTHORIZED, body: "plain text error".to_string(), + user_message: None, url: Some("https://chatgpt.com/backend-api/codex/responses".to_string()), cf_ray: Some("9c81f9f18f2fa49d-LHR".to_string()), request_id: Some("req-xyz".to_string()), @@ -507,6 +510,7 @@ fn unexpected_status_includes_identity_auth_details() { let err = UnexpectedResponseError { status: StatusCode::UNAUTHORIZED, body: "plain text error".to_string(), + user_message: None, url: Some("https://chatgpt.com/backend-api/codex/models".to_string()), cf_ray: Some("cf-ray-auth-401-test".to_string()), request_id: Some("req-auth".to_string()), diff --git a/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap new file mode 100644 index 0000000000..3652ad4428 --- /dev/null +++ b/codex-rs/tui/src/history_cell/snapshots/codex_tui__history_cell__tests__error_event_bedrock_expired_signature_snapshot.snap @@ -0,0 +1,5 @@ +--- +source: tui/src/history_cell/tests.rs +expression: rendered +--- +■ Amazon Bedrock rejected the request because its AWS signature has expired. Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or unset it, then restart Codex, url: https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses diff --git a/codex-rs/tui/src/history_cell/tests.rs b/codex-rs/tui/src/history_cell/tests.rs index 4d23392128..99f7a3a93b 100644 --- a/codex-rs/tui/src/history_cell/tests.rs +++ b/codex-rs/tui/src/history_cell/tests.rs @@ -15,11 +15,13 @@ use codex_otel::RuntimeMetricTotals; use codex_otel::RuntimeMetricsSummary; use codex_protocol::ThreadId; use codex_protocol::account::PlanType; +use codex_protocol::error::UnexpectedResponseError; use codex_protocol::parse_command::ParsedCommand; use dirs::home_dir; use pretty_assertions::assert_eq; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use reqwest::StatusCode; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -752,6 +754,31 @@ fn error_event_oversized_input_snapshot() { insta::assert_snapshot!(rendered); } +#[test] +fn error_event_bedrock_expired_signature_snapshot() { + let error = UnexpectedResponseError { + status: StatusCode::UNAUTHORIZED, + body: "Signature expired: 20260609T133205Z is now earlier than 20260614T062525Z \ +(20260614T063025Z - 5 min.)" + .to_string(), + user_message: Some( + "Amazon Bedrock rejected the request because its AWS signature has expired. \ +Refresh your AWS credentials and retry. If `AWS_BEARER_TOKEN_BEDROCK` is set, update or \ +unset it, then restart Codex" + .to_string(), + ), + url: Some("https://bedrock-mantle.us-east-2.api.aws/openai/v1/responses".to_string()), + cf_ray: None, + request_id: None, + identity_authorization_error: None, + identity_error_code: None, + }; + let cell = new_error_event(error.to_string()); + let rendered = render_lines(&cell.display_lines(/*width*/ 100)).join("\n"); + + insta::assert_snapshot!(rendered); +} + #[tokio::test] async fn mcp_tools_output_masks_sensitive_values() { let mut config = test_config().await; From ff31ba8d0a75f0a51644359265e194b93848a023 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 22 Jun 2026 17:55:49 -0700 Subject: [PATCH 65/86] Make formatter output quiet on success (#29467) ## Why `just fmt` is quite noisy even on successful runs. ## What Only print output when a formatter fails. - Buffer output from each formatter and print only a failed command and its diagnostics. - Prefix the `justfile` driver invocations with `@` so Just does not echo the command itself. - Retain rustfmt stderr on failure and cover silent-success and failure-reporting behavior. ## Validation - Confirmed `just fmt` and `just fmt-check` both exit successfully with empty stdout and stderr. --- justfile | 4 +- scripts/format.py | 38 +++------- .../test_artifact_workflow_and_binaries.py | 73 ++++++++++++++++++- 3 files changed, 85 insertions(+), 30 deletions(-) diff --git a/justfile b/justfile index 560fb1c22e..03f87391ac 100644 --- a/justfile +++ b/justfile @@ -38,11 +38,11 @@ app-server-test-client *args: # Format the justfile, Rust, Bazel/Starlark, Python SDK code, and Python scripts. fmt: - {{ python }} ../scripts/format.py + @{{ python }} ../scripts/format.py # Check formatting without modifying files. fmt-check: - {{ python }} ../scripts/format.py --check + @{{ python }} ../scripts/format.py --check fix *args: cargo clippy --fix --tests --allow-dirty {args} diff --git a/scripts/format.py b/scripts/format.py index 6fa5aab699..9310254f52 100644 --- a/scripts/format.py +++ b/scripts/format.py @@ -18,7 +18,6 @@ REPO_ROOT = Path(__file__).resolve().parents[1] class Command: args: tuple[str, ...] cwd: Path = REPO_ROOT - discard_stderr: bool = False @dataclass(frozen=True) @@ -45,13 +44,7 @@ def rust_formatter_group(*, check: bool) -> FormatterGroup: args = ["cargo", "fmt", "--", "--config", "imports_granularity=Item"] if check: args.append("--check") - # Stable rustfmt repeats a nightly-only `imports_granularity` warning - # for each crate, so suppress that expected stderr noise. - command = Command( - tuple(args), - REPO_ROOT / "codex-rs", - discard_stderr=True, - ) + command = Command(tuple(args), REPO_ROOT / "codex-rs") return FormatterGroup("Rust", (command,)) @@ -152,31 +145,27 @@ def formatter_groups(*, check: bool) -> tuple[FormatterGroup, ...]: def run_formatter_group(group: FormatterGroup) -> FormatterResult: """Run one formatter group sequentially and return its buffered output.""" - output: list[str] = [] for command in group.commands: - output.append(f"$ {shlex.join(command.args)}\n") try: process = subprocess.run( command.args, cwd=command.cwd, stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL - if command.discard_stderr - else subprocess.STDOUT, + stderr=subprocess.STDOUT, text=True, check=False, ) except OSError as error: - output.append(f"{error}\n") - return FormatterResult(group.name, "".join(output), 1) + output = f"$ {shlex.join(command.args)}\n{error}\n" + return FormatterResult(group.name, output, 1) - output.append(process.stdout) - if process.stdout and not process.stdout.endswith("\n"): - output.append("\n") if process.returncode != 0: - return FormatterResult(group.name, "".join(output), process.returncode) + output = f"$ {shlex.join(command.args)}\n{process.stdout}" + if process.stdout and not process.stdout.endswith("\n"): + output += "\n" + return FormatterResult(group.name, output, process.returncode) - return FormatterResult(group.name, "".join(output), 0) + return FormatterResult(group.name, "", 0) def main() -> int: @@ -191,16 +180,13 @@ def main() -> int: failures: list[str] = [] with ThreadPoolExecutor(max_workers=len(groups)) as executor: - futures = {} - for group in groups: - print(f"Starting {group.name} formatter...", flush=True) - futures[executor.submit(run_formatter_group, group)] = group.name + futures = [executor.submit(run_formatter_group, group) for group in groups] for future in as_completed(futures): result = future.result() - print(f"==> {result.name} formatter finished") - print(result.output, end="") if result.returncode != 0: failures.append(result.name) + print(f"==> {result.name} formatter failed", file=sys.stderr) + print(result.output, end="", file=sys.stderr) if failures: print(f"Formatting failed: {', '.join(failures)}", file=sys.stderr) diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index b2cfda53b5..301a75abac 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -112,9 +112,9 @@ def test_root_fmt_recipes_use_shared_formatter_driver() -> None: "fmt_comment": ( "# Format the justfile, Rust, Bazel/Starlark, Python SDK code, and Python scripts." ), - "fmt_commands": ["{{ python }} ../scripts/format.py"], + "fmt_commands": ["@{{ python }} ../scripts/format.py"], "fmt_check_comment": "# Check formatting without modifying files.", - "fmt_check_commands": ["{{ python }} ../scripts/format.py --check"], + "fmt_check_commands": ["@{{ python }} ../scripts/format.py --check"], } assert actual == expected, ( @@ -245,6 +245,75 @@ def test_root_format_driver_covers_all_formatter_groups( ] +def test_root_format_driver_discards_successful_command_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + script = _load_root_format_script_module() + processes = iter( + ( + script.subprocess.CompletedProcess(("first",), 0, "routine output\n"), + script.subprocess.CompletedProcess(("second",), 2, "failure output\n"), + ) + ) + monkeypatch.setattr(script.subprocess, "run", lambda *args, **kwargs: next(processes)) + group = script.FormatterGroup( + "Test", + (script.Command(("first",)), script.Command(("second",))), + ) + + assert script.run_formatter_group(group) == script.FormatterResult( + "Test", + "$ second\nfailure output\n", + 2, + ) + + +def test_root_format_driver_is_silent_when_all_formatters_succeed( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + script = _load_root_format_script_module() + groups = (script.FormatterGroup("Quiet", ()),) + monkeypatch.setattr(script, "formatter_groups", lambda *, check: groups) + monkeypatch.setattr( + script, + "run_formatter_group", + lambda group: script.FormatterResult(group.name, "hidden output\n", 0), + ) + monkeypatch.setattr(sys, "argv", ["format.py"]) + + assert script.main() == 0 + captured = capsys.readouterr() + assert (captured.out, captured.err) == ("", "") + + +def test_root_format_driver_reports_only_failed_formatters( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + script = _load_root_format_script_module() + groups = ( + script.FormatterGroup("Quiet", ()), + script.FormatterGroup("Broken", ()), + ) + monkeypatch.setattr(script, "formatter_groups", lambda *, check: groups) + + def fake_run(group): + if group.name == "Broken": + return script.FormatterResult(group.name, "$ broken\nfailure output\n", 2) + return script.FormatterResult(group.name, "hidden output\n", 0) + + monkeypatch.setattr(script, "run_formatter_group", fake_run) + monkeypatch.setattr(sys, "argv", ["format.py"]) + + assert script.main() == 1 + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err == ( + "==> Broken formatter failed\n$ broken\nfailure output\nFormatting failed: Broken\n" + ) + + def test_generate_types_wires_all_generation_steps() -> None: """The type generation command should refresh every schema-derived artifact.""" source = (ROOT / "scripts" / "update_sdk_artifacts.py").read_text() From b16d2858f56a9f3928ac0150250a176b04dde92b Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 22 Jun 2026 17:56:04 -0700 Subject: [PATCH 66/86] PAC 4 - Add macOS system proxy resolver (#26709) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Stacked on #26708. Adds the macOS implementation of the shared system-proxy contract. This allows Codex-owned auth clients to use the route macOS selects for each auth URL through SystemConfiguration and CFNetwork, including PAC and WPAD results. The `respect_system_proxy` feature is disabled by default, so existing client behavior remains unchanged unless explicitly enabled. ## Implementation - Adds the macOS-only `system-configuration` dependency to `codex-client`. - Dispatches system-proxy resolution to `outbound_proxy/macos.rs` on macOS. - Reads system proxy settings from `SCDynamicStore` and resolves the target URL with `CFNetworkCopyProxiesForURL`. - Executes PAC URLs and inline PAC JavaScript through a bounded run loop with a five-second timeout. - Handles `DIRECT`, HTTP proxies, and CFNetwork HTTPS entries using HTTP CONNECT; unsupported SOCKS entries map to `UnsupportedProxyScheme`. - Builds concrete proxy URLs from host and port entries, including IPv6 host bracketing. - Maps results into the shared `SystemProxyDecision::{Direct, Proxy, Unavailable}` contract. - Hashes URL-specific cache keys so PAC decisions remain distinct without retaining raw request URLs or query strings. ## End-user behavior - Disabled/default: existing client behavior is unchanged. - Enabled with `[features.respect_system_proxy]`: - macOS auth clients honor system proxy configuration, PAC, and WPAD; - valid OS/PAC `DIRECT` decisions use a direct connection; - unavailable system resolution falls back to explicit environment proxy variables, then `DIRECT`, through the shared contract from #26707. - Unsupported proxy schemes are not silently translated into another route. - Custom CA handling remains separate from proxy selection. - Known limitation: only the first supported system/PAC candidate is used. Subsequent proxy or `DIRECT` candidates are not attempted after a connection failure. This matches the current Windows behavior and leaves room for future ordered-fallback support. ## Tests - `just test -p codex-client` — 34 tests passed. - `just clippy -p codex-client` - `just fmt` - `just bazel-lock-check` --- codex-rs/Cargo.lock | 1 + codex-rs/Cargo.toml | 1 + codex-rs/codex-client/Cargo.toml | 7 +- codex-rs/codex-client/src/outbound_proxy.rs | 34 +- .../codex-client/src/outbound_proxy/macos.rs | 382 ++++++++++++++++++ .../codex-client/src/outbound_proxy_tests.rs | 5 +- 6 files changed, 419 insertions(+), 11 deletions(-) create mode 100644 codex-rs/codex-client/src/outbound_proxy/macos.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 18b7bc9190..65d581db6f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2405,6 +2405,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "system-configuration", "tempfile", "thiserror 2.0.18", "tokio", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 878d359148..9591f78afa 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -409,6 +409,7 @@ strum_macros = "0.28.0" supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" +system-configuration = "0.7" tar = { version = "=0.4.45", default-features = false } tempfile = "3.23.0" test-log = "0.2.19" diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index 65be21913a..fcd97b6bda 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -24,8 +24,13 @@ tracing-opentelemetry = { workspace = true } codex-utils-rustls-provider = { workspace = true } zstd = { workspace = true } -[target.'cfg(target_os = "windows")'.dependencies] +[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies] sha2 = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +system-configuration = { workspace = true } + +[target.'cfg(target_os = "windows")'.dependencies] windows-sys = { version = "0.52", features = [ "Win32_Foundation", "Win32_Networking_WinHttp", diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/codex-client/src/outbound_proxy.rs index ba3735ba53..ea6587052e 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/codex-client/src/outbound_proxy.rs @@ -14,9 +14,9 @@ use std::time::Instant; use crate::custom_ca::BuildCustomCaTransportError; use crate::custom_ca::build_reqwest_client_with_custom_ca; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] use sha2::Digest; -#[cfg(target_os = "windows")] +#[cfg(any(target_os = "windows", target_os = "macos"))] use sha2::Sha256; use thiserror::Error; @@ -24,6 +24,8 @@ const SYSTEM_PROXY_SUCCESS_CACHE_TTL: Duration = Duration::from_secs(60); const SYSTEM_PROXY_UNAVAILABLE_CACHE_TTL: Duration = Duration::from_secs(5); const SYSTEM_PROXY_CACHE_MAX_ENTRIES: usize = 256; +#[cfg(target_os = "macos")] +mod macos; #[cfg(target_os = "windows")] mod windows; @@ -115,15 +117,23 @@ impl From for io::Error { /// Builds a reqwest client with conservative route selection and shared CA handling. /// /// Unavailable platform resolution falls back to environment proxies and then direct. Errors after -/// a route is selected are returned without trying another route. +/// a route is selected are returned without trying another route. Ordered PAC candidates are +/// currently collapsed to one route on both Windows and macOS; later proxy or `DIRECT` candidates +/// are not retried after a connection failure. pub fn build_reqwest_client_for_route( builder: reqwest::ClientBuilder, request_url: &str, route_class: ClientRouteClass, config: Option<&OutboundProxyConfig>, ) -> Result { - let builder = - configure_proxy_for_route(&ProcessEnv, builder, request_url, route_class, config)?; + let builder = configure_proxy_for_route( + &ProcessEnv, + builder, + request_url, + route_class, + config, + resolve_system_proxy, + )?; build_reqwest_client_with_custom_ca(builder).map_err(Into::into) } @@ -133,6 +143,7 @@ fn configure_proxy_for_route( request_url: &str, route_class: ClientRouteClass, config: Option<&OutboundProxyConfig>, + resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, ) -> Result { if config.is_none() { return Ok(builder); @@ -217,7 +228,7 @@ impl RequestOrigin { } #[cfg_attr( - not(target_os = "windows"), + not(any(target_os = "windows", target_os = "macos")), allow( dead_code, reason = "Direct and Proxy are constructed only by platform-specific resolvers" @@ -240,12 +251,17 @@ fn resolve_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProx decision } +#[cfg(target_os = "macos")] +fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + macos::resolve(request_url, origin) +} + #[cfg(target_os = "windows")] fn resolve_platform_system_proxy(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { windows::resolve(request_url, origin) } -#[cfg(not(target_os = "windows"))] +#[cfg(not(any(target_os = "windows", target_os = "macos")))] fn resolve_platform_system_proxy( _request_url: &str, _origin: &RequestOrigin, @@ -317,7 +333,7 @@ fn insert_system_proxy_cache_entry( } fn system_proxy_cache_key(request_url: &str) -> String { - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] { // Keep URL-specific PAC decisions without retaining the raw routed URL. let mut hasher = Sha256::new(); @@ -326,7 +342,7 @@ fn system_proxy_cache_key(request_url: &str) -> String { format!("{:x}", hasher.finalize()) } - #[cfg(not(target_os = "windows"))] + #[cfg(not(any(target_os = "windows", target_os = "macos")))] request_url.to_string() } diff --git a/codex-rs/codex-client/src/outbound_proxy/macos.rs b/codex-rs/codex-client/src/outbound_proxy/macos.rs new file mode 100644 index 0000000000..3913ecc40b --- /dev/null +++ b/codex-rs/codex-client/src/outbound_proxy/macos.rs @@ -0,0 +1,382 @@ +use std::ffi::c_void; +use std::ptr; +use std::time::Duration; +use std::time::Instant; + +use super::RequestOrigin; +use super::RouteFailureClass; +use super::SystemProxyDecision; +use system_configuration::core_foundation::array::CFArray; +use system_configuration::core_foundation::array::CFArrayRef; +use system_configuration::core_foundation::base::CFEqual; +use system_configuration::core_foundation::base::CFGetTypeID; +use system_configuration::core_foundation::base::CFIndex; +use system_configuration::core_foundation::base::CFType; +use system_configuration::core_foundation::base::CFTypeRef; +use system_configuration::core_foundation::base::TCFType; +use system_configuration::core_foundation::base::kCFAllocatorDefault; +use system_configuration::core_foundation::dictionary::CFDictionary; +use system_configuration::core_foundation::dictionary::CFDictionaryRef; +use system_configuration::core_foundation::error::CFErrorRef; +use system_configuration::core_foundation::number::CFNumber; +use system_configuration::core_foundation::runloop::CFRunLoop; +use system_configuration::core_foundation::runloop::CFRunLoopSource; +use system_configuration::core_foundation::runloop::CFRunLoopSourceInvalidate; +use system_configuration::core_foundation::runloop::CFRunLoopSourceRef; +use system_configuration::core_foundation::runloop::kCFRunLoopDefaultMode; +use system_configuration::core_foundation::string::CFString; +use system_configuration::core_foundation::string::CFStringRef; +use system_configuration::core_foundation::url::CFURL; +use system_configuration::core_foundation::url::CFURLCreateWithString; +use system_configuration::core_foundation::url::CFURLGetTypeID; +use system_configuration::core_foundation::url::CFURLRef; +use system_configuration::dynamic_store::SCDynamicStoreBuilder; + +const PAC_EXECUTION_TIMEOUT: Duration = Duration::from_secs(5); + +type ProxyDictionary = CFDictionary; +type ProxyArray = CFArray; + +#[repr(C)] +struct CFStreamClientContext { + version: CFIndex, + info: *mut c_void, + retain: Option *mut c_void>, + release: Option, + copy_description: Option CFStringRef>, +} + +type CFProxyAutoConfigurationResultCallback = + unsafe extern "C" fn(*mut c_void, CFArrayRef, CFErrorRef); + +#[link(name = "CFNetwork", kind = "framework")] +unsafe extern "C" { + static kCFProxyTypeKey: CFStringRef; + static kCFProxyHostNameKey: CFStringRef; + static kCFProxyPortNumberKey: CFStringRef; + static kCFProxyAutoConfigurationURLKey: CFStringRef; + static kCFProxyAutoConfigurationJavaScriptKey: CFStringRef; + static kCFProxyTypeNone: CFStringRef; + static kCFProxyTypeHTTP: CFStringRef; + static kCFProxyTypeHTTPS: CFStringRef; + static kCFProxyTypeSOCKS: CFStringRef; + static kCFProxyTypeAutoConfigurationURL: CFStringRef; + static kCFProxyTypeAutoConfigurationJavaScript: CFStringRef; + + fn CFNetworkCopyProxiesForURL(url: CFURLRef, proxy_settings: CFDictionaryRef) -> CFArrayRef; + fn CFNetworkExecuteProxyAutoConfigurationURL( + proxy_auto_config_url: CFURLRef, + target_url: CFURLRef, + callback: CFProxyAutoConfigurationResultCallback, + client_context: *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef; + fn CFNetworkExecuteProxyAutoConfigurationScript( + proxy_auto_config_script: CFStringRef, + target_url: CFURLRef, + callback: CFProxyAutoConfigurationResultCallback, + client_context: *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef; +} + +pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyDecision { + let Some(target_url) = cf_url(request_url) else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::InvalidProxyConfig, + }; + }; + + let Some(settings) = system_proxy_settings() else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + + let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else { + return SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }; + }; + + proxy_array_decision(&proxies, &target_url, origin) +} + +fn system_proxy_settings() -> Option> { + let store = SCDynamicStoreBuilder::new("Codex").build()?; + store.get_proxies() +} + +fn copy_proxies_for_url( + target_url: &CFURL, + settings: &CFDictionary, +) -> Option { + let proxies = unsafe { + CFNetworkCopyProxiesForURL( + target_url.as_concrete_TypeRef(), + settings.as_concrete_TypeRef(), + ) + }; + if proxies.is_null() { + None + } else { + Some(unsafe { ProxyArray::wrap_under_create_rule(proxies) }) + } +} + +fn proxy_array_decision( + proxies: &ProxyArray, + target_url: &CFURL, + origin: &RequestOrigin, +) -> SystemProxyDecision { + let mut saw_unsupported = false; + let mut saw_unavailable = false; + + // CFNetwork returns candidates in failover order, but the shared resolver currently carries + // only one route. This matches the Windows limitation; cross-platform retry requires request + // replay semantics and is intentionally deferred. + for proxy in proxies { + match proxy_entry_decision(&proxy, target_url, origin) { + ProxyEntryDecision::Direct => return SystemProxyDecision::Direct, + ProxyEntryDecision::Proxy { url } => return SystemProxyDecision::Proxy { url }, + ProxyEntryDecision::UnsupportedScheme => saw_unsupported = true, + ProxyEntryDecision::Unavailable => saw_unavailable = true, + } + } + + if saw_unsupported { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + } + } else if saw_unavailable { + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + } + } else { + SystemProxyDecision::Direct + } +} + +fn proxy_entry_decision( + proxy: &ProxyDictionary, + target_url: &CFURL, + origin: &RequestOrigin, +) -> ProxyEntryDecision { + let Some(proxy_type) = cf_string_value(proxy, unsafe { kCFProxyTypeKey }) else { + return ProxyEntryDecision::Unavailable; + }; + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone }) { + return ProxyEntryDecision::Direct; + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP }) { + return concrete_proxy_entry(proxy, "http"); + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS }) { + // CFNetwork's HTTPS proxy type is a tunneling proxy for HTTPS destinations; it does not + // preserve an explicit TLS-to-proxy transport. See https://developer.apple.com/documentation/cfnetwork/kcfproxytypehttps. + return concrete_proxy_entry(proxy, "http"); + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeSOCKS }) { + return ProxyEntryDecision::UnsupportedScheme; + } + + if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL }) { + let Some(pac_url) = cf_url_value(proxy, unsafe { kCFProxyAutoConfigurationURLKey }) else { + return ProxyEntryDecision::Unavailable; + }; + return pac_decision(execute_pac_url(&pac_url, target_url), target_url, origin); + } + + if cf_string_equals(&proxy_type, unsafe { + kCFProxyTypeAutoConfigurationJavaScript + }) { + let Some(script) = + cf_string_value(proxy, unsafe { kCFProxyAutoConfigurationJavaScriptKey }) + else { + return ProxyEntryDecision::Unavailable; + }; + return pac_decision( + execute_pac(|callback, context| unsafe { + CFNetworkExecuteProxyAutoConfigurationScript( + script.as_concrete_TypeRef(), + target_url.as_concrete_TypeRef(), + callback, + context, + ) + }), + target_url, + origin, + ); + } + + ProxyEntryDecision::Unavailable +} + +fn pac_decision( + result: Result, + target_url: &CFURL, + origin: &RequestOrigin, +) -> ProxyEntryDecision { + let proxies = match result { + Ok(proxies) => proxies, + Err(RouteFailureClass::UnsupportedProxyScheme) => { + return ProxyEntryDecision::UnsupportedScheme; + } + Err(_) => return ProxyEntryDecision::Unavailable, + }; + + match proxy_array_decision(&proxies, target_url, origin) { + SystemProxyDecision::Direct => ProxyEntryDecision::Direct, + SystemProxyDecision::Proxy { url } => ProxyEntryDecision::Proxy { url }, + SystemProxyDecision::Unavailable { + failure: RouteFailureClass::UnsupportedProxyScheme, + } => ProxyEntryDecision::UnsupportedScheme, + SystemProxyDecision::Unavailable { failure: _ } => ProxyEntryDecision::Unavailable, + } +} + +fn execute_pac_url(pac_url: &CFURL, target_url: &CFURL) -> Result { + execute_pac(|callback, context| unsafe { + CFNetworkExecuteProxyAutoConfigurationURL( + pac_url.as_concrete_TypeRef(), + target_url.as_concrete_TypeRef(), + callback, + context, + ) + }) +} + +fn execute_pac( + create_source: impl FnOnce( + CFProxyAutoConfigurationResultCallback, + *mut CFStreamClientContext, + ) -> CFRunLoopSourceRef, +) -> Result { + let mut state = PacRunLoopState { result: None }; + let mut context = CFStreamClientContext { + version: 0, + info: (&mut state as *mut PacRunLoopState).cast::(), + retain: None, + release: None, + copy_description: None, + }; + + let source = create_source(pac_result_callback, &mut context); + if source.is_null() { + return Err(RouteFailureClass::ProxyResolutionUnavailable); + } + + let source = unsafe { CFRunLoopSource::wrap_under_create_rule(source) }; + let run_loop = CFRunLoop::get_current(); + let mode = unsafe { kCFRunLoopDefaultMode }; + run_loop.add_source(&source, mode); + + let started_at = Instant::now(); + while state.result.is_none() && started_at.elapsed() < PAC_EXECUTION_TIMEOUT { + CFRunLoop::run_in_mode(mode, Duration::from_millis(50), true); + } + + if state.result.is_none() { + unsafe { CFRunLoopSourceInvalidate(source.as_concrete_TypeRef()) }; + } + run_loop.remove_source(&source, mode); + state + .result + .unwrap_or(Err(RouteFailureClass::ConnectTimeout)) +} + +unsafe extern "C" fn pac_result_callback( + client: *mut c_void, + proxies: CFArrayRef, + error: CFErrorRef, +) { + let state = unsafe { &mut *client.cast::() }; + state.result = if !error.is_null() || proxies.is_null() { + Some(Err(RouteFailureClass::ProxyResolutionUnavailable)) + } else { + Some(Ok(unsafe { ProxyArray::wrap_under_get_rule(proxies) })) + }; + CFRunLoop::get_current().stop(); +} + +struct PacRunLoopState { + result: Option>, +} + +fn concrete_proxy_entry(proxy: &ProxyDictionary, proxy_scheme: &str) -> ProxyEntryDecision { + let Some(host) = cf_string_value(proxy, unsafe { kCFProxyHostNameKey }) + .map(|host| host.to_string()) + .filter(|host| !host.is_empty()) + else { + return ProxyEntryDecision::Unavailable; + }; + + let host = bracket_ipv6_host(&host); + let url = match cf_i32_value(proxy, unsafe { kCFProxyPortNumberKey }) { + Some(port) if port > 0 => format!("{proxy_scheme}://{host}:{port}"), + _ => format!("{proxy_scheme}://{host}"), + }; + ProxyEntryDecision::Proxy { url } +} + +fn bracket_ipv6_host(host: &str) -> String { + if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + } +} + +fn cf_string_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy + .find(key) + .and_then(|value| value.downcast::()) +} + +fn cf_i32_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy + .find(key) + .and_then(|value| value.downcast::()) + .and_then(|value| value.to_i32()) +} + +fn cf_url_value(proxy: &ProxyDictionary, key: CFStringRef) -> Option { + proxy.find(key).and_then(|value| { + if unsafe { CFGetTypeID(value.as_CFTypeRef()) == CFURLGetTypeID() } { + Some(unsafe { CFURL::wrap_under_get_rule(value.as_CFTypeRef() as CFURLRef) }) + } else { + value + .downcast::() + .and_then(|value| cf_url(value.to_string().as_str())) + } + }) +} + +fn cf_string_equals(value: &CFString, expected: CFStringRef) -> bool { + unsafe { CFEqual(value.as_CFTypeRef(), expected as CFTypeRef) != 0 } +} + +fn cf_url(value: &str) -> Option { + let value = CFString::new(value); + let url = unsafe { + CFURLCreateWithString( + kCFAllocatorDefault, + value.as_concrete_TypeRef(), + ptr::null(), + ) + }; + if url.is_null() { + None + } else { + Some(unsafe { CFURL::wrap_under_create_rule(url) }) + } +} + +enum ProxyEntryDecision { + Direct, + Proxy { url: String }, + UnsupportedScheme, + Unavailable, +} diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/codex-client/src/outbound_proxy_tests.rs index 27e303a5d8..46868e8f73 100644 --- a/codex-rs/codex-client/src/outbound_proxy_tests.rs +++ b/codex-rs/codex-client/src/outbound_proxy_tests.rs @@ -84,6 +84,9 @@ async fn enabled_environment_proxy_routes_request_through_proxy() { request_url, ClientRouteClass::Auth, Some(&config), + |_, _| SystemProxyDecision::Unavailable { + failure: RouteFailureClass::ProxyResolutionUnavailable, + }, ) .expect("enabled proxy route should configure"); @@ -204,6 +207,6 @@ fn system_proxy_cache_key_preserves_url_specific_pac_decisions() { cache_key, system_proxy_cache_key("https://auth.openai.com/oauth/revoke") ); - #[cfg(target_os = "windows")] + #[cfg(any(target_os = "windows", target_os = "macos"))] assert!(!cache_key.contains(request_url)); } From 3310fc8ae5d56b2c2a617dff0800abf0ef3be16b Mon Sep 17 00:00:00 2001 From: Celia Chen Date: Mon, 22 Jun 2026 18:15:11 -0700 Subject: [PATCH 67/86] chore: warn when Code Mode lacks model metadata (#29490) --- .../core/src/session/code_mode_warning.rs | 26 +++ .../src/session/code_mode_warning_tests.rs | 70 +++++++++ codex-rs/core/src/session/handlers.rs | 4 +- codex-rs/core/src/session/inject.rs | 2 +- codex-rs/core/src/session/mod.rs | 2 + codex-rs/core/src/session/turn_context.rs | 9 +- codex-rs/core/src/tasks/mod.rs | 2 +- .../tests/suite/model_runtime_selectors.rs | 148 +++++++++++++++++- ...layout__unsupported_code_mode_warning.snap | 8 + .../src/chatwidget/tests/status_and_layout.rs | 16 ++ 10 files changed, 275 insertions(+), 12 deletions(-) create mode 100644 codex-rs/core/src/session/code_mode_warning.rs create mode 100644 codex-rs/core/src/session/code_mode_warning_tests.rs create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_and_layout__unsupported_code_mode_warning.snap diff --git a/codex-rs/core/src/session/code_mode_warning.rs b/codex-rs/core/src/session/code_mode_warning.rs new file mode 100644 index 0000000000..87f122b065 --- /dev/null +++ b/codex-rs/core/src/session/code_mode_warning.rs @@ -0,0 +1,26 @@ +use codex_features::Feature; +use codex_features::Features; +use codex_protocol::openai_models::ModelInfo; + +pub(super) fn unsupported_code_mode_warning( + model_info: &ModelInfo, + features: &Features, +) -> Option { + let code_mode_enabled = + features.enabled(Feature::CodeMode) || features.enabled(Feature::CodeModeOnly); + if !code_mode_enabled + || model_info.tool_mode.is_some() + || model_info.used_fallback_model_metadata + { + return None; + } + + let model = &model_info.slug; + Some(format!( + "Code Mode is enabled in configuration, but model `{model}` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode." + )) +} + +#[cfg(test)] +#[path = "code_mode_warning_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/session/code_mode_warning_tests.rs b/codex-rs/core/src/session/code_mode_warning_tests.rs new file mode 100644 index 0000000000..ed393d870e --- /dev/null +++ b/codex-rs/core/src/session/code_mode_warning_tests.rs @@ -0,0 +1,70 @@ +use super::unsupported_code_mode_warning; +use codex_features::Feature; +use codex_features::Features; +use codex_models_manager::model_info::model_info_from_slug; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ToolMode; +use pretty_assertions::assert_eq; + +const MODEL_SLUG: &str = "test-model"; + +fn known_model_info() -> ModelInfo { + ModelInfo { + used_fallback_model_metadata: false, + ..model_info_from_slug(MODEL_SLUG) + } +} + +#[test] +fn warns_when_code_mode_is_enabled_without_model_selector() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + + assert_eq!( + unsupported_code_mode_warning(&known_model_info(), &features), + Some(format!( + "Code Mode is enabled in configuration, but model `{MODEL_SLUG}` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode." + )) + ); +} + +#[test] +fn warns_when_code_mode_only_is_enabled_without_model_selector() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeModeOnly); + + assert!(unsupported_code_mode_warning(&known_model_info(), &features).is_some()); +} + +#[test] +fn does_not_warn_when_code_mode_is_disabled() { + assert_eq!( + unsupported_code_mode_warning(&known_model_info(), &Features::with_defaults()), + None + ); +} + +#[test] +fn does_not_warn_when_model_has_tool_mode_selector() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeModeOnly); + + for tool_mode in [ToolMode::Direct, ToolMode::CodeMode, ToolMode::CodeModeOnly] { + let model_info = ModelInfo { + tool_mode: Some(tool_mode), + ..known_model_info() + }; + assert_eq!(unsupported_code_mode_warning(&model_info, &features), None); + } +} + +#[test] +fn fallback_metadata_only_uses_existing_warning() { + let mut features = Features::with_defaults(); + features.enable(Feature::CodeMode); + + assert_eq!( + unsupported_code_mode_warning(&model_info_from_slug(MODEL_SLUG), &features), + None + ); +} diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 2feee3e9f3..39e91d6cf9 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -218,7 +218,7 @@ pub(super) async fn user_input_or_turn_inner( }) .await; } - sess.maybe_emit_unknown_model_warning_for_turn(current_context.as_ref()) + sess.maybe_emit_model_warnings_for_turn(current_context.as_ref()) .await; match sess .steer_input( @@ -673,7 +673,7 @@ pub async fn review( review_request: ReviewRequest, ) { let turn_context = sess.new_default_turn_with_sub_id(sub_id.clone()).await; - sess.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + sess.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) .await; sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer())) .await; diff --git a/codex-rs/core/src/session/inject.rs b/codex-rs/core/src/session/inject.rs index 3f73cd96d0..5c5b7d1feb 100644 --- a/codex-rs/core/src/session/inject.rs +++ b/codex-rs/core/src/session/inject.rs @@ -94,7 +94,7 @@ impl Session { input, )); } - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + self.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) .await; if self.input_queue.has_trigger_turn_mailbox_items().await { self.clear_reserved_idle_turn(&turn_state).await; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 1bd5bae8bf..cdf866c391 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -208,6 +208,7 @@ use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::exec_output::StreamOutput; +mod code_mode_warning; mod config_lock; mod handlers; mod inject; @@ -225,6 +226,7 @@ mod token_budget; pub(crate) mod turn; pub(crate) mod turn_context; mod world_state; +use self::code_mode_warning::unsupported_code_mode_warning; use self::config_lock::export_config_lock_if_configured; use self::config_lock::validate_config_lock_if_configured; #[cfg(test)] diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b546355f7e..b2c50cf96a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -797,7 +797,7 @@ impl Session { turn_context } - pub(crate) async fn maybe_emit_unknown_model_warning_for_turn(&self, tc: &TurnContext) { + pub(crate) async fn maybe_emit_model_warnings_for_turn(&self, tc: &TurnContext) { if tc.model_info.used_fallback_model_metadata { self.send_event( tc, @@ -810,6 +810,13 @@ impl Session { ) .await; } + + if let Some(message) = + unsupported_code_mode_warning(&tc.model_info, tc.config.features.get()) + { + self.send_event(tc, EventMsg::Warning(WarningEvent { message })) + .await; + } } pub(crate) async fn new_default_turn(&self) -> Arc { diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 1745a40256..cb532445fd 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -483,7 +483,7 @@ impl Session { } let turn_context = self.new_default_turn_with_sub_id(sub_id).await; - self.maybe_emit_unknown_model_warning_for_turn(turn_context.as_ref()) + self.maybe_emit_model_warnings_for_turn(turn_context.as_ref()) .await; self.start_task(turn_context, Vec::new(), RegularTask::new()) .await; diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index 3ee758372d..d893e389d3 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -22,6 +22,7 @@ use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_models_once; use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_sse_sequence; use core_test_support::responses::sse; use core_test_support::skip_if_no_network; use core_test_support::submit_thread_settings; @@ -36,6 +37,12 @@ use tokio::time::sleep; const CHILD_MODEL: &str = "test-multi-agent-child"; const ROOT_MODEL: &str = "test-multi-agent-root"; const ROOT_PROMPT: &str = "spawn a child"; +const UNSUPPORTED_CODE_MODE_WARNING: &str = "does not advertise Code Mode support"; + +struct RemoteModelResponse { + body: Value, + warnings: Vec, +} fn remote_model(slug: &str) -> ModelInfo { ModelInfo { @@ -81,10 +88,10 @@ async fn wait_for_model_available(manager: &SharedModelsManager, slug: &str) -> } } -async fn response_body_for_remote_model( +async fn response_for_remote_model( remote_model: ModelInfo, configure: impl FnOnce(&mut Config) + Send + 'static, -) -> Result { +) -> Result { let server = responses::start_mock_server().await; let model_slug = remote_model.slug.clone(); let models_mock = mount_models_once( @@ -133,12 +140,28 @@ async fn response_body_for_remote_model( thread_settings: Default::default(), }) .await?; - wait_for_event(&test.codex, |event| { - matches!(event, EventMsg::TurnComplete(_)) - }) - .await; + let mut warnings = Vec::new(); + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Warning(warning) => warnings.push(warning.message), + EventMsg::TurnComplete(_) => break, + _ => {} + } + } - Ok(response_mock.single_request().body_json()) + Ok(RemoteModelResponse { + body: response_mock.single_request().body_json(), + warnings, + }) +} + +async fn response_body_for_remote_model( + remote_model: ModelInfo, + configure: impl FnOnce(&mut Config) + Send + 'static, +) -> Result { + Ok(response_for_remote_model(remote_model, configure) + .await? + .body) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -180,6 +203,117 @@ async fn remote_tool_mode_selector_overrides_feature_flags() -> Result<()> { ] ); + let unsupported_model = remote_model("test-tool-mode-unsupported"); + let unsupported_response = response_for_remote_model(unsupported_model, |config| { + config + .features + .enable(Feature::CodeModeOnly) + .expect("test config should allow feature update"); + }) + .await?; + assert!( + tool_names(&unsupported_response.body) + .iter() + .any(|name| name == codex_code_mode::PUBLIC_TOOL_NAME) + ); + assert_eq!( + unsupported_response + .warnings + .iter() + .filter(|warning| warning.contains(UNSUPPORTED_CODE_MODE_WARNING)) + .count(), + 1 + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unsupported_code_mode_warning_is_emitted_each_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let model_slug = "test-tool-mode-warning-each-turn"; + let models_mock = mount_models_once( + &server, + ModelsResponse { + models: vec![remote_model(model_slug)], + }, + ) + .await; + let response_mock = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_response_created("resp-2"), + ev_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(|config| { + config + .features + .enable(Feature::CodeMode) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let models_manager = test.thread_manager.get_models_manager(); + let available_model = wait_for_model_available(&models_manager, model_slug).await; + assert_eq!(available_model.model, model_slug); + assert_eq!(models_mock.requests().len(), 1); + + submit_thread_settings( + &test.codex, + ThreadSettingsOverrides { + model: Some(model_slug.to_string()), + ..Default::default() + }, + ) + .await?; + + let mut warning_counts = Vec::new(); + for prompt in ["first turn", "second turn"] { + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let mut warning_count = 0; + loop { + match wait_for_event(&test.codex, |_| true).await { + EventMsg::Warning(warning) + if warning.message.contains(UNSUPPORTED_CODE_MODE_WARNING) => + { + warning_count += 1; + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + warning_counts.push(warning_count); + } + + assert_eq!(warning_counts, vec![1, 1]); + assert_eq!(response_mock.requests().len(), 2); + Ok(()) } diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_and_layout__unsupported_code_mode_warning.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_and_layout__unsupported_code_mode_warning.snap new file mode 100644 index 0000000000..96deb66773 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__status_and_layout__unsupported_code_mode_warning.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: "lines_to_single_string(&cells[0])" +--- +⚠ Code Mode is enabled in configuration, but model `gpt-5.4` does not advertise + Code Mode support. This may degrade model performance. Disable + `features.code_mode` and `features.code_mode_only`, or select a model whose + metadata enables Code Mode. diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index d484c1ee56..57de08cee5 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -2079,6 +2079,22 @@ async fn warning_event_adds_warning_history_cell() { ); } +#[tokio::test] +async fn unsupported_code_mode_warning_renders_as_warning_history_cell() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + handle_warning( + &mut chat, + "Code Mode is enabled in configuration, but model `gpt-5.4` does not advertise Code Mode support. This may degrade model performance. Disable `features.code_mode` and `features.code_mode_only`, or select a model whose metadata enables Code Mode.", + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1, "expected one warning history cell"); + insta::assert_snapshot!( + "unsupported_code_mode_warning", + lines_to_single_string(&cells[0]) + ); +} + #[tokio::test] async fn repeated_model_metadata_warning_is_hidden_for_same_slug() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; From 67009bc53ffc684eaf235c6186df890d35259240 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 22 Jun 2026 18:33:51 -0700 Subject: [PATCH 68/86] mcp: accept foreign absolute cwd for remote stdio (#29493) ## Why Remote stdio MCP servers can run in an environment whose path convention differs from the Codex host. A Windows cwd such as `C:\Users\openai\share` is absolute for the executor but was rejected by a POSIX orchestrator. Built on #29501, now merged, which only clarifies the host-native `PathUri` constructor name. ## What changed - Deserialize MCP cwd values as `LegacyAppPathString` so config does not apply host path rules. - Interpret that spelling as host-native for local launches and convert it to `PathUri` at executor launch. - Skip host filesystem and command resolution checks for remote stdio in `codex doctor`. - Add host-independent config and executor-boundary coverage using the foreign path convention for each test platform. ## Validation - `just test -p codex-utils-path-uri -p codex-config -p codex-mcp -p codex-rmcp-client` (408 passed) - `just test -p codex-cli -p codex-rmcp-client` (372 passed) - `cargo check --workspace --tests` - `just test` (11,311 passed; 43 unrelated environment/timing failures) - `just fix -p codex-cli -p codex-config -p codex-core -p codex-mcp -p codex-mcp-extension -p codex-rmcp-client -p codex-tui` --- .../schema/typescript/LegacyAppPathString.ts | 8 +- codex-rs/cli/src/doctor.rs | 114 +++++++++++++++--- codex-rs/cli/src/mcp_cmd.rs | 4 +- codex-rs/codex-mcp/src/plugin_config_tests.rs | 3 +- codex-rs/codex-mcp/src/rmcp_client.rs | 1 + codex-rs/codex-mcp/src/runtime.rs | 55 +-------- codex-rs/config/src/mcp_edit.rs | 2 +- codex-rs/config/src/mcp_types.rs | 31 +---- codex-rs/config/src/mcp_types_tests.rs | 45 ++----- codex-rs/core/config.schema.json | 11 +- codex-rs/core/src/config/config_tests.rs | 11 +- .../core/src/config/edit/document_helpers.rs | 2 +- codex-rs/core/tests/suite/rmcp_client.rs | 3 +- .../mcp/src/executor_plugin/provider_tests.rs | 5 +- codex-rs/rmcp-client/src/rmcp_client.rs | 3 +- .../rmcp-client/src/stdio_server_launcher.rs | 12 +- .../rmcp-client/tests/foreign_stdio_cwd.rs | 67 ++++++++++ codex-rs/tui/src/history_cell/mcp.rs | 2 +- .../utils/path-uri/src/api_path_string.rs | 16 ++- .../path-uri/src/api_path_string_tests.rs | 17 +++ 20 files changed, 250 insertions(+), 162 deletions(-) create mode 100644 codex-rs/rmcp-client/tests/foreign_stdio_cwd.rs diff --git a/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts index 04e465b8a2..e39784a8b0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts +++ b/codex-rs/app-server-protocol/schema/typescript/LegacyAppPathString.ts @@ -11,10 +11,10 @@ * * When converting from [`PathUri`], "native" refers to the supplied * [`PathConvention`], which may be foreign to the operating system running - * this process. The inner string is private so path-producing code must convert - * from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing - * the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8 - * lossily because this API value is serialized as a JSON string. + * this process. The inner string is private so path-producing code must use a + * path conversion method instead of bypassing the intended conversion + * boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API + * value is serialized as a JSON string. * * Deserialization accepts any UTF-8 string without interpreting or validating * it. That unrestricted construction path is intentionally available only to diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 5e5a1e94be..5f60a96ff3 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -1520,19 +1520,35 @@ async fn mcp_check_from_servers(servers: &HashMap) -> D if disabled_server { continue; } - if let Some(cwd) = cwd - && !cwd.exists() - { - missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display())); - } - if command.trim().is_empty() { + let command_is_empty = command.trim().is_empty(); + if command_is_empty { missing_env.push(format!("{name}: stdio command is empty")); - } else if let Err(err) = - stdio_command_resolves(command, cwd.as_deref(), env.as_ref()) - { - missing_env.push(format!( - "{name}: stdio command {command:?} is not resolvable ({err})" - )); + } + if server.is_local_environment() { + let host_native_cwd = cwd.as_ref().map(|cwd| Path::new(cwd.as_str())); + if let Some(cwd) = host_native_cwd + && !cwd.exists() + { + missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display())); + } + if !command_is_empty + && let Err(err) = + stdio_command_resolves(command, host_native_cwd, env.as_ref()) + { + missing_env.push(format!( + "{name}: stdio command {command:?} is not resolvable ({err})" + )); + } + } else { + match cwd { + Some(cwd) if cwd.to_inferred_path_uri().is_none() => { + missing_env + .push(format!("{name}: remote stdio cwd is not absolute ({cwd})")); + } + None => missing_env + .push(format!("{name}: remote stdio requires an explicit cwd")), + Some(_) => {} + } } if let Some(env) = env { for key in env.keys().filter(|key| key.trim().is_empty()) { @@ -1541,10 +1557,12 @@ async fn mcp_check_from_servers(servers: &HashMap) -> D } for env_var in env_vars { if env_var.is_remote_source() { - missing_env.push(format!( - "{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", - env_var.name() - )); + if server.is_local_environment() { + missing_env.push(format!( + "{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", + env_var.name() + )); + } } else if !env_var_present(env_var.name()) { missing_env.push(format!("{name}: env var {} is not set", env_var.name())); } @@ -3863,6 +3881,70 @@ mod tests { })); } + #[tokio::test] + async fn mcp_check_skips_host_path_checks_for_remote_stdio() { + #[cfg(not(windows))] + let cwd = r"C:\Users\openai\share"; + #[cfg(windows)] + let cwd = "/home/openai/share"; + let cwd = toml::Value::String(cwd.to_string()); + let remote_server: McpServerConfig = toml::from_str(&format!( + r#" + command = "definitely-missing-codex-doctor-mcp" + environment_id = "remote" + cwd = {cwd} + required = true + env_vars = [{{ name = "REMOTE_ONLY_TOKEN", source = "remote" }}] + "#, + )) + .expect("should deserialize remote MCP config"); + let servers = HashMap::from([("remote".to_string(), remote_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "MCP configuration is locally consistent"); + } + + #[tokio::test] + async fn mcp_check_validates_remote_stdio_cwd() { + let missing_cwd: McpServerConfig = toml::from_str( + r#" + command = "echo" + environment_id = "remote" + required = true + "#, + ) + .expect("should deserialize remote MCP config without cwd"); + let relative_cwd: McpServerConfig = toml::from_str( + r#" + command = "echo" + environment_id = "remote" + cwd = "relative" + required = true + "#, + ) + .expect("should deserialize remote MCP config with relative cwd"); + let servers = HashMap::from([ + ("missing".to_string(), missing_cwd), + ("relative".to_string(), relative_cwd), + ]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert!( + check + .details + .contains(&"missing: remote stdio requires an explicit cwd".to_string()) + ); + assert!( + check + .details + .contains(&"relative: remote stdio cwd is not absolute (relative)".to_string()) + ); + } + #[cfg(unix)] #[test] fn read_probe_file_rejects_unreadable_file() { diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index 1466f10394..7baee7d349 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -640,7 +640,7 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) -> let env_display = format_env_display(env.as_ref(), env_vars); let cwd_display = cwd .as_ref() - .map(|path| path.display().to_string()) + .map(ToString::to_string) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "-".to_string()); let status = format_mcp_status(cfg); @@ -897,7 +897,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re println!(" args: {args_display}"); let cwd_display = cwd .as_ref() - .map(|path| path.display().to_string()) + .map(ToString::to_string) .filter(|value| !value.is_empty()) .unwrap_or_else(|| "-".to_string()); println!(" cwd: {cwd_display}"); diff --git a/codex-rs/codex-mcp/src/plugin_config_tests.rs b/codex-rs/codex-mcp/src/plugin_config_tests.rs index 0ba8f42f35..44f7e8208c 100644 --- a/codex-rs/codex-mcp/src/plugin_config_tests.rs +++ b/codex-rs/codex-mcp/src/plugin_config_tests.rs @@ -7,6 +7,7 @@ use codex_config::McpServerConfig; use codex_config::McpServerEnvVar; use codex_config::McpServerOAuthConfig; use codex_config::McpServerTransportConfig; +use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use std::collections::BTreeMap; use std::collections::HashMap; @@ -31,7 +32,7 @@ fn stdio_server( args: Vec::new(), env: None, env_vars, - cwd: Some(cwd.to_path_buf()), + cwd: Some(LegacyAppPathString::from_path(cwd)), }, environment_id: environment_id.to_string(), enabled: true, diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index d6c2590ba4..6c8b9a2415 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -645,6 +645,7 @@ async fn make_rmcp_client( )) as Arc }; + let cwd = cwd.map(codex_utils_path_uri::LegacyAppPathString::into_string); RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher) .await .map_err(|err| StartupOutcomeError::from(anyhow!(err))) diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index ff5c65db00..e52048349c 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -66,9 +66,6 @@ impl McpRuntimeContext { .environment_manager .get_environment(&config.environment_id) { - if !config.is_local_environment() { - ensure_remote_stdio_cwd(server_name, config)?; - } return Ok(Some(environment)); } @@ -88,27 +85,6 @@ impl McpRuntimeContext { } } -fn ensure_remote_stdio_cwd( - server_name: &str, - config: &codex_config::McpServerConfig, -) -> Result<(), String> { - let codex_config::McpServerTransportConfig::Stdio { cwd, .. } = &config.transport else { - return Ok(()); - }; - let Some(cwd) = cwd else { - return Err(format!( - "remote stdio MCP server `{server_name}` requires an absolute cwd" - )); - }; - if cwd.is_absolute() { - return Ok(()); - } - Err(format!( - "remote stdio MCP server `{server_name}` requires an absolute cwd, got `{}`", - cwd.display() - )) -} - pub(crate) fn emit_duration(metric: &str, duration: Duration, tags: &[(&str, &str)]) { if let Some(metrics) = codex_otel::global() { let _ = metrics.record_duration(metric, duration, tags); @@ -123,6 +99,7 @@ mod tests { use codex_config::McpServerConfig; use codex_config::McpServerTransportConfig; use codex_exec_server::EnvironmentManager; + use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use super::*; @@ -236,7 +213,7 @@ mod tests { let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { unreachable!("stdio helper should build stdio transport"); }; - *cwd = Some(std::env::temp_dir()); + *cwd = Some(LegacyAppPathString::from_path(&std::env::temp_dir())); for resolved_runtime in [ runtime_context.resolve_server_environment("stdio", &remote_stdio), runtime_context.resolve_server_environment("http", &http_server("remote")), @@ -264,32 +241,4 @@ mod tests { }; assert!(resolved_runtime.is_some()); } - - #[tokio::test] - async fn remote_stdio_requires_absolute_cwd() { - let runtime_context = McpRuntimeContext::new( - Arc::new( - EnvironmentManager::create_for_tests( - Some("ws://127.0.0.1:8765".to_string()), - /*local_runtime_paths*/ None, - ) - .await, - ), - PathBuf::from("/tmp"), - ); - let mut remote_stdio = stdio_server("remote"); - let McpServerTransportConfig::Stdio { cwd, .. } = &mut remote_stdio.transport else { - unreachable!("stdio helper should build stdio transport"); - }; - *cwd = Some(PathBuf::from("relative")); - - let error = match runtime_context.resolve_server_environment("stdio", &remote_stdio) { - Ok(_) => panic!("remote stdio MCP should require absolute cwd"), - Err(error) => error, - }; - assert_eq!( - error, - "remote stdio MCP server `stdio` requires an absolute cwd, got `relative`" - ); - } } diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index 9f007de113..1d896e5f31 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -146,7 +146,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { entry["env_vars"] = array_from_env_vars(env_vars); } if let Some(cwd) = cwd { - entry["cwd"] = value(cwd.to_string_lossy().to_string()); + entry["cwd"] = value(cwd.as_str()); } } McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 0d30ba8b23..42142212c5 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -2,9 +2,9 @@ use std::collections::HashMap; use std::fmt; -use std::path::PathBuf; use std::time::Duration; +use codex_utils_path_uri::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Deserializer; @@ -224,7 +224,7 @@ pub struct RawMcpServerConfig { #[serde(default)] pub env_vars: Option>, #[serde(default)] - pub cwd: Option, + pub cwd: Option, pub http_headers: Option>, #[serde(default)] pub env_http_headers: Option>, @@ -358,7 +358,6 @@ impl TryFrom for McpServerConfig { let environment_id = environment_id.unwrap_or_else(|| DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string()); - validate_remote_stdio_cwd(&transport, &environment_id)?; Ok(Self { transport, @@ -395,30 +394,6 @@ const fn default_enabled() -> bool { true } -fn validate_remote_stdio_cwd( - transport: &McpServerTransportConfig, - environment_id: &str, -) -> Result<(), String> { - if environment_id == DEFAULT_MCP_SERVER_ENVIRONMENT_ID { - return Ok(()); - } - let McpServerTransportConfig::Stdio { cwd, .. } = transport else { - return Ok(()); - }; - let Some(cwd) = cwd else { - return Err(format!( - "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`" - )); - }; - if cwd.is_absolute() { - return Ok(()); - } - Err(format!( - "remote stdio MCP servers require an absolute cwd when environment_id is `{environment_id}`, got `{}`", - cwd.display() - )) -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)] #[serde(untagged, deny_unknown_fields, rename_all = "snake_case")] pub enum McpServerTransportConfig { @@ -432,7 +407,7 @@ pub enum McpServerTransportConfig { #[serde(default, skip_serializing_if = "Vec::is_empty")] env_vars: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] - cwd: Option, + cwd: Option, }, /// https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http StreamableHttp { diff --git a/codex-rs/config/src/mcp_types_tests.rs b/codex-rs/config/src/mcp_types_tests.rs index 5f3933ce43..d3ec9d77e6 100644 --- a/codex-rs/config/src/mcp_types_tests.rs +++ b/codex-rs/config/src/mcp_types_tests.rs @@ -1,7 +1,8 @@ use super::*; +use codex_utils_path_uri::LegacyAppPathString; use pretty_assertions::assert_eq; use std::collections::HashMap; -use std::path::PathBuf; +use std::path::Path; #[test] fn deserialize_stdio_command_server_config() { @@ -52,38 +53,12 @@ fn deserialize_stdio_command_server_config_with_args() { } #[test] -fn deserialize_remote_stdio_server_requires_absolute_cwd() { - let missing_cwd = toml::from_str::( - r#" - command = "echo" - environment_id = "remote" - "#, - ) - .expect_err("remote stdio MCP should require cwd"); - assert!( - missing_cwd - .to_string() - .contains("remote stdio MCP servers require an absolute cwd"), - "unexpected error: {missing_cwd}" - ); - - let relative_cwd = toml::from_str::( - r#" - command = "echo" - environment_id = "remote" - cwd = "relative" - "#, - ) - .expect_err("remote stdio MCP should require absolute cwd"); - assert!( - relative_cwd.to_string().contains("got `relative`"), - "unexpected error: {relative_cwd}" - ); -} - -#[test] -fn deserialize_remote_stdio_server_accepts_absolute_cwd() { - let cwd = std::env::temp_dir(); +fn deserialize_remote_stdio_server_accepts_foreign_absolute_cwd() { + #[cfg(not(windows))] + let cwd = r"C:\Users\openai\share"; + #[cfg(windows)] + let cwd = "/home/openai/share"; + let expected_cwd = LegacyAppPathString::from_path(Path::new(cwd)); let cfg: McpServerConfig = match toml::from_str(&format!( r#" command = "echo" @@ -102,7 +77,7 @@ fn deserialize_remote_stdio_server_accepts_absolute_cwd() { args: vec![], env: None, env_vars: Vec::new(), - cwd: Some(cwd), + cwd: Some(expected_cwd), } ); } @@ -223,7 +198,7 @@ fn deserialize_stdio_command_server_config_with_cwd() { args: vec![], env: None, env_vars: Vec::new(), - cwd: Some(PathBuf::from("/tmp")), + cwd: Some(LegacyAppPathString::from_path(Path::new("/tmp"))), } ); } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a10d01a8a7..31dcc667d1 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1324,6 +1324,9 @@ ], "description": "One action binding value in config.\n\nThis accepts either:\n\n1. A single key spec string (`\"ctrl-a\"`). 2. A list of key spec strings (`[\"ctrl-a\", \"alt-a\"]`).\n\nAn empty list explicitly unbinds the action in that scope. Because an explicit empty list is still a configured value, runtime resolution must not fall through to global or built-in defaults for that action." }, + "LegacyAppPathString": { + "type": "string" + }, "MarketplaceConfig": { "additionalProperties": false, "properties": { @@ -2440,8 +2443,12 @@ "type": "string" }, "cwd": { - "default": null, - "type": "string" + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ], + "default": null }, "default_tools_approval_mode": { "allOf": [ diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9b4b6e93f8..67cb0932de 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -89,6 +89,7 @@ use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SandboxPolicy; +use codex_utils_path_uri::LegacyAppPathString; use serde::Deserialize; use tempfile::tempdir; @@ -5554,6 +5555,7 @@ async fn load_global_mcp_servers_returns_empty_if_missing() -> anyhow::Result<() #[tokio::test] async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { let codex_home = TempDir::new()?; + let expected_cwd = LegacyAppPathString::from_path(codex_home.path()); let mut servers = BTreeMap::new(); servers.insert( @@ -5564,7 +5566,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { args: vec!["hello".to_string()], env: None, env_vars: Vec::new(), - cwd: Some(codex_home.path().to_path_buf()), + cwd: Some(expected_cwd.clone()), }, environment_id: "remote".to_string(), enabled: true, @@ -5603,7 +5605,7 @@ async fn replace_mcp_servers_round_trips_entries() -> anyhow::Result<()> { assert_eq!(args, &vec!["hello".to_string()]); assert!(env.is_none()); assert!(env_vars.is_empty()); - assert_eq!(cwd, &Some(codex_home.path().to_path_buf())); + assert_eq!(cwd, &Some(expected_cwd)); } other => panic!("unexpected transport {other:?}"), } @@ -6104,6 +6106,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { let codex_home = TempDir::new()?; let cwd_path = PathBuf::from("/tmp/codex-mcp"); + let cwd = LegacyAppPathString::from_path(&cwd_path); let servers = BTreeMap::from([( "docs".to_string(), McpServerConfig { @@ -6112,7 +6115,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { args: Vec::new(), env: None, env_vars: Vec::new(), - cwd: Some(cwd_path.clone()), + cwd: Some(cwd.clone()), }, environment_id: codex_config::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(), enabled: true, @@ -6147,7 +6150,7 @@ async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> { let docs = loaded.get("docs").expect("docs entry"); match &docs.transport { McpServerTransportConfig::Stdio { cwd, .. } => { - assert_eq!(cwd.as_deref(), Some(Path::new("/tmp/codex-mcp"))); + assert_eq!(cwd, &Some(LegacyAppPathString::from_path(&cwd_path))); } other => panic!("unexpected transport {other:?}"), } diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 5b99306eb9..3aed9a49ba 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -69,7 +69,7 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { entry["env_vars"] = array_from_env_vars(env_vars); } if let Some(cwd) = cwd { - entry["cwd"] = value(cwd.to_string_lossy().to_string()); + entry["cwd"] = value(cwd.as_str()); } } McpServerTransportConfig::StreamableHttp { diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 95f7fbb931..78fa73ce50 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -27,6 +27,7 @@ use codex_exec_server::HttpRequestParams; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; use codex_models_manager::manager::RefreshStrategy; +use codex_utils_path_uri::LegacyAppPathString; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; @@ -297,7 +298,7 @@ fn stdio_transport_with_cwd( args: Vec::new(), env, env_vars, - cwd, + cwd: cwd.map(|cwd| LegacyAppPathString::from_path(&cwd)), } } diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index b154b060b2..07d6621856 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -18,6 +18,7 @@ use codex_plugin::manifest::PluginManifest; use codex_plugin::manifest::PluginManifestMcpServers; use codex_plugin::manifest::PluginManifestPaths; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::collections::HashMap; @@ -169,7 +170,7 @@ async fn reads_declared_config_only_through_executor_file_system() { args: Vec::new(), env: None, env_vars: Vec::new(), - cwd: Some(plugin_root.to_path_buf()), + cwd: Some(LegacyAppPathString::from_path(plugin_root.as_path())), }, environment_id: "executor-test".to_string(), enabled: true, @@ -223,7 +224,7 @@ async fn reads_manifest_object_config_without_executor_file_system_access() { args: Vec::new(), env: None, env_vars: Vec::new(), - cwd: Some(plugin_root.to_path_buf()), + cwd: Some(LegacyAppPathString::from_path(plugin_root.as_path())), }, environment_id: "executor-test".to_string(), enabled: true, diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 4a9b89de3c..f5c7da5297 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -2,7 +2,6 @@ use std::collections::HashMap; use std::ffi::OsString; use std::future::Future; use std::io; -use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; @@ -354,7 +353,7 @@ impl RmcpClient { args: Vec, env: Option>, env_vars: &[McpServerEnvVar], - cwd: Option, + cwd: Option, launcher: Arc, ) -> io::Result { let transport_recipe = TransportRecipe::Stdio { diff --git a/codex-rs/rmcp-client/src/stdio_server_launcher.rs b/codex-rs/rmcp-client/src/stdio_server_launcher.rs index dfd2420dbd..bc1a8924d5 100644 --- a/codex-rs/rmcp-client/src/stdio_server_launcher.rs +++ b/codex-rs/rmcp-client/src/stdio_server_launcher.rs @@ -15,6 +15,7 @@ use std::collections::HashMap; use std::ffi::OsString; use std::future::Future; use std::io; +use std::path::Path; use std::path::PathBuf; use std::process::Stdio; use std::sync::Arc; @@ -35,6 +36,7 @@ use codex_exec_server::ExecEnvPolicy; use codex_exec_server::ExecParams; use codex_exec_server::ExecProcess; use codex_protocol::config_types::ShellEnvironmentPolicyInherit; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; #[cfg(unix)] use codex_utils_pty::process_group::kill_process_group; @@ -82,7 +84,7 @@ pub struct StdioServerCommand { args: Vec, env: Option>, env_vars: Vec, - cwd: Option, + cwd: Option, } /// Client-side rmcp transport for a launched MCP stdio server. @@ -149,7 +151,7 @@ impl StdioServerCommand { args: Vec, env: Option>, env_vars: Vec, - cwd: Option, + cwd: Option, ) -> Self { Self { program, @@ -247,7 +249,7 @@ impl LocalStdioServerLauncher { } = command; let program_name = program.to_string_lossy().into_owned(); let envs = create_env_for_mcp_server(env, &env_vars).map_err(io::Error::other)?; - let cwd = cwd.unwrap_or(fallback_cwd); + let cwd = cwd.map(PathBuf::from).unwrap_or(fallback_cwd); let resolved_program = program_resolver::resolve(program, &envs, &cwd).map_err(io::Error::other)?; @@ -479,6 +481,9 @@ impl ExecutorStdioServerLauncher { "executor stdio server requires an explicit cwd", )); }; + let cwd: PathUri = LegacyAppPathString::from_path(Path::new(&cwd)) + .try_into() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidInput, err))?; let program_name = program.to_string_lossy().into_owned(); let envs = create_env_overlay_for_remote_mcp_server(env, &env_vars); let remote_env_vars = remote_mcp_env_var_names(&env_vars); @@ -488,7 +493,6 @@ impl ExecutorStdioServerLauncher { // before sending an executor request. let argv = Self::process_api_argv(&program, &args).map_err(io::Error::other)?; let env = Self::process_api_env(envs).map_err(io::Error::other)?; - let cwd = PathUri::from_host_native_path(cwd)?; let process_id = ExecutorProcessTransport::next_process_id(); // Start the MCP server process on the executor with raw pipes. `tty=false` // keeps stdout as a clean protocol stream, while `pipe_stdin=true` lets diff --git a/codex-rs/rmcp-client/tests/foreign_stdio_cwd.rs b/codex-rs/rmcp-client/tests/foreign_stdio_cwd.rs new file mode 100644 index 0000000000..84aca7b92a --- /dev/null +++ b/codex-rs/rmcp-client/tests/foreign_stdio_cwd.rs @@ -0,0 +1,67 @@ +use std::ffi::OsString; +use std::sync::Arc; +use std::sync::Mutex; + +use codex_exec_server::ExecBackend; +use codex_exec_server::ExecBackendFuture; +use codex_exec_server::ExecParams; +use codex_exec_server::ExecServerError; +use codex_rmcp_client::ExecutorStdioServerLauncher; +use codex_rmcp_client::RmcpClient; +use codex_utils_path_uri::PathUri; +use pretty_assertions::assert_eq; + +#[derive(Default)] +struct RecordingExecBackend { + params: Mutex>, +} + +impl ExecBackend for RecordingExecBackend { + fn start(&self, params: ExecParams) -> ExecBackendFuture<'_> { + let mut recorded_params = match self.params.lock() { + Ok(recorded_params) => recorded_params, + Err(poisoned) => poisoned.into_inner(), + }; + *recorded_params = Some(params); + Box::pin(async { + Err(ExecServerError::Protocol( + "stop after recording executor request".to_string(), + )) + }) + } +} + +#[tokio::test] +async fn executor_stdio_forwards_foreign_absolute_cwd_as_path_uri() { + #[cfg(not(windows))] + let cwd = r"C:\Users\openai\share"; + #[cfg(windows)] + let cwd = "/home/openai/share"; + #[cfg(not(windows))] + let expected_cwd: PathUri = "file:///C:/Users/openai/share" + .parse() + .expect("expected cwd should be a path URI"); + #[cfg(windows)] + let expected_cwd: PathUri = "file:///home/openai/share" + .parse() + .expect("expected cwd should be a path URI"); + let backend = Arc::new(RecordingExecBackend::default()); + let launcher = Arc::new(ExecutorStdioServerLauncher::new(backend.clone())); + + let _ = RmcpClient::new_stdio_client( + OsString::from("echo"), + Vec::new(), + /*env*/ None, + &[], + Some(cwd.to_string()), + launcher, + ) + .await; + let params = backend + .params + .lock() + .expect("recorded params lock should not be poisoned") + .take() + .expect("executor start request should be recorded"); + assert_eq!(params.cwd, expected_cwd); +} diff --git a/codex-rs/tui/src/history_cell/mcp.rs b/codex-rs/tui/src/history_cell/mcp.rs index 599a44f092..b71af3d251 100644 --- a/codex-rs/tui/src/history_cell/mcp.rs +++ b/codex-rs/tui/src/history_cell/mcp.rs @@ -414,7 +414,7 @@ pub(crate) fn new_mcp_tools_output( lines.push(vec![" • Command: ".into(), cmd_display.into()].into()); if let Some(cwd) = cwd.as_ref() { - lines.push(vec![" • Cwd: ".into(), cwd.display().to_string().into()].into()); + lines.push(vec![" • Cwd: ".into(), cwd.to_string().into()].into()); } let env_display = format_env_display(env.as_ref(), env_vars); diff --git a/codex-rs/utils/path-uri/src/api_path_string.rs b/codex-rs/utils/path-uri/src/api_path_string.rs index 3e33f316ca..e4fdbe7c27 100644 --- a/codex-rs/utils/path-uri/src/api_path_string.rs +++ b/codex-rs/utils/path-uri/src/api_path_string.rs @@ -7,6 +7,7 @@ use serde::Deserialize; use serde::Serialize; use serde::Serializer; use std::fmt; +use std::path::Path; use thiserror::Error; use ts_rs::TS; @@ -18,10 +19,10 @@ use ts_rs::TS; /// /// When converting from [`PathUri`], "native" refers to the supplied /// [`PathConvention`], which may be foreign to the operating system running -/// this process. The inner string is private so path-producing code must convert -/// from [`AbsolutePathBuf`] or use [`Self::from_path_uri`] instead of bypassing -/// the intended conversion boundary. Non-UTF-8 paths are converted to UTF-8 -/// lossily because this API value is serialized as a JSON string. +/// this process. The inner string is private so path-producing code must use a +/// path conversion method instead of bypassing the intended conversion +/// boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API +/// value is serialized as a JSON string. /// /// Deserialization accepts any UTF-8 string without interpreting or validating /// it. That unrestricted construction path is intentionally available only to @@ -35,9 +36,14 @@ use ts_rs::TS; pub struct LegacyAppPathString(String); impl LegacyAppPathString { + /// Preserves path text without interpreting it using the current host. + pub fn from_path(path: &Path) -> Self { + Self(path.to_string_lossy().into_owned()) + } + /// Renders an absolute path using the current host's path convention. pub fn from_abs_path(path: &AbsolutePathBuf) -> Self { - Self(path.to_string_lossy().into_owned()) + Self::from_path(path.as_path()) } /// Renders a path URI using the requested native path convention. diff --git a/codex-rs/utils/path-uri/src/api_path_string_tests.rs b/codex-rs/utils/path-uri/src/api_path_string_tests.rs index 3439ca11ea..c71a65abf3 100644 --- a/codex-rs/utils/path-uri/src/api_path_string_tests.rs +++ b/codex-rs/utils/path-uri/src/api_path_string_tests.rs @@ -496,6 +496,23 @@ fn foreign_absolute_syntax_deserializes_without_host_interpretation() { } } +#[test] +fn from_path_preserves_foreign_absolute_path_for_uri_conversion() { + #[cfg(not(windows))] + let (foreign_path, expected_uri) = (r"C:\Users\openai\share", "file:///C:/Users/openai/share"); + #[cfg(windows)] + let (foreign_path, expected_uri) = ("/home/openai/share", "file:///home/openai/share"); + + let path: PathUri = LegacyAppPathString::from_path(std::path::Path::new(foreign_path)) + .try_into() + .expect("foreign absolute path should convert"); + + assert_eq!( + path, + PathUri::parse(expected_uri).expect("valid expected URI") + ); +} + #[test] fn renders_an_absolute_path_using_the_host_convention() { #[cfg(unix)] From 7c22d376e510e5f596e000260714d24b5b8f0783 Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Mon, 22 Jun 2026 19:51:03 -0700 Subject: [PATCH 69/86] Propagate safety buffering treatment metadata (#29473) ## Summary - read the request-scoped safety-buffering treatment from HTTP response headers and per-turn WebSocket metadata through one shared header parser - combine that treatment with Responses API safety-buffering signals - propagate `showBufferingUi` and nullable `fasterModel` through the existing `model/safetyBuffering/updated` app-server notification - update the app-server documentation and generated JSON and TypeScript schemas The public implementation contains no model mapping or real model identifier. Tests and protocol examples use generic `current-model` and `faster-model` placeholders only. ## Dependencies - server-side treatment evaluation: https://github.com/openai/openai/pull/1060247 - initial Responses API safety-buffering propagation: https://github.com/openai/codex/pull/29371 - Codex App UI: https://github.com/openai/openai/pull/1057789 ## Validation - Codex API tests: 129 passed - focused Codex core safety-buffering integration test passed - app-server protocol tests passed after regenerating schema fixtures - Clippy fix and repository formatting completed successfully The broader app-server run compiled all changed crates and completed with 1,269 passing tests. Its remaining failures were unrelated environment limitations: macOS sandbox application was denied, one expected test binary was unavailable, and several existing subprocess tests timed out as a result. --- .../schema/json/ServerNotification.json | 10 ++++ .../codex_app_server_protocol.schemas.json | 10 ++++ .../codex_app_server_protocol.v2.schemas.json | 10 ++++ ...delSafetyBufferingUpdatedNotification.json | 10 ++++ ...ModelSafetyBufferingUpdatedNotification.ts | 2 +- .../src/protocol/common.rs | 10 ++-- .../src/protocol/v2/model.rs | 2 + codex-rs/app-server/README.md | 2 +- .../app-server/src/bespoke_event_handling.rs | 2 + codex-rs/codex-api/src/common.rs | 18 +++++++ .../src/endpoint/responses_websocket.rs | 21 ++++++-- codex-rs/codex-api/src/lib.rs | 1 + codex-rs/codex-api/src/safety_buffering.rs | 54 +++++++++++++++++++ codex-rs/codex-api/src/sse/responses.rs | 37 +++++++++++-- codex-rs/core/src/session/turn.rs | 2 + codex-rs/core/tests/suite/safety_buffering.rs | 15 +++++- codex-rs/protocol/src/protocol.rs | 2 + 17 files changed, 193 insertions(+), 15 deletions(-) create mode 100644 codex-rs/codex-api/src/safety_buffering.rs diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 74fb9e739c..b79e3cb13f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2585,6 +2585,12 @@ }, "ModelSafetyBufferingUpdatedNotification": { "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, "model": { "type": "string" }, @@ -2594,6 +2600,9 @@ }, "type": "array" }, + "showBufferingUi": { + "type": "boolean" + }, "threadId": { "type": "string" }, @@ -2610,6 +2619,7 @@ "required": [ "model", "reasons", + "showBufferingUi", "threadId", "turnId", "useCases" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 1b9719f986..597fd5a1b8 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12642,6 +12642,12 @@ "ModelSafetyBufferingUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, "model": { "type": "string" }, @@ -12651,6 +12657,9 @@ }, "type": "array" }, + "showBufferingUi": { + "type": "boolean" + }, "threadId": { "type": "string" }, @@ -12667,6 +12676,7 @@ "required": [ "model", "reasons", + "showBufferingUi", "threadId", "turnId", "useCases" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 4ade0622cd..3af48cc6c0 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9046,6 +9046,12 @@ "ModelSafetyBufferingUpdatedNotification": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, "model": { "type": "string" }, @@ -9055,6 +9061,9 @@ }, "type": "array" }, + "showBufferingUi": { + "type": "boolean" + }, "threadId": { "type": "string" }, @@ -9071,6 +9080,7 @@ "required": [ "model", "reasons", + "showBufferingUi", "threadId", "turnId", "useCases" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json index 5ed692480b..ab542b6399 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelSafetyBufferingUpdatedNotification.json @@ -1,6 +1,12 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "fasterModel": { + "type": [ + "string", + "null" + ] + }, "model": { "type": "string" }, @@ -10,6 +16,9 @@ }, "type": "array" }, + "showBufferingUi": { + "type": "boolean" + }, "threadId": { "type": "string" }, @@ -26,6 +35,7 @@ "required": [ "model", "reasons", + "showBufferingUi", "threadId", "turnId", "useCases" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts index 484f8c6146..5abc9f3bf6 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelSafetyBufferingUpdatedNotification.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type ModelSafetyBufferingUpdatedNotification = { threadId: string, turnId: string, model: string, useCases: Array, reasons: Array, }; +export type ModelSafetyBufferingUpdatedNotification = { threadId: string, turnId: string, model: string, useCases: Array, reasons: Array, showBufferingUi: boolean, fasterModel: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 71773f2fc8..ba63f59e9a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3392,9 +3392,11 @@ mod tests { v2::ModelSafetyBufferingUpdatedNotification { thread_id: "thr_123".to_string(), turn_id: "turn_123".to_string(), - model: "gpt-5.4".to_string(), + model: "current-model".to_string(), use_cases: vec!["cyber".to_string()], reasons: vec!["user_risk".to_string()], + show_buffering_ui: true, + faster_model: Some("faster-model".to_string()), }, ); assert_eq!( @@ -3403,9 +3405,11 @@ mod tests { "params": { "threadId": "thr_123", "turnId": "turn_123", - "model": "gpt-5.4", + "model": "current-model", "useCases": ["cyber"], - "reasons": ["user_risk"] + "reasons": ["user_risk"], + "showBufferingUi": true, + "fasterModel": "faster-model" } }), serde_json::to_value(¬ification)?, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/model.rs b/codex-rs/app-server-protocol/src/protocol/v2/model.rs index 2ce1819024..bb6c411124 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/model.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/model.rs @@ -172,4 +172,6 @@ pub struct ModelSafetyBufferingUpdatedNotification { pub model: String, pub use_cases: Vec, pub reasons: Vec, + pub show_buffering_ui: bool, + pub faster_model: Option, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 5f25914e4e..50f0052094 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1355,7 +1355,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo?, additionalDetails? } }`. - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. -- `model/safetyBuffering/updated` — `{ threadId, turnId, model, useCases, reasons }` when a response enters safety buffering. This notification is transient and is not persisted in rollout history. +- `model/safetyBuffering/updated` — `{ threadId, turnId, model, useCases, reasons, showBufferingUi, fasterModel }` when a response enters safety buffering. `fasterModel` is nullable. This notification is transient and is not persisted in rollout history. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). - `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. - `turn/moderationMetadata` — experimental; `{ threadId, turnId, metadata }` when a first-party backend supplies turn-scoped moderation metadata for client-side presentation. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index bc69505066..36dbdd9d3b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -359,6 +359,8 @@ pub(crate) async fn apply_bespoke_event_handling( model: event.model, use_cases: event.use_cases, reasons: event.reasons, + show_buffering_ui: event.show_buffering_ui, + faster_model: event.faster_model, }; outgoing .send_server_notification(ServerNotification::ModelSafetyBufferingUpdated( diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index aeea85e7fc..ad37f6d5a4 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -118,6 +118,24 @@ pub enum ResponseEvent { pub struct SafetyBuffering { pub use_cases: Vec, pub reasons: Vec, + #[serde(skip)] + pub show_buffering_ui: bool, + #[serde(skip)] + pub faster_model: Option, +} + +impl SafetyBuffering { + pub(crate) fn with_treatment(mut self, treatment: &SafetyBufferingTreatment) -> Self { + self.show_buffering_ui = treatment.show_buffering_ui; + self.faster_model.clone_from(&treatment.faster_model); + self + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct SafetyBufferingTreatment { + pub show_buffering_ui: bool, + pub faster_model: Option, } #[derive(Debug, Serialize, Clone, PartialEq)] diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index d44825154a..ea7565ef57 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -2,9 +2,11 @@ use crate::auth::SharedAuthProvider; use crate::common::ResponseEvent; use crate::common::ResponseStream; use crate::common::ResponsesWsRequest; +use crate::common::SafetyBufferingTreatment; use crate::error::ApiError; use crate::provider::Provider; use crate::rate_limits::parse_rate_limit_event; +use crate::safety_buffering::treatment_from_headers; use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; @@ -595,12 +597,12 @@ fn map_wrapped_websocket_error_event( Some(ApiError::Transport(TransportError::Http { status, url: None, - headers: headers.map(json_headers_to_http_headers), + headers: headers.as_ref().map(json_headers_to_http_headers), body: Some(original_payload), })) } -fn json_headers_to_http_headers(headers: JsonMap) -> HeaderMap { +fn json_headers_to_http_headers(headers: &JsonMap) -> HeaderMap { let mut mapped = HeaderMap::new(); for (name, value) in headers { let Ok(header_name) = HeaderName::from_bytes(name.as_bytes()) else { @@ -614,9 +616,9 @@ fn json_headers_to_http_headers(headers: JsonMap) -> HeaderMap { mapped } -fn json_header_value(value: Value) -> Option { +fn json_header_value(value: &Value) -> Option { let value = match value { - Value::String(value) => value, + Value::String(value) => value.clone(), Value::Number(value) => value.to_string(), Value::Bool(value) => value.to_string(), _ => return None, @@ -634,6 +636,7 @@ async fn run_websocket_response_stream( turn_state: Option<&OnceLock>, ) -> Result<(), ApiError> { let mut last_server_model: Option = None; + let mut safety_buffering_treatment = SafetyBufferingTreatment::default(); send_websocket_request( ws_stream, request_text, @@ -687,9 +690,17 @@ async fn run_websocket_response_stream( { let _ = turn_state.set(response_turn_state); } + if let Some(headers) = event.headers.as_ref().and_then(Value::as_object) + && let Some(treatment) = + treatment_from_headers(&json_headers_to_http_headers(headers)) + { + safety_buffering_treatment = treatment; + } let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); - let safety_buffering = event.safety_buffering(); + let safety_buffering = event + .safety_buffering() + .map(|buffering| buffering.with_treatment(&safety_buffering_treatment)); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 4881e77fbc..1e7294be8f 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -8,6 +8,7 @@ pub(crate) mod images; pub(crate) mod provider; pub(crate) mod rate_limits; pub(crate) mod requests; +pub(crate) mod safety_buffering; pub(crate) mod search; pub(crate) mod sse; pub(crate) mod telemetry; diff --git a/codex-rs/codex-api/src/safety_buffering.rs b/codex-rs/codex-api/src/safety_buffering.rs new file mode 100644 index 0000000000..e5f3dcd79e --- /dev/null +++ b/codex-rs/codex-api/src/safety_buffering.rs @@ -0,0 +1,54 @@ +use crate::common::SafetyBufferingTreatment; +use http::HeaderMap; + +pub(crate) const X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER: &str = "x-codex-safety-buffering-enabled"; +pub(crate) const X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER: &str = + "x-codex-safety-buffering-faster-model"; + +pub(crate) fn treatment_from_headers(headers: &HeaderMap) -> Option { + let show_buffering_ui = headers + .get(X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER) + .and_then(|value| value.to_str().ok())? + .eq_ignore_ascii_case("true"); + let faster_model = if show_buffering_ui { + headers + .get(X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + } else { + None + }; + + Some(SafetyBufferingTreatment { + show_buffering_ui, + faster_model, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use http::HeaderValue; + use pretty_assertions::assert_eq; + + #[test] + fn reads_treatment_from_http_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + X_CODEX_SAFETY_BUFFERING_ENABLED_HEADER, + HeaderValue::from_static("true"), + ); + headers.insert( + X_CODEX_SAFETY_BUFFERING_FASTER_MODEL_HEADER, + HeaderValue::from_static("faster-model"), + ); + + assert_eq!( + treatment_from_headers(&headers), + Some(SafetyBufferingTreatment { + show_buffering_ui: true, + faster_model: Some("faster-model".to_string()), + }) + ); + } +} diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 0c20e10392..71f73c0232 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -1,8 +1,10 @@ use crate::common::ResponseEvent; use crate::common::ResponseStream; use crate::common::SafetyBuffering; +use crate::common::SafetyBufferingTreatment; use crate::error::ApiError; use crate::rate_limits::parse_all_rate_limits; +use crate::safety_buffering::treatment_from_headers; use crate::telemetry::SseTelemetry; use codex_client::ByteStream; use codex_client::StreamResponse; @@ -55,6 +57,8 @@ pub fn spawn_response_stream( .get(REQUEST_ID_HEADER) .and_then(|value| value.to_str().ok()) .map(str::to_string); + let safety_buffering_treatment = + treatment_from_headers(&stream_response.headers).unwrap_or_default(); if let Some(turn_state) = turn_state.as_ref() && let Some(header_value) = stream_response .headers @@ -79,7 +83,14 @@ pub fn spawn_response_stream( .send(Ok(ResponseEvent::ServerReasoningIncluded(true))) .await; } - process_sse(stream_response.bytes, tx_event, idle_timeout, telemetry).await; + process_sse_with_treatment( + stream_response.bytes, + tx_event, + idle_timeout, + telemetry, + safety_buffering_treatment, + ) + .await; }); ResponseStream { @@ -149,7 +160,7 @@ struct ResponseCompletedOutputTokensDetails { pub struct ResponsesStreamEvent { #[serde(rename = "type")] pub(crate) kind: String, - headers: Option, + pub(crate) headers: Option, metadata: Option, response: Option, item: Option, @@ -437,11 +448,29 @@ pub fn process_responses_event( Ok(None) } +#[cfg(test)] pub async fn process_sse( stream: ByteStream, tx_event: mpsc::Sender>, idle_timeout: Duration, telemetry: Option>, +) { + process_sse_with_treatment( + stream, + tx_event, + idle_timeout, + telemetry, + SafetyBufferingTreatment::default(), + ) + .await; +} + +async fn process_sse_with_treatment( + stream: ByteStream, + tx_event: mpsc::Sender>, + idle_timeout: Duration, + telemetry: Option>, + safety_buffering_treatment: SafetyBufferingTreatment, ) { let mut stream = stream.eventsource(); let mut response_error: Option = None; @@ -486,7 +515,9 @@ pub async fn process_sse( }; let model_verifications = event.model_verifications(); let turn_moderation_metadata = event.turn_moderation_metadata(); - let safety_buffering = event.safety_buffering(); + let safety_buffering = event + .safety_buffering() + .map(|buffering| buffering.with_treatment(&safety_buffering_treatment)); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 81f9f52356..cdb8468e55 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2234,6 +2234,8 @@ async fn try_run_sampling_request( model: turn_context.model_info.slug.clone(), use_cases: buffering.use_cases, reasons: buffering.reasons, + show_buffering_ui: buffering.show_buffering_ui, + faster_model: buffering.faster_model, }), ) .await; diff --git a/codex-rs/core/tests/suite/safety_buffering.rs b/codex-rs/core/tests/suite/safety_buffering.rs index 6c2df1b6da..6e41b23766 100644 --- a/codex-rs/core/tests/suite/safety_buffering.rs +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -5,8 +5,9 @@ use codex_protocol::protocol::SafetyBufferingEvent; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; -use core_test_support::responses::mount_sse_once; +use core_test_support::responses::mount_response_once; use core_test_support::responses::sse; +use core_test_support::responses::sse_response; use core_test_support::responses::start_mock_server; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; @@ -15,6 +16,8 @@ use core_test_support::wait_for_event_match; use pretty_assertions::assert_eq; use serde_json::json; +const FASTER_MODEL: &str = "faster-model"; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn emits_safety_buffering_with_the_requested_model() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); @@ -25,7 +28,13 @@ async fn emits_safety_buffering_with_the_requested_model() -> anyhow::Result<()> "use_cases": ["cyber"], "reasons": ["policy-check"], }); - mount_sse_once(&server, sse(vec![created, ev_completed("resp-1")])).await; + mount_response_once( + &server, + sse_response(sse(vec![created, ev_completed("resp-1")])) + .insert_header("x-codex-safety-buffering-enabled", "true") + .insert_header("x-codex-safety-buffering-faster-model", FASTER_MODEL), + ) + .await; let test = test_codex().build(&server).await?; test.codex @@ -52,6 +61,8 @@ async fn emits_safety_buffering_with_the_requested_model() -> anyhow::Result<()> model: test.session_configured.model.clone(), use_cases: vec!["cyber".to_string()], reasons: vec!["policy-check".to_string()], + show_buffering_ui: true, + faster_model: Some(FASTER_MODEL.to_string()), } ); wait_for_event(&test.codex, |event| { diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5a73ac4c90..e1fe605462 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1952,6 +1952,8 @@ pub struct SafetyBufferingEvent { pub model: String, pub use_cases: Vec, pub reasons: Vec, + pub show_buffering_ui: bool, + pub faster_model: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] From 27f22b54aef4d7e5eb6c564e969c961c74605461 Mon Sep 17 00:00:00 2001 From: Chris Bookholt Date: Mon, 22 Jun 2026 20:09:20 -0700 Subject: [PATCH 70/86] [codex] Reject unlowered PowerShell AST regions (#24092) ## Why On Windows, Codex uses a PowerShell safe-command classifier to decide whether a command is read-only enough to run without additional approval. The classifier lowers `EndBlock.Statements` into argv-like command words and checks those words against a safelist. PowerShell can execute code stored elsewhere in the AST. Parameter defaults, named blocks, `using` preambles, and top-level `trap` handlers are not represented in the lowered statement list. Ignoring those regions can make a side-effecting script look like a read-only command. ## What Fail closed whenever a PowerShell script contains executable AST content that the current lowering does not represent. ## How - Return `unsupported` for parameter, dynamic-parameter, begin, process, and clean blocks. - Return `unsupported` for `using module` and `using assembly` preambles. - Return `unsupported` for non-empty `EndBlock.Traps` collections. - Preserve compatibility with Windows PowerShell 5.1 by looking up `CleanBlock` dynamically. - Treat `unsupported` as a failure to prove that the command is safe, routing it through the normal approval path. - Add parser-level and end-to-end regressions for parameter blocks, named blocks, using statements, and trap handlers. This does not make these PowerShell forms invalid or prevent them from running. It prevents automatic safe-command approval when the classifier cannot account for all executable behavior. ## Testing - `just test -p codex-shell-command` - Windows CI exercises the parser and end-to-end safe-command regressions against a real PowerShell installation. --------- Co-authored-by: viyatb-oai --- .../src/command_safety/powershell_parser.ps1 | 15 +++++ .../src/command_safety/powershell_parser.rs | 58 +++++++++++++++++++ .../command_safety/windows_safe_commands.rs | 40 +++++++++++++ 3 files changed, 113 insertions(+) diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 b/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 index de696c88bd..92291e7c98 100644 --- a/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 +++ b/codex-rs/shell-command/src/command_safety/powershell_parser.ps1 @@ -42,6 +42,21 @@ function Invoke-ParseRequest { return @{ id = $RequestId; status = 'parse_errors' } } + # Top-level AST regions and collections outside the end-block statement list + # can execute code that the command lowering below does not inspect. + $cleanBlock = $ast.PSObject.Properties['CleanBlock'] + if ( + $ast.ParamBlock -ne $null -or + $ast.DynamicParamBlock -ne $null -or + $ast.BeginBlock -ne $null -or + $ast.ProcessBlock -ne $null -or + ($cleanBlock -ne $null -and $cleanBlock.Value -ne $null) -or + $ast.UsingStatements.Count -gt 0 -or + $ast.EndBlock.Traps.Count -gt 0 + ) { + return @{ id = $RequestId; status = 'unsupported' } + } + # PowerShell's stop-parsing marker hands the remaining source text to native # commands with runtime argument handling that does not match the AST shape we # flatten below. Keep that form out of the argv-like lowering path entirely. diff --git a/codex-rs/shell-command/src/command_safety/powershell_parser.rs b/codex-rs/shell-command/src/command_safety/powershell_parser.rs index ddcbdf5727..00b18af6f0 100644 --- a/codex-rs/shell-command/src/command_safety/powershell_parser.rs +++ b/codex-rs/shell-command/src/command_safety/powershell_parser.rs @@ -310,4 +310,62 @@ mod tests { .unwrap(); assert_eq!(parsed, PowershellParseOutcome::Unsupported); } + + #[test] + fn parser_process_rejects_param_blocks() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse("param([string]$path = (Get-Location)) Write-Output test") + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } + + #[test] + fn parser_process_rejects_named_blocks() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse("begin { Set-Content codex_poc.txt pwned } end { Get-Content Cargo.toml }") + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } + + #[test] + fn parser_process_rejects_using_statements() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse("using module ./codex_poc.psm1\nGet-Content Cargo.toml") + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } + + #[test] + fn parser_process_rejects_trap_blocks() { + let Some(powershell) = try_find_powershell_executable_blocking() else { + return; + }; + let powershell = powershell.as_path().to_str().unwrap(); + let mut parser = PowershellParserProcess::spawn(powershell).unwrap(); + + let parsed = parser + .parse( + "trap { Set-Content codex_poc.txt pwned; continue } Get-Content missing -ErrorAction Stop", + ) + .unwrap(); + assert_eq!(parsed, PowershellParseOutcome::Unsupported); + } } diff --git a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs index a8dcef6de6..df2025253b 100644 --- a/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/shell-command/src/command_safety/windows_safe_commands.rs @@ -412,6 +412,46 @@ mod tests { ]))); } + #[test] + fn rejects_powershell_param_blocks() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "param([string]$path = (Get-Location)) Write-Output test", + ]))); + } + + #[test] + fn rejects_powershell_named_blocks() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "begin { Set-Content codex_poc.txt pwned } end { Get-Content Cargo.toml }", + ]))); + } + + #[test] + fn rejects_powershell_using_statements() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "using module ./codex_poc.psm1\nGet-Content Cargo.toml", + ]))); + } + + #[test] + fn rejects_powershell_trap_blocks() { + assert!(!is_safe_command_windows(&vec_str(&[ + "powershell.exe", + "-NoProfile", + "-Command", + "trap { Set-Content codex_poc.txt pwned; continue } Get-Content missing -ErrorAction Stop", + ]))); + } + #[test] fn rejects_powershell_commands_with_side_effects() { assert!(!is_safe_command_windows(&vec_str(&[ From e0ac5d3c1552b4eb2c11d1f7e215bb933c78ad99 Mon Sep 17 00:00:00 2001 From: daniel-oai Date: Mon, 22 Jun 2026 20:44:48 -0700 Subject: [PATCH 71/86] [codex] Expose service tier and reasoning effort in OTEL (#29155) ## Summary NVIDIA asked to measure Fast mode usage and reasoning effort from Codex CLI OTEL logs. Add the finalized `service_tier` and `model_reasoning_effort` to the existing `codex.sse_event` `response.completed` record. This intentionally reuses the existing completion event and leaves transport APIs and shared telemetry plumbing unchanged. ## Testing - `cargo build -p codex-cli --bin codex` - `just test -p codex-core responses_api_emits_api_request_event` - End-to-end with the built CLI and a local OTLP/HTTP collector: - Fast/high emitted `service_tier=priority` and `model_reasoning_effort=high` with token usage. - Standard/low omitted `service_tier` and emitted `model_reasoning_effort=low` with token usage. --- codex-rs/core/src/client.rs | 25 +++++++++++++-- codex-rs/core/tests/suite/otel.rs | 32 +++++++++++++++++-- codex-rs/otel/src/events/session_telemetry.rs | 16 ++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 8ebea984d6..a01cd7826f 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -162,6 +162,19 @@ pub(crate) struct CompactConversationRequestSettings { pub(crate) service_tier: Option, } +fn session_telemetry_for_request( + session_telemetry: &SessionTelemetry, + request: &ResponsesApiRequest, +) -> SessionTelemetry { + session_telemetry.clone().with_inference_request( + request.service_tier.as_deref(), + request + .reasoning + .as_ref() + .and_then(|reasoning| reasoning.effort.as_ref()), + ) +} + /// Session-scoped state shared by all [`ModelClient`] clones. /// /// This is intentionally kept minimal so `ModelClient` does not need to hold a full `Config`. Most @@ -1319,6 +1332,8 @@ impl ModelClientSession { let store = request.store; self.client .prepare_response_items_for_request(&mut request.input, store); + let request_session_telemetry = + session_telemetry_for_request(session_telemetry, &request); let inference_trace_attempt = inference_trace.start_attempt(); inference_trace_attempt.add_request_headers(&mut options.extra_headers); inference_trace_attempt.record_started(&request); @@ -1334,7 +1349,7 @@ impl ModelClientSession { Ok(stream) => { let (stream, _) = map_response_stream( stream, - session_telemetry.clone(), + request_session_telemetry, inference_trace_attempt, Arc::clone(&self.client.state.provider), ); @@ -1426,6 +1441,12 @@ impl ModelClientSession { service_tier.clone(), responses_metadata, )?; + let request_session_telemetry = if warmup { + // `generate=false` prewarm is connection setup, not an inference request. + session_telemetry.clone() + } else { + session_telemetry_for_request(session_telemetry, &request) + }; let mut client_metadata = self .client .build_ws_client_metadata(responses_metadata, model_info.use_responses_lite); @@ -1529,7 +1550,7 @@ impl ModelClientSession { })?; let (stream, last_request_rx) = map_response_stream( stream_result, - session_telemetry.clone(), + request_session_telemetry, inference_trace_attempt, Arc::clone(&self.client.state.provider), ); diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 95811d3f63..e3415d945b 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -3,6 +3,7 @@ use codex_features::Feature; use codex_otel::SessionTelemetry; use codex_otel::TelemetryAuthMode; use codex_protocol::ThreadId; +use codex_protocol::config_types::ServiceTier; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; @@ -114,9 +115,17 @@ fn extract_log_field_does_not_confuse_similar_keys() { async fn responses_api_emits_api_request_event() { let server = start_mock_server().await; - mount_sse_once(&server, sse(vec![ev_completed("done")])).await; + let response_mock = mount_sse_once(&server, sse(vec![ev_completed("done")])).await; - let TestCodex { codex, .. } = test_codex().build(&server).await.unwrap(); + let TestCodex { codex, .. } = test_codex() + .with_model("gpt-5.4") + .with_config(|config| { + config.service_tier = Some(ServiceTier::Fast.request_value().to_string()); + config.model_reasoning_effort = Some(ReasoningEffort::High); + }) + .build(&server) + .await + .unwrap(); codex .submit(Op::UserInput { @@ -134,6 +143,10 @@ async fn responses_api_emits_api_request_event() { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + let request_body = response_mock.single_request().body_json(); + assert_eq!(request_body["service_tier"].as_str(), Some("priority")); + assert_eq!(request_body["reasoning"]["effort"].as_str(), Some("high")); + logs_assert(|lines: &[&str]| { lines .iter() @@ -142,6 +155,21 @@ async fn responses_api_emits_api_request_event() { .unwrap_or_else(|| Err("expected codex.api_request event".to_string())) }); + logs_assert(|lines: &[&str]| { + lines + .iter() + .find(|line| { + line.contains("codex.sse_event") + && line.contains("event.kind=response.completed") + && line.contains("service_tier=\"priority\"") + && line.contains("model_reasoning_effort=\"high\"") + }) + .map(|_| Ok(())) + .unwrap_or_else(|| { + Err("expected response.completed event with inference attributes".to_string()) + }) + }); + logs_assert(|lines: &[&str]| { lines .iter() diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index efff5c24e3..02bd39714a 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -94,6 +94,8 @@ pub struct SessionTelemetryMetadata { pub(crate) session_source: String, pub(crate) model: String, pub(crate) slug: String, + pub(crate) service_tier: Option, + pub(crate) model_reasoning_effort: Option, pub(crate) log_user_prompts: bool, pub(crate) app_version: &'static str, pub(crate) terminal_type: String, @@ -118,6 +120,16 @@ impl SessionTelemetry { self } + pub fn with_inference_request( + mut self, + service_tier: Option<&str>, + model_reasoning_effort: Option<&ReasoningEffort>, + ) -> Self { + self.metadata.service_tier = service_tier.map(str::to_owned); + self.metadata.model_reasoning_effort = model_reasoning_effort.map(ToString::to_string); + self + } + pub fn with_metrics_service_name(mut self, service_name: &str) -> Self { self.metadata.service_name = Some(sanitize_metric_tag_value(service_name)); self @@ -389,6 +401,8 @@ impl SessionTelemetry { session_source: session_source.to_string(), model: model.to_owned(), slug: slug.to_owned(), + service_tier: None, + model_reasoning_effort: None, log_user_prompts, app_version: env!("CARGO_PKG_VERSION"), terminal_type, @@ -896,6 +910,8 @@ impl SessionTelemetry { cached_token_count = cached_token_count, reasoning_token_count = reasoning_token_count, tool_token_count = %tool_token_count, + service_tier = self.metadata.service_tier.as_deref(), + model_reasoning_effort = self.metadata.model_reasoning_effort.as_deref(), }, log: {}, trace: {}, From 802c98cab4b65c3faa689a4d7962d28469f0d659 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 23:17:12 -0700 Subject: [PATCH 72/86] [codex] stylistic changes (#29068) ## Summary - express remote compaction result handling as an exhaustive match - preserve the special `TurnAborted` path without emitting a generic compaction error - rely on the standard `test_codex` provider setup in the compaction budget test Follow-up to review feedback on #28707. ## Testing - `just test -p codex-core compaction_budget_exhaustion_aborts_without_error_or_retry` - `just fmt` --- codex-rs/core/src/compact_remote_v2.rs | 22 ++++++++++----------- codex-rs/core/tests/suite/rollout_budget.rs | 9 ++------- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 54d139a0e1..d188f9dfc1 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -165,18 +165,18 @@ async fn run_remote_compact_task_inner( attempt .track(sess.as_ref(), status, codex_error, analytics_details) .await; - if matches!(&result, Err(CodexErr::TurnAborted)) { - return result; + match result { + Ok(()) => Ok(()), + Err(err @ CodexErr::TurnAborted) => Err(err), + Err(err) => { + sess.track_turn_codex_error(turn_context, &err); + let event = EventMsg::Error( + err.to_error_event(Some("Error running remote compact task".to_string())), + ); + sess.send_event(turn_context, event).await; + Err(err) + } } - if let Err(err) = result { - sess.track_turn_codex_error(turn_context, &err); - let event = EventMsg::Error( - err.to_error_event(Some("Error running remote compact task".to_string())), - ); - sess.send_event(turn_context, event).await; - return Err(err); - } - Ok(()) } async fn run_remote_compact_task_inner_impl( diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index feafdc51ef..c051f01af2 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -284,15 +284,8 @@ async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: b ]) }; let responses = mount_sse_sequence(&server, vec![compact_response]).await; - let mut model_provider = built_in_model_providers(/*openai_base_url*/ None)["openai"].clone(); - model_provider.base_url = Some(format!("{}/v1", server.uri())); - model_provider.supports_websockets = false; - if !remote_v2 { - model_provider.name = "OpenAI-compatible test provider".to_string(); - } let test = test_codex() .with_config(move |config| { - config.model_provider = model_provider; config.rollout_budget = Some(RolloutBudgetConfig { limit_tokens: 10, reminder_at_remaining_tokens: vec![5], @@ -303,6 +296,8 @@ async fn compaction_budget_exhaustion_aborts_without_error_or_retry(remote_v2: b .features .enable(Feature::RemoteCompactionV2) .expect("test config should allow remote compaction v2"); + } else { + config.model_provider.name = "OpenAI-compatible test provider".to_string(); } }) .build(&server) From a22e3d0b8295550b6b8630cdaf4291af18aa4de1 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Mon, 22 Jun 2026 23:19:42 -0700 Subject: [PATCH 73/86] Remove redundant Codex Apps manager flag (#29518) ## Why Codex Apps server admission is already decided before `McpConnectionManager` is constructed. `effective_mcp_servers` and `effective_mcp_servers_from_configured` remove the server when the apps feature or required authentication is unavailable, so storing the same decision on the manager duplicates state that can drift from the effective server map. ## What changed - Remove `host_owned_codex_apps_enabled` from `McpConnectionManager` and its constructor. - Identify the host-owned Codex Apps server by its reserved server name once it is present in the effective server map. - Remove the now-unused flag calculations and constructor arguments from production and test callsites. --- codex-rs/codex-mcp/src/connection_manager.rs | 6 +----- codex-rs/codex-mcp/src/connection_manager_tests.rs | 1 - codex-rs/codex-mcp/src/mcp/mod.rs | 4 ---- codex-rs/core/src/connectors.rs | 3 --- codex-rs/core/src/mcp_tool_call_tests.rs | 1 - codex-rs/core/src/session/mcp.rs | 3 --- codex-rs/core/src/session/mod.rs | 1 - codex-rs/core/src/session/session.rs | 4 ---- 8 files changed, 1 insertion(+), 22 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index ea55d21379..749c13acd7 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -109,7 +109,6 @@ pub struct McpConnectionManager { server_metadata: HashMap, required_servers: Vec, tool_plugin_provenance: Arc, - host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, elicitation_requests: ElicitationRequestManager, startup_cancellation_token: CancellationToken, @@ -130,7 +129,6 @@ impl McpConnectionManager { runtime_context: McpRuntimeContext, codex_home: PathBuf, codex_apps_tools_cache_key: CodexAppsToolsCacheKey, - host_owned_codex_apps_enabled: bool, prefix_mcp_tool_names: bool, client_elicitation_capability: ElicitationCapability, supports_openai_form_elicitation: bool, @@ -252,7 +250,6 @@ impl McpConnectionManager { server_metadata, required_servers, tool_plugin_provenance, - host_owned_codex_apps_enabled, prefix_mcp_tool_names, elicitation_requests: elicitation_requests.clone(), startup_cancellation_token: startup_cancellation_token.clone(), @@ -338,7 +335,6 @@ impl McpConnectionManager { server_metadata: HashMap::new(), required_servers: Vec::new(), tool_plugin_provenance: Arc::new(ToolPluginProvenance::default()), - host_owned_codex_apps_enabled: false, prefix_mcp_tool_names, elicitation_requests: ElicitationRequestManager::new( approval_policy.value(), @@ -406,7 +402,7 @@ impl McpConnectionManager { } pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { - self.host_owned_codex_apps_enabled && server_name == CODEX_APPS_MCP_SERVER_NAME + server_name == CODEX_APPS_MCP_SERVER_NAME } pub fn set_approval_policy(&self, approval_policy: &Constrained) { diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 52194b07f1..b2b3b98be0 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1276,7 +1276,6 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { chatgpt_user_id: None, is_workspace_account: false, }, - /*host_owned_codex_apps_enabled*/ false, /*prefix_mcp_tool_names*/ true, ElicitationCapability::default(), /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 4076880c1d..4a765e6a62 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -277,7 +277,6 @@ pub async fn read_mcp_resource( uri: &str, ) -> anyhow::Result { let mut mcp_servers = effective_mcp_servers(config, auth); - let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); mcp_servers.retain(|name, _| name == server); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), @@ -302,7 +301,6 @@ pub async fn read_mcp_resource( runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), - host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), /*supports_openai_form_elicitation*/ false, @@ -337,7 +335,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( detail: McpSnapshotDetail, ) -> McpServerStatusSnapshot { let mcp_servers = effective_mcp_servers(config, auth); - let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(config, auth); let tool_plugin_provenance = tool_plugin_provenance(config); if mcp_servers.is_empty() { return McpServerStatusSnapshot { @@ -377,7 +374,6 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( runtime_context, config.codex_home.clone(), codex_apps_tools_cache_key(auth), - host_owned_codex_apps_enabled, config.prefix_mcp_tool_names, config.client_elicitation_capability.clone(), /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 86e5f07f9a..87c3097162 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -40,7 +40,6 @@ use codex_mcp::ToolPluginProvenance; use codex_mcp::codex_apps_tools_cache_key; use codex_mcp::compute_auth_statuses; use codex_mcp::effective_mcp_servers; -use codex_mcp::host_owned_codex_apps_enabled; use codex_mcp::tool_plugin_provenance; const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30); @@ -240,7 +239,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( let mut mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); mcp_servers.retain(|name, _| name == CODEX_APPS_MCP_SERVER_NAME); - let host_owned_codex_apps_enabled = host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); if mcp_servers.is_empty() { return Ok(AccessibleConnectorsStatus { connectors: Vec::new(), @@ -275,7 +273,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( McpRuntimeContext::new(environment_manager, config.cwd.to_path_buf()), config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), - host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index eb500b0f6c..2a951bfe55 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1354,7 +1354,6 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: ), turn_context.config.codex_home.to_path_buf(), codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), - /*host_owned_codex_apps_enabled*/ true, turn_context.config.prefix_mcp_tool_names(), rmcp::model::ElicitationCapability::default(), /*supports_openai_form_elicitation*/ false, diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index d03576aadc..42c274fdb4 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -317,8 +317,6 @@ impl Session { let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config); let mcp_servers = effective_mcp_servers_from_configured(mcp_servers, &mcp_config, auth.as_ref()); - let host_owned_codex_apps_enabled = - host_owned_codex_apps_enabled(&mcp_config, auth.as_ref()); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), store_mode, @@ -359,7 +357,6 @@ impl Session { mcp_runtime_context, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth.as_ref()), - host_owned_codex_apps_enabled, mcp_config.prefix_mcp_tool_names, mcp_config.client_elicitation_capability, self.services diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cdf866c391..a199d5b2e9 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -345,7 +345,6 @@ use codex_git_utils::get_git_repo_root; use codex_mcp::McpConfig; use codex_mcp::compute_auth_statuses; use codex_mcp::effective_mcp_servers_from_configured; -use codex_mcp::host_owned_codex_apps_enabled; use codex_otel::SessionTelemetry; use codex_otel::THREAD_STARTED_METRIC; use codex_otel::TelemetryAuthMode; diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index b4740bea99..69f1959cce 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1138,9 +1138,6 @@ impl Session { sess.send_event_raw(event).await; } - let host_owned_codex_apps_enabled = config - .features - .apps_enabled_for_auth(auth.as_ref().is_some_and(|auth| auth.uses_codex_backend())); let client_elicitation_capability = if config.features.enabled(Feature::AuthElicitation) { ElicitationCapability { form: Some(FormElicitationCapability::default()), @@ -1183,7 +1180,6 @@ impl Session { mcp_runtime_context, config.codex_home.to_path_buf(), codex_apps_tools_cache_key(auth), - host_owned_codex_apps_enabled, config.prefix_mcp_tool_names(), client_elicitation_capability, sess.services From 33cc928d339307795d4f5987337c7c4607f70338 Mon Sep 17 00:00:00 2001 From: rka-oai Date: Mon, 22 Jun 2026 23:56:16 -0700 Subject: [PATCH 74/86] [codex] Use input items for Responses Lite tools (#27946) When using Responses Lite, we should all use `additional_tools` and a developer item instead of the top level tools array & instructions field. This keeps things 1-to-1. Forced namespacing for _all_ tools will land in a following PR after some coordination & fixes in Responses API (around collisions & return items). The goal is to eventually expand the scope of this to _all_ requests from codex, but that will require larger coordination across providers & slower rollout. --- codex-rs/codex-api/src/common.rs | 9 +- .../src/endpoint/responses_websocket.rs | 4 +- codex-rs/codex-api/tests/clients.rs | 6 +- codex-rs/core/src/agent/control/spawn.rs | 3 +- codex-rs/core/src/client.rs | 26 +++++- codex-rs/core/src/client_common.rs | 3 +- codex-rs/core/src/client_common_tests.rs | 8 +- codex-rs/core/src/compact_remote.rs | 3 +- codex-rs/core/src/context_manager/history.rs | 9 +- codex-rs/core/src/image_preparation.rs | 3 +- codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/tests.rs | 13 +++ codex-rs/core/src/session/turn.rs | 3 +- codex-rs/core/src/turn_timing.rs | 3 +- codex-rs/core/tests/suite/agent_websocket.rs | 81 ++++++++++++++++++ codex-rs/core/tests/suite/responses_lite.rs | 82 ++++++++++++++++--- codex-rs/ext/image-generation/src/tool.rs | 6 +- codex-rs/otel/src/events/session_telemetry.rs | 1 + codex-rs/protocol/src/models.rs | 26 +++++- .../src/reducer/conversation/normalize.rs | 3 + .../src/reducer/conversation_tests.rs | 42 ++++++++++ codex-rs/rollout/src/policy.rs | 8 +- 22 files changed, 298 insertions(+), 45 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index ad37f6d5a4..bd037deb58 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -28,7 +28,8 @@ pub struct CompactionInput<'a> { pub input: &'a [ResponseItem], #[serde(skip_serializing_if = "str::is_empty")] pub instructions: &'a str, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub parallel_tool_calls: bool, #[serde(skip_serializing_if = "Option::is_none")] pub reasoning: Option, @@ -210,7 +211,8 @@ pub struct ResponsesApiRequest { #[serde(skip_serializing_if = "String::is_empty")] pub instructions: String, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, @@ -258,7 +260,8 @@ pub struct ResponseCreateWsRequest { #[serde(skip_serializing_if = "Option::is_none")] pub previous_response_id: Option, pub input: Vec, - pub tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub tools: Option>, pub tool_choice: String, pub parallel_tool_calls: bool, pub reasoning: Option, diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index ea7565ef57..755228a9f5 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -838,11 +838,11 @@ mod tests { phase: None, internal_chat_message_metadata_passthrough: None, }], - tools: vec![json!({ + tools: Some(vec![json!({ "type": "function", "name": "lookup", "parameters": {"type": "object"} - })], + })]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 2d69f83860..60a9852cfa 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -315,7 +315,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { phase: None, internal_chat_message_metadata_passthrough: None, }], - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, @@ -401,7 +401,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { model: "gpt-test".into(), instructions: "Say hi".into(), input: Vec::new(), - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, @@ -520,7 +520,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { phase: None, internal_chat_message_metadata_passthrough: None, }], - tools: Vec::new(), + tools: Some(Vec::new()), tool_choice: "auto".into(), parallel_tool_calls: false, reasoning: None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 05fac6aaf3..37a3a8105e 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -41,7 +41,8 @@ fn keep_forked_rollout_item(item: &RolloutItem, preserve_reference_context_item: _ => false, }, RolloutItem::ResponseItem( - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index a01cd7826f..9e240e968d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -73,6 +73,7 @@ use codex_otel::current_span_w3c_trace_context; use codex_protocol::ThreadId; 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 codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -790,7 +791,6 @@ impl ModelClient { service_tier: Option, responses_metadata: &CodexResponsesMetadata, ) -> Result { - let instructions = &prompt.base_instructions.text; let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); if !self.state.provider.info().is_openai() { input @@ -798,6 +798,28 @@ impl ModelClient { .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); } let tools = create_tools_json_for_responses_api(&prompt.tools)?; + let (instructions, tools) = if model_info.use_responses_lite { + let mut prefix = vec![ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools, + }]; + if !prompt.base_instructions.text.is_empty() { + prefix.push(ResponseItem::Message { + id: None, + role: "developer".to_string(), + content: vec![ContentItem::InputText { + text: prompt.base_instructions.text.clone(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }); + } + input.splice(0..0, prefix); + (String::new(), None) + } else { + (prompt.base_instructions.text.clone(), Some(tools)) + }; let reasoning = Self::build_reasoning(model_info, effort, summary); let include = if reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] @@ -824,7 +846,7 @@ impl ModelClient { let service_tier = model_info.service_tier_for_request(service_tier); let request = ResponsesApiRequest { model: model_info.slug.clone(), - instructions: instructions.clone(), + instructions, input, tools, tool_choice: "auto".to_string(), diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index e0c2ff03c3..54ad70daaa 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -83,7 +83,8 @@ fn strip_image_details(items: &mut [ResponseItem]) { } } } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 8b25c7e300..38c4aaecb5 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -110,7 +110,7 @@ fn serializes_text_verbosity_when_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -157,7 +157,7 @@ fn serializes_text_schema_with_strict_format() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -218,7 +218,7 @@ fn omits_text_when_not_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input, - tools, + tools: Some(tools), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, @@ -241,7 +241,7 @@ fn serializes_flex_service_tier_when_set() { model: "gpt-5.4".to_string(), instructions: "i".to_string(), input: vec![], - tools: vec![], + tools: Some(vec![]), tool_choice: "auto".to_string(), parallel_tool_calls: true, reasoning: None, diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index 3d23140684..48784141a8 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -352,7 +352,8 @@ pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool { ResponseItem::AgentMessage { .. } => true, ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 0244f7f96f..b474ed1a9a 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -386,7 +386,8 @@ impl ContextManager { output: truncate_function_output_payload(output, policy_with_serialization_budget), internal_chat_message_metadata_passthrough: metadata.clone(), }, - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -476,7 +477,8 @@ pub(crate) fn truncate_function_output_payload( fn is_api_message(message: &ResponseItem) -> bool { match message { ResponseItem::Message { role, .. } => role.as_str() != "system", - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::FunctionCallOutput { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } @@ -722,7 +724,8 @@ fn is_model_generated_item(item: &ResponseItem) -> bool { | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::AgentMessage { .. } diff --git a/codex-rs/core/src/image_preparation.rs b/codex-rs/core/src/image_preparation.rs index 87ac444df5..111ce9b955 100644 --- a/codex-rs/core/src/image_preparation.rs +++ b/codex-rs/core/src/image_preparation.rs @@ -57,7 +57,8 @@ pub(crate) fn prepare_response_items(items: &mut [ResponseItem]) { prepare_tool_output_content(content); } } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index a199d5b2e9..95db348583 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2715,6 +2715,7 @@ impl Session { continue; } let prefix = match item { + ResponseItem::AdditionalTools { .. } => "at", ResponseItem::Message { .. } => "msg", ResponseItem::Reasoning { .. } => "rs", ResponseItem::LocalShellCall { .. } => "lsh", diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 378dff58c9..52bde2cc4a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -222,6 +222,19 @@ fn assign_missing_response_item_ids_skips_agent_messages() { assert!(items[1].id().is_some_and(|id| id.starts_with("msg_"))); } +#[test] +fn assign_missing_response_item_ids_assigns_additional_tools_ids() { + let items = Cow::Owned(vec![ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: Vec::new(), + }]); + + let items = Session::assign_missing_response_item_ids(items); + + assert!(items[0].id().is_some_and(|id| id.starts_with("at_"))); +} + fn assistant_message(text: &str) -> ResponseItem { ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index cdb8468e55..84442a6caf 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2091,7 +2091,8 @@ async fn try_run_sampling_request( } ResponseItem::Reasoning { .. } => true, ResponseItem::AgentMessage { .. } => false, - ResponseItem::LocalShellCall { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } | ResponseItem::ToolSearchCall { .. } | ResponseItem::FunctionCallOutput { .. } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index e7a90800b0..04f7aeb69b 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -380,7 +380,8 @@ fn response_item_records_turn_ttft(item: &ResponseItem) -> bool { | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::FunctionCallOutput { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::FunctionCallOutput { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::ToolSearchOutput { .. } | ResponseItem::Other => false, diff --git a/codex-rs/core/tests/suite/agent_websocket.rs b/codex-rs/core/tests/suite/agent_websocket.rs index f3e1047784..9e75e36e99 100644 --- a/codex-rs/core/tests/suite/agent_websocket.rs +++ b/codex-rs/core/tests/suite/agent_websocket.rs @@ -1,6 +1,10 @@ use anyhow::Result; use codex_features::Feature; use codex_protocol::config_types::ServiceTier; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::protocol::ThreadSettingsOverrides; +use codex_protocol::user_input::UserInput; use core_test_support::responses::WebSocketConnectionConfig; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -10,12 +14,89 @@ use core_test_support::responses::start_websocket_server; use core_test_support::responses::start_websocket_server_with_headers; use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; use std::time::Duration; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn websocket_model_switch_to_responses_lite_omits_top_level_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_websocket_server(vec![vec![ + vec![ev_response_created("warm-1"), ev_completed("warm-1")], + vec![ev_response_created("resp-1"), ev_completed("resp-1")], + vec![ev_response_created("resp-2"), ev_completed("resp-2")], + ]]) + .await; + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + }) + .with_model("gpt-5.3-codex"); + let test = builder.build_with_websocket_server(&server).await?; + + test.submit_turn("non-lite turn").await?; + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "lite turn".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: ThreadSettingsOverrides { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(server.handshakes().len(), 1); + let connection = server.single_connection(); + assert_eq!(connection.len(), 3); + let non_lite_turn = connection + .get(1) + .expect("missing non-lite turn request") + .body_json(); + let lite_turn = connection + .get(2) + .expect("missing lite turn request") + .body_json(); + + assert_eq!(non_lite_turn["model"].as_str(), Some("gpt-5.3-codex")); + assert_eq!(lite_turn["model"].as_str(), Some("gpt-5.4")); + assert!( + non_lite_turn + .get("tools") + .and_then(Value::as_array) + .is_some_and(|tools| !tools.is_empty()) + ); + assert_eq!(lite_turn.get("previous_response_id"), None); + assert_eq!(lite_turn.get("tools"), None); + assert_eq!(lite_turn.get("instructions"), None); + let additional_tools = lite_turn + .get("input") + .and_then(Value::as_array) + .and_then(|input| input.first()) + .filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .and_then(|item| item.get("tools")) + .and_then(Value::as_array) + .expect("lite turn should start with an additional_tools item"); + assert!(!additional_tools.is_empty()); + + server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn websocket_test_codex_shell_chain() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/core/tests/suite/responses_lite.rs b/codex-rs/core/tests/suite/responses_lite.rs index 8e633522f4..4d2a38f6f8 100644 --- a/codex-rs/core/tests/suite/responses_lite.rs +++ b/codex-rs/core/tests/suite/responses_lite.rs @@ -54,6 +54,70 @@ fn has_hosted_tool(tools: &[Value], tool_type: &str) -> bool { .any(|tool| tool.get("type").and_then(Value::as_str) == Some(tool_type)) } +fn additional_tools(body: &Value) -> Result<&[Value]> { + body["input"] + .as_array() + .context("Responses request input should be an array")? + .first() + .filter(|item| item.get("type").and_then(Value::as_str) == Some("additional_tools")) + .context("Responses request should start with additional_tools")?["tools"] + .as_array() + .map(Vec::as_slice) + .context("additional_tools tools should be an array") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn responses_lite_uses_input_items_for_instructions_and_tools() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_once( + &server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_model_info_override("gpt-5.4", |model_info| { + model_info.use_responses_lite = true; + }) + .with_config(|config| { + config.base_instructions = Some("test instructions".to_string()); + }); + let test = builder.build(&server).await?; + + test.submit_turn("hello").await?; + + let body = response_mock.single_request().body_json(); + assert!(body.get("instructions").is_none()); + assert!(body.get("tools").is_none()); + + let input = body["input"] + .as_array() + .context("Responses request input should be an array")?; + assert_eq!(input[0]["type"], "additional_tools"); + assert_eq!(input[0]["role"], "developer"); + assert_eq!( + input[1], + serde_json::json!({ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "test instructions", + }], + }) + ); + + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn responses_lite_prepares_images() -> Result<()> { skip_if_no_network!(Ok(())); @@ -158,17 +222,10 @@ async fn responses_lite_uses_standalone_web_search_and_image_generation() -> Res request.header(RESPONSES_LITE_HEADER).as_deref(), Some("true") ); - request - .tool_by_name("web", "run") - .context("Responses Lite should expose standalone web search")?; - request - .tool_by_name("image_gen", "imagegen") - .context("Responses Lite should expose standalone image generation")?; - let body = request.body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; + assert!(!tools.is_empty()); assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); @@ -256,9 +313,8 @@ async fn responses_lite_omits_hosted_tools_without_standalone_extensions() -> Re test.submit_turn("Do not use hosted tools").await?; let body = response_mock.single_request().body_json(); - let tools = body["tools"] - .as_array() - .context("Responses request tools should be an array")?; + assert!(body.get("tools").is_none()); + let tools = additional_tools(&body)?; assert!(!has_hosted_tool(tools, "web_search")); assert!(!has_hosted_tool(tools, "image_generation")); diff --git a/codex-rs/ext/image-generation/src/tool.rs b/codex-rs/ext/image-generation/src/tool.rs index 3e2cfd1792..d4451a8926 100644 --- a/codex-rs/ext/image-generation/src/tool.rs +++ b/codex-rs/ext/image-generation/src/tool.rs @@ -256,7 +256,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::CustomToolCall { call_id, .. } => { custom_tool_call_ids.insert(call_id.as_str()); } - ResponseItem::Message { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Message { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::LocalShellCall { .. } @@ -296,7 +297,8 @@ fn recent_images(history: &[ResponseItem], count: usize) -> Vec { ResponseItem::ImageGenerationCall { result, .. } if !result.is_empty() => { image_urls.push(format!("data:image/png;base64,{result}")); } - ResponseItem::Reasoning { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::Reasoning { .. } | ResponseItem::AgentMessage { .. } | ResponseItem::LocalShellCall { .. } | ResponseItem::FunctionCall { .. } diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 02bd39714a..7e2700236b 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1198,6 +1198,7 @@ impl SessionTelemetry { fn responses_item_type(item: &ResponseItem) -> String { match item { + ResponseItem::AdditionalTools { .. } => "additional_tools".into(), ResponseItem::Message { role, .. } => format!("message_from_{role}"), ResponseItem::AgentMessage { .. } => "agent_message".into(), ResponseItem::Reasoning { .. } => "reasoning".into(), diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index e107013a78..2faad87349 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -932,6 +932,14 @@ impl InternalChatMessageMetadataPassthrough { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ResponseItem { + #[schemars(skip)] + #[ts(skip)] + AdditionalTools { + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + role: String, + tools: Vec, + }, Message { #[serde(default, skip_serializing_if = "Option::is_none")] #[ts(optional)] @@ -1160,7 +1168,8 @@ impl ResponseItem { /// Returns the non-empty Responses API item ID, if present. pub fn id(&self) -> Option<&str> { match self { - Self::Message { id, .. } + Self::AdditionalTools { id, .. } + | Self::Message { id, .. } | Self::AgentMessage { id, .. } | Self::LocalShellCall { id, .. } | Self::FunctionCall { id, .. } @@ -1181,7 +1190,8 @@ impl ResponseItem { /// Sets or clears the Responses API item ID for variants that carry one. pub fn set_id(&mut self, new_id: Option) { match self { - Self::Message { id, .. } + Self::AdditionalTools { id, .. } + | Self::Message { id, .. } | Self::AgentMessage { id, .. } | Self::LocalShellCall { id, .. } | Self::FunctionCall { id, .. } @@ -1282,7 +1292,7 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } => metadata.as_ref(), - Self::CompactionTrigger { .. } | Self::Other => None, + Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None, } } @@ -1346,7 +1356,7 @@ impl ResponseItem { internal_chat_message_metadata_passthrough: metadata, .. } => Some(metadata), - Self::CompactionTrigger { .. } | Self::Other => None, + Self::CompactionTrigger { .. } | Self::AdditionalTools { .. } | Self::Other => None, } } } @@ -2237,6 +2247,14 @@ mod tests { item.set_id(/*new_id*/ None); assert_eq!(item.id(), None); + + let mut additional_tools = ResponseItem::AdditionalTools { + id: None, + role: "developer".to_string(), + tools: Vec::new(), + }; + additional_tools.set_id(Some("at_test".to_string())); + assert_eq!(additional_tools.id(), Some("at_test")); } fn response_item_with_passthrough_metadata( diff --git a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs index e7a3253f6f..f97cc65f67 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation/normalize.rs @@ -37,6 +37,9 @@ pub(super) fn normalize_model_items( ) -> Result> { let mut normalized_items = Vec::new(); for item in items { + if item.get("type").and_then(Value::as_str) == Some("additional_tools") { + continue; + } normalized_items.push(normalize_model_item(item, raw_payload)?); } Ok(normalized_items) diff --git a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs index 1cf13b1fa8..18d38a4170 100644 --- a/codex-rs/rollout-trace/src/reducer/conversation_tests.rs +++ b/codex-rs/rollout-trace/src/reducer/conversation_tests.rs @@ -735,6 +735,48 @@ fn unsupported_model_item_is_reducer_error() -> anyhow::Result<()> { ) } +#[test] +fn additional_tools_are_excluded_from_request_conversation() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let writer = create_started_writer(&temp)?; + start_turn(&writer, "turn-1")?; + + let request = writer.write_json_payload( + RawPayloadKind::InferenceRequest, + &json!({ + "input": [ + { + "type": "additional_tools", + "role": "developer", + "tools": [{ + "type": "function", + "name": "lookup", + "parameters": {"type": "object", "properties": {}} + }] + }, + message("user", "find it") + ] + }), + )?; + append_inference_start(&writer, "inference-1", "turn-1", request)?; + + let rollout = replay_bundle(temp.path())?; + let request_item_ids = &rollout.inference_calls["inference-1"].request_item_ids; + + assert_eq!(request_item_ids.len(), 1); + assert_eq!(rollout.conversation_items.len(), 1); + assert_eq!( + rollout.conversation_items[&request_item_ids[0]].body, + ConversationBody { + parts: vec![ConversationPart::Text { + text: "find it".to_string(), + }], + } + ); + + Ok(()) +} + #[test] fn missing_request_input_is_reducer_error() -> anyhow::Result<()> { let temp = TempDir::new()?; diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index b342d699b7..4db26576b0 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -44,8 +44,9 @@ pub fn should_persist_response_item(item: &ResponseItem) -> bool { | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true, - ResponseItem::CompactionTrigger { .. } => false, - ResponseItem::Other => false, + ResponseItem::AdditionalTools { .. } + | ResponseItem::CompactionTrigger { .. } + | ResponseItem::Other => false, } } @@ -62,7 +63,8 @@ pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { | ResponseItem::CustomToolCall { .. } | ResponseItem::CustomToolCallOutput { .. } | ResponseItem::WebSearchCall { .. } => true, - ResponseItem::AgentMessage { .. } + ResponseItem::AdditionalTools { .. } + | ResponseItem::AgentMessage { .. } | ResponseItem::Reasoning { .. } | ResponseItem::ImageGenerationCall { .. } | ResponseItem::Compaction { .. } From f0ad028a740f1fe6295cec38a0b5cfd0a909683a Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 23 Jun 2026 00:00:25 -0700 Subject: [PATCH 75/86] Centralize Codex Apps client handling (#29528) ## Why Codex Apps-specific behavior is currently distributed across cache helpers, startup, tool conversion, and model-visible annotation. Each layer independently checks the reserved server name, which obscures the boundary between trusted host-owned connector metadata and regular MCP server data. Classifying the server once when `AsyncManagedClient` is created gives the client a single source of truth and makes the two processing paths explicit. ## What changed - Record whether an `AsyncManagedClient` represents the Codex Apps server at construction time. - Route startup cache loading, cache persistence, and cache telemetry through the Codex Apps branch. - Split uncached tool conversion between Codex Apps normalization and regular MCP metadata sanitization. - Split model-visible schema and plugin provenance handling along the same boundary. - Remove redundant server-name guards from helpers that are now called only from the Codex Apps branch. ## Verification - Preserve behavioral coverage that verifies Codex Apps connector metadata and the complete converted `ToolInfo` shape. ## Stack Depends on #29518. --- codex-rs/codex-mcp/src/codex_apps.rs | 29 +- codex-rs/codex-mcp/src/connection_manager.rs | 1 + .../codex-mcp/src/connection_manager_tests.rs | 46 +- codex-rs/codex-mcp/src/rmcp_client.rs | 392 ++++++++++-------- codex-rs/codex-mcp/src/tools.rs | 30 +- codex-rs/core/src/mcp_openai_file.rs | 4 +- 6 files changed, 262 insertions(+), 240 deletions(-) diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index b22a45f0d1..53b546b8a0 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -66,15 +66,7 @@ pub(crate) enum CachedCodexAppsToolsLoad { Invalid, } -pub(crate) fn normalize_codex_apps_tool_title( - server_name: &str, - connector_name: Option<&str>, - value: &str, -) -> String { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return value.to_string(); - } - +pub(crate) fn normalize_codex_apps_tool_title(connector_name: Option<&str>, value: &str) -> String { let Some(connector_name) = connector_name .map(str::trim) .filter(|name| !name.is_empty()) @@ -93,15 +85,10 @@ pub(crate) fn normalize_codex_apps_tool_title( } pub(crate) fn normalize_codex_apps_callable_name( - server_name: &str, tool_name: &str, connector_id: Option<&str>, connector_name: Option<&str>, ) -> String { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return tool_name.to_string(); - } - let tool_name = sanitize_name(tool_name); if let Some(connector_name) = connector_name @@ -131,9 +118,7 @@ pub(crate) fn normalize_codex_apps_callable_namespace( server_name: &str, connector_name: Option<&str>, ) -> String { - if server_name == CODEX_APPS_MCP_SERVER_NAME - && let Some(connector_name) = connector_name - { + if let Some(connector_name) = connector_name { format!("{}__{}", server_name, sanitize_name(connector_name)) } else { server_name.to_string() @@ -165,13 +150,8 @@ pub(crate) fn write_cached_codex_apps_tools_if_needed( } pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( - server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, ) -> Option> { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return None; - } - let cache_context = cache_context?; match load_cached_codex_apps_tools(cache_context) { @@ -181,13 +161,8 @@ pub(crate) fn load_startup_cached_codex_apps_tools_snapshot( } pub(crate) fn load_startup_cached_codex_apps_server_info( - server_name: &str, cache_context: Option<&CodexAppsToolsCacheContext>, ) -> Option { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return None; - } - load_cached_codex_apps_server_info(cache_context?) } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 749c13acd7..a881e865d2 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -506,6 +506,7 @@ impl McpConnectionManager { let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached( CODEX_APPS_MCP_SERVER_NAME, + /*is_codex_apps_mcp_server*/ true, &managed_client.client, managed_client.tool_timeout, managed_client.server_instructions.as_deref(), diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index b2b3b98be0..05b3917027 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -711,15 +711,9 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { &cached_tools, ); - let startup_tools = load_startup_cached_codex_apps_tools_snapshot( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ) - .expect("expected startup snapshot to load from cache"); - let cached_server_info = load_startup_cached_codex_apps_server_info( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ); + let startup_tools = load_startup_cached_codex_apps_tools_snapshot(Some(&cache_context)) + .expect("expected startup snapshot to load from cache"); + let cached_server_info = load_startup_cached_codex_apps_server_info(Some(&cache_context)); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].server_name, CODEX_APPS_MCP_SERVER_NAME); @@ -746,15 +740,9 @@ fn startup_cached_codex_apps_tools_loads_without_server_info_cache() { .expect("serialize"); std::fs::write(cache_path, bytes).expect("write"); - let startup_tools = load_startup_cached_codex_apps_tools_snapshot( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ) - .expect("legacy startup snapshot should remain available"); - let cached_server_info = load_startup_cached_codex_apps_server_info( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ); + let startup_tools = load_startup_cached_codex_apps_tools_snapshot(Some(&cache_context)) + .expect("legacy startup snapshot should remain available"); + let cached_server_info = load_startup_cached_codex_apps_server_info(Some(&cache_context)); assert_eq!(startup_tools.len(), 1); assert_eq!(startup_tools[0].callable_name, "calendar_search"); @@ -792,19 +780,10 @@ fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { std::fs::write(cache_path, bytes).expect("write legacy tools cache"); assert_eq!( - load_startup_cached_codex_apps_server_info( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ), + load_startup_cached_codex_apps_server_info(Some(&cache_context)), Some(server_info) ); - assert!( - load_startup_cached_codex_apps_tools_snapshot( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - ) - .is_none() - ); + assert!(load_startup_cached_codex_apps_tools_snapshot(Some(&cache_context)).is_none()); } #[tokio::test] @@ -827,6 +806,7 @@ async fn list_all_tools_uses_cached_tool_info_snapshot_while_client_is_pending() CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -864,6 +844,7 @@ async fn list_available_server_infos_uses_cache_while_client_is_pending() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(Vec::new()), cached_server_info: Some(server_info.clone()), startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -901,6 +882,7 @@ async fn list_all_tools_accepts_canonical_namespaced_tool_names() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: false, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -944,6 +926,7 @@ async fn list_all_tools_applies_legacy_mcp_prefix_by_default() { "rmcp".to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: false, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -986,6 +969,7 @@ async fn list_all_tools_blocks_while_client_is_pending_without_cached_tool_info_ CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: None, cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1022,6 +1006,7 @@ async fn shutdown_cancels_pending_tool_listing() { CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: None, cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1057,6 +1042,7 @@ async fn list_all_tools_does_not_block_when_cached_tool_info_snapshot_is_empty() CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(Vec::new()), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -1097,6 +1083,7 @@ async fn list_all_tools_uses_cached_tool_info_snapshot_when_client_startup_fails CODEX_APPS_MCP_SERVER_NAME.to_string(), AsyncManagedClient { client: failed_client, + is_codex_apps_mcp_server: true, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: Some(server_info.clone()), startup_complete, @@ -1155,6 +1142,7 @@ async fn list_all_tools_adds_server_metadata_to_cached_tools() { server_name.to_string(), AsyncManagedClient { client: pending_client, + is_codex_apps_mcp_server: false, cached_tool_info_snapshot: Some(startup_tools), cached_server_info: None, startup_complete: Arc::new(std::sync::atomic::AtomicBool::new(false)), diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 6c8b9a2415..f8173b6003 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -55,6 +55,7 @@ use codex_rmcp_client::ExecutorStdioServerLauncher; use codex_rmcp_client::LocalStdioServerLauncher; use codex_rmcp_client::RmcpClient; use codex_rmcp_client::StdioServerLauncher; +use codex_rmcp_client::ToolWithConnectorId; use futures::future::BoxFuture; use futures::future::FutureExt; use futures::future::Shared; @@ -129,6 +130,7 @@ impl ManagedClient { #[derive(Clone)] pub(crate) struct AsyncManagedClient { pub(crate) client: Shared>>, + pub(crate) is_codex_apps_mcp_server: bool, pub(crate) cached_tool_info_snapshot: Option>, pub(crate) cached_server_info: Option, pub(crate) startup_complete: Arc, @@ -155,20 +157,23 @@ impl AsyncManagedClient { client_elicitation_capability: ElicitationCapability, supports_openai_form_elicitation: bool, ) -> Self { + let is_codex_apps_mcp_server = server_name == CODEX_APPS_MCP_SERVER_NAME; let tool_filter = server .configured_config() .map(ToolFilter::from_config) .unwrap_or_default(); - let cached_tool_info_snapshot = load_startup_cached_codex_apps_tools_snapshot( - &server_name, - codex_apps_tools_cache_context.as_ref(), - ); + let (cached_tool_info_snapshot, cached_server_info) = if is_codex_apps_mcp_server { + ( + load_startup_cached_codex_apps_tools_snapshot( + codex_apps_tools_cache_context.as_ref(), + ), + load_startup_cached_codex_apps_server_info(codex_apps_tools_cache_context.as_ref()), + ) + } else { + (None, None) + }; let cached_tool_info_snapshot = cached_tool_info_snapshot.map(|tools| filter_tools(tools, &tool_filter)); - let cached_server_info = load_startup_cached_codex_apps_server_info( - &server_name, - codex_apps_tools_cache_context.as_ref(), - ); let startup_tool_filter = tool_filter; let startup_complete = Arc::new(AtomicBool::new(false)); let startup_complete_for_fut = Arc::clone(&startup_complete); @@ -194,6 +199,7 @@ impl AsyncManagedClient { server_name, client, StartServerTaskParams { + is_codex_apps_mcp_server, startup_timeout: server .configured_config() .and_then(|config| config.startup_timeout_sec) @@ -232,6 +238,7 @@ impl AsyncManagedClient { Self { client, + is_codex_apps_mcp_server, cached_tool_info_snapshot, cached_server_info, startup_complete, @@ -255,65 +262,7 @@ impl AsyncManagedClient { } } - fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { - if !self.startup_complete.load(Ordering::Acquire) { - return self.cached_tool_info_snapshot.clone(); - } - None - } - pub(crate) async fn listed_tools(&self) -> Option> { - let annotate_tools = |tools: Vec| { - let mut tools = tools; - for tool in &mut tools { - if tool.server_name == CODEX_APPS_MCP_SERVER_NAME { - tool.tool = tool_with_model_visible_input_schema(&tool.tool); - } - - let plugin_names = match tool.connector_id.as_deref() { - Some(connector_id) => self - .tool_plugin_provenance - .plugin_display_names_for_connector_id(connector_id), - None => self - .tool_plugin_provenance - .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), - }; - tool.plugin_display_names = plugin_names.to_vec(); - - if plugin_names.is_empty() { - continue; - } - - let plugin_source_note = if plugin_names.len() == 1 { - format!("This tool is part of plugin `{}`.", plugin_names[0]) - } else { - format!( - "This tool is part of plugins {}.", - plugin_names - .iter() - .map(|plugin_name| format!("`{plugin_name}`")) - .collect::>() - .join(", ") - ) - }; - let description = tool - .tool - .description - .as_deref() - .map(str::trim) - .unwrap_or(""); - let annotated_description = if description.is_empty() { - plugin_source_note - } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { - format!("{description} {plugin_source_note}") - } else { - format!("{description}. {plugin_source_note}") - }; - tool.tool.description = Some(Cow::Owned(annotated_description)); - } - tools - }; - // Keep cache payloads raw; plugin provenance is resolved per-session at read time. let tools = if let Some(startup_tools) = self.cached_tool_info_snapshot_while_initializing() { @@ -323,8 +272,19 @@ impl AsyncManagedClient { Ok(client) => Some(client.listed_tools()), Err(_) => self.cached_tool_info_snapshot.clone(), } - }; - tools.map(annotate_tools) + }?; + Some(if self.is_codex_apps_mcp_server { + prepare_codex_apps_tools_for_model(tools, &self.tool_plugin_provenance) + } else { + prepare_regular_mcp_tools_for_model(tools, &self.tool_plugin_provenance) + }) + } + + fn cached_tool_info_snapshot_while_initializing(&self) -> Option> { + if !self.startup_complete.load(Ordering::Acquire) { + return self.cached_tool_info_snapshot.clone(); + } + None } } @@ -348,6 +308,7 @@ impl From for StartupOutcomeError { pub(crate) async fn list_tools_for_client_uncached( server_name: &str, + is_codex_apps_mcp_server: bool, client: &Arc, timeout: Option, server_instructions: Option<&str>, @@ -359,68 +320,165 @@ pub(crate) async fn list_tools_for_client_uncached( .tools .into_iter() .map(|tool| { - let mut tool_def = tool.tool; - let (connector_id, connector_name, connector_description) = - sanitize_tool_connector_metadata( - server_name, - &mut tool_def, - tool.connector_id, - tool.connector_name, - tool.connector_description, - ); - let callable_name = normalize_codex_apps_callable_name( + tool_info_from_listed_tool( server_name, - &tool_def.name, - connector_id.as_deref(), - connector_name.as_deref(), - ); - let callable_namespace = - normalize_codex_apps_callable_namespace(server_name, connector_name.as_deref()); - if let Some(title) = tool_def.title.as_deref() { - let normalized_title = - normalize_codex_apps_tool_title(server_name, connector_name.as_deref(), title); - if tool_def.title.as_deref() != Some(normalized_title.as_str()) { - tool_def.title = Some(normalized_title); - } - } - let has_connector_metadata = connector_id.is_some() - || connector_name.is_some() - || connector_description.is_some(); - let namespace_description = if has_connector_metadata { - connector_description - } else { - server_instructions.map(str::to_string) - }; - ToolInfo { - server_name: server_name.to_owned(), - supports_parallel_tool_calls: false, - server_origin: None, - callable_name, - callable_namespace, - namespace_description, - tool: tool_def, - connector_id, - connector_name, - plugin_display_names: Vec::new(), - } + is_codex_apps_mcp_server, + server_instructions, + tool, + ) }) .collect(); Ok(tools) } -fn sanitize_tool_connector_metadata( - server_name: &str, - tool: &mut RmcpTool, - connector_id: Option, - connector_name: Option, - connector_description: Option, -) -> (Option, Option, Option) { - if server_name == CODEX_APPS_MCP_SERVER_NAME { - return (connector_id, connector_name, connector_description); +/// Presents declared Codex Apps file parameters to the model as local-path inputs and adds plugin +/// names to each tool. Plugin membership is resolved by connector ID, falling back to the MCP +/// server when absent. +fn prepare_codex_apps_tools_for_model( + mut tools: Vec, + tool_plugin_provenance: &ToolPluginProvenance, +) -> Vec { + for tool in &mut tools { + tool.tool = tool_with_model_visible_input_schema(&tool.tool); + let plugin_names = match tool.connector_id.as_deref() { + Some(connector_id) => { + tool_plugin_provenance.plugin_display_names_for_connector_id(connector_id) + } + None => tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()), + }; + add_plugin_provenance_to_tool(tool, plugin_names); + } + tools +} + +/// Stores plugin names on the tool and appends a model-visible plugin membership note. +fn add_plugin_provenance_to_tool(tool: &mut ToolInfo, plugin_names: &[String]) { + tool.plugin_display_names = plugin_names.to_vec(); + if plugin_names.is_empty() { + return; } - strip_untrusted_connector_meta(tool); - (None, None, None) + let plugin_source_note = if plugin_names.len() == 1 { + format!("This tool is part of plugin `{}`.", plugin_names[0]) + } else { + format!( + "This tool is part of plugins {}.", + plugin_names + .iter() + .map(|plugin_name| format!("`{plugin_name}`")) + .collect::>() + .join(", ") + ) + }; + let description = tool + .tool + .description + .as_deref() + .map(str::trim) + .unwrap_or(""); + let annotated_description = if description.is_empty() { + plugin_source_note + } else if matches!(description.chars().last(), Some('.' | '!' | '?')) { + format!("{description} {plugin_source_note}") + } else { + format!("{description}. {plugin_source_note}") + }; + tool.tool.description = Some(Cow::Owned(annotated_description)); +} + +/// Adds server-scoped plugin names to regular MCP tools without changing their input schemas. +fn prepare_regular_mcp_tools_for_model( + mut tools: Vec, + tool_plugin_provenance: &ToolPluginProvenance, +) -> Vec { + for tool in &mut tools { + let plugin_names = tool_plugin_provenance + .plugin_display_names_for_mcp_server_name(tool.server_name.as_str()); + add_plugin_provenance_to_tool(tool, plugin_names); + } + tools +} + +fn tool_info_from_listed_tool( + server_name: &str, + is_codex_apps_mcp_server: bool, + server_instructions: Option<&str>, + tool: ToolWithConnectorId, +) -> ToolInfo { + if is_codex_apps_mcp_server { + codex_apps_tool_info_from_listed_tool(server_name, server_instructions, tool) + } else { + regular_mcp_tool_info_from_listed_tool(server_name, server_instructions, tool) + } +} + +/// Converts a Codex Apps tool by preserving connector fields, removing connector prefixes from +/// model-visible names and titles, and using the connector description for its tool namespace. +fn codex_apps_tool_info_from_listed_tool( + server_name: &str, + server_instructions: Option<&str>, + tool: ToolWithConnectorId, +) -> ToolInfo { + let mut tool_def = tool.tool; + let connector_id = tool.connector_id; + let connector_name = tool.connector_name; + let connector_description = tool.connector_description; + let callable_name = normalize_codex_apps_callable_name( + &tool_def.name, + connector_id.as_deref(), + connector_name.as_deref(), + ); + let callable_namespace = + normalize_codex_apps_callable_namespace(server_name, connector_name.as_deref()); + if let Some(title) = tool_def.title.as_deref() { + let normalized_title = normalize_codex_apps_tool_title(connector_name.as_deref(), title); + if tool_def.title.as_deref() != Some(normalized_title.as_str()) { + tool_def.title = Some(normalized_title); + } + } + let has_connector_metadata = + connector_id.is_some() || connector_name.is_some() || connector_description.is_some(); + let namespace_description = if has_connector_metadata { + connector_description + } else { + server_instructions.map(str::to_string) + }; + ToolInfo { + server_name: server_name.to_owned(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name, + callable_namespace, + namespace_description, + tool: tool_def, + connector_id, + connector_name, + plugin_display_names: Vec::new(), + } +} + +/// Converts a regular MCP tool by removing reserved connector metadata, keeping its raw tool name, +/// and using the MCP server name and instructions for the model-visible namespace. +fn regular_mcp_tool_info_from_listed_tool( + server_name: &str, + server_instructions: Option<&str>, + tool: ToolWithConnectorId, +) -> ToolInfo { + let mut tool_def = tool.tool; + strip_untrusted_connector_meta(&mut tool_def); + ToolInfo { + server_name: server_name.to_owned(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: tool_def.name.to_string(), + callable_namespace: server_name.to_string(), + namespace_description: server_instructions.map(str::to_string), + tool: tool_def, + connector_id: None, + connector_name: None, + plugin_display_names: Vec::new(), + } } fn strip_untrusted_connector_meta(tool: &mut RmcpTool) { @@ -477,6 +535,7 @@ async fn start_server_task( params: StartServerTaskParams, ) -> Result { let StartServerTaskParams { + is_codex_apps_mcp_server, startup_timeout, tool_timeout, tool_filter, @@ -508,6 +567,7 @@ async fn start_server_task( let fetch_start = Instant::now(); let tools = list_tools_for_client_uncached( &server_name, + is_codex_apps_mcp_server, &client, startup_timeout, initialize_result.instructions.as_deref(), @@ -520,19 +580,22 @@ async fn start_server_task( &[], ); let server_info = mcp_server_info_from_implementation(initialize_result.server_info); - write_cached_codex_apps_tools_if_needed( - &server_name, - codex_apps_tools_cache_context.as_ref(), - &server_info, - &tools, - ); - if server_name == CODEX_APPS_MCP_SERVER_NAME { + let codex_apps_tools_cache_context = if is_codex_apps_mcp_server { + write_cached_codex_apps_tools_if_needed( + &server_name, + codex_apps_tools_cache_context.as_ref(), + &server_info, + &tools, + ); emit_duration( MCP_TOOLS_LIST_DURATION_METRIC, list_start.elapsed(), &[("cache", "miss")], ); - } + codex_apps_tools_cache_context + } else { + None + }; let tools = filter_tools(tools, &tool_filter); let managed = ManagedClient { @@ -585,6 +648,7 @@ fn mcp_server_info_from_implementation(server_info: Implementation) -> McpServer } struct StartServerTaskParams { + is_codex_apps_mcp_server: bool, startup_timeout: Option, // TODO: cancel_token should handle this. tool_timeout: Duration, tool_filter: ToolFilter, @@ -685,6 +749,7 @@ async fn make_rmcp_client( #[cfg(test)] mod tests { use super::*; + use pretty_assertions::assert_eq; use rmcp::model::JsonObject; use rmcp::model::Meta; @@ -737,18 +802,7 @@ mod tests { fn custom_mcp_connector_metadata_is_stripped() { let mut tool = tool_with_connector_meta(); - let (connector_id, connector_name, connector_description) = - sanitize_tool_connector_metadata( - "minimaltest", - &mut tool, - Some("connector_gmail".to_string()), - Some("Gmail".to_string()), - Some("Mail connector".to_string()), - ); - - assert_eq!(connector_id, None); - assert_eq!(connector_name, None); - assert_eq!(connector_description, None); + strip_untrusted_connector_meta(&mut tool); let meta = tool.meta.as_ref().expect("meta"); for key in [ @@ -771,32 +825,36 @@ mod tests { #[test] fn codex_apps_connector_metadata_is_preserved() { - let mut tool = tool_with_connector_meta(); + let tool = tool_with_connector_meta(); + let expected_tool = tool.clone(); - let (connector_id, connector_name, connector_description) = - sanitize_tool_connector_metadata( - CODEX_APPS_MCP_SERVER_NAME, - &mut tool, - Some("connector_gmail".to_string()), - Some("Gmail".to_string()), - Some("Mail connector".to_string()), - ); + let tool_info = tool_info_from_listed_tool( + CODEX_APPS_MCP_SERVER_NAME, + /*is_codex_apps_mcp_server*/ true, + /*server_instructions*/ None, + ToolWithConnectorId { + tool, + connector_id: Some("connector_gmail".to_string()), + connector_name: Some("Gmail".to_string()), + connector_description: Some("Mail connector".to_string()), + }, + ); - assert_eq!(connector_id.as_deref(), Some("connector_gmail")); - assert_eq!(connector_name.as_deref(), Some("Gmail")); - assert_eq!(connector_description.as_deref(), Some("Mail connector")); - - let meta = tool.meta.as_ref().expect("meta"); - for key in [ - "connector_id", - "connector_name", - "connector_display_name", - "connector_description", - "connectorDescription", - "connectorFutureField", - "CONNECTOR_UPPERCASE", - ] { - assert!(meta.0.contains_key(key), "{key} should be preserved"); - } + let expected = ToolInfo { + server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(), + supports_parallel_tool_calls: false, + server_origin: None, + callable_name: "capture_file_upload".to_string(), + callable_namespace: "codex_apps__gmail".to_string(), + namespace_description: Some("Mail connector".to_string()), + tool: expected_tool, + connector_id: Some("connector_gmail".to_string()), + connector_name: Some("Gmail".to_string()), + plugin_display_names: Vec::new(), + }; + assert_eq!( + serde_json::to_value(tool_info).expect("serialize actual tool info"), + serde_json::to_value(expected).expect("serialize expected tool info") + ); } } diff --git a/codex-rs/codex-mcp/src/tools.rs b/codex-rs/codex-mcp/src/tools.rs index 83cbfd5e74..f63ab002df 100644 --- a/codex-rs/codex-mcp/src/tools.rs +++ b/codex-rs/codex-mcp/src/tools.rs @@ -113,9 +113,9 @@ impl ToolFilter { } } -/// Returns the model-visible view of a tool while preserving the raw metadata -/// used by execution. Keep cache entries raw and call this at manager return -/// boundaries. +/// Returns the model-visible view of a tool while preserving the raw metadata used by execution. +/// Declared file parameters are presented as local file paths; execution later uploads those files +/// and replaces the paths with the uploaded-file objects expected by the app. pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { let file_params = declared_openai_file_input_param_names(tool.meta.as_deref()); if file_params.is_empty() { @@ -124,7 +124,7 @@ pub(crate) fn tool_with_model_visible_input_schema(tool: &Tool) -> Tool { let mut tool = tool.clone(); let mut input_schema = JsonValue::Object(tool.input_schema.as_ref().clone()); - mask_input_schema_for_file_path_params(&mut input_schema, &file_params); + rewrite_input_schema_for_local_file_paths(&mut input_schema, &file_params); if let JsonValue::Object(input_schema) = input_schema { tool.input_schema = Arc::new(input_schema); } @@ -262,15 +262,7 @@ const MAX_TOOL_NAME_LENGTH: usize = 64; const CALLABLE_NAME_HASH_LEN: usize = 12; const META_OPENAI_FILE_PARAMS: &str = "openai/fileParams"; -fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String { - if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { - namespace.to_string() - } else { - format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{namespace}") - } -} - -fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_params: &[String]) { +fn rewrite_input_schema_for_local_file_paths(input_schema: &mut JsonValue, file_params: &[String]) { let Some(properties) = input_schema .as_object_mut() .and_then(|schema| schema.get_mut("properties")) @@ -283,11 +275,11 @@ fn mask_input_schema_for_file_path_params(input_schema: &mut JsonValue, file_par let Some(property_schema) = properties.get_mut(field_name) else { continue; }; - mask_input_property_schema(property_schema); + rewrite_input_property_schema_as_local_file_path(property_schema); } } -fn mask_input_property_schema(schema: &mut JsonValue) { +fn rewrite_input_property_schema_as_local_file_path(schema: &mut JsonValue) { let Some(object) = schema.as_object_mut() else { return; }; @@ -316,6 +308,14 @@ fn mask_input_property_schema(schema: &mut JsonValue) { } } +fn callable_namespace_with_prefix(namespace: &str, prefix_mcp_tool_names: bool) -> String { + if !prefix_mcp_tool_names || namespace.starts_with(LEGACY_MCP_TOOL_NAME_PREFIX) { + namespace.to_string() + } else { + format!("{LEGACY_MCP_TOOL_NAME_PREFIX}{namespace}") + } +} + fn sha1_hex(s: &str) -> String { let mut hasher = Sha1::new(); hasher.update(s.as_bytes()); diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 4dee0da717..5044568cd6 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -8,8 +8,8 @@ //! and rewrite only the declared arguments into the provided-file payload //! shape expected by the downstream Apps tool. //! -//! Model-visible schema masking is owned by `codex-mcp` alongside MCP tool -//! inventory, so this module only handles the execution-time argument rewrite. +//! The model-facing local-path schema is owned by `codex-mcp` alongside MCP tool inventory, so this +//! module only handles uploading the files and rewriting the execution-time arguments. use crate::session::session::Session; use crate::session::turn_context::TurnContext; From 6e0c8b45170eb295cfbba514a4e8aca00efb43aa Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 23 Jun 2026 00:20:23 -0700 Subject: [PATCH 76/86] Handle additional tools in image URL validation (#29577) ## Why `ResponseItem::AdditionalTools` was added without updating app-server image URL validation. The exhaustive match therefore prevents app-server and downstream targets from compiling on `main`. ## What changed Treat `AdditionalTools` like the other response items that cannot contain input-image URLs. --- codex-rs/app-server/src/request_processors/turn_processor.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index f7b4f7c14e..4b6e0fda3a 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -58,6 +58,7 @@ fn validate_response_item_image_urls(items: &[ResponseItem]) -> Result<(), JSONR | ResponseItem::Compaction { .. } | ResponseItem::CompactionTrigger { .. } | ResponseItem::ContextCompaction { .. } + | ResponseItem::AdditionalTools { .. } | ResponseItem::Other => false, }) { return Err(invalid_request(REMOTE_IMAGE_URL_ERROR)); From 83c4934e45d22dab962c55817fdbdc134e98dca6 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 23 Jun 2026 00:31:56 -0700 Subject: [PATCH 77/86] Remove redundant Codex Apps cache guard (#29575) ## Why Codex Apps cache writes are already restricted to Codex Apps call paths: startup invokes the helper only from the Codex Apps branch, and hard refresh operates on the reserved Codex Apps server directly. Rechecking the server name inside the cache helper duplicates that classification and leaves the helper with an argument that cannot change valid behavior. ## What changed - Remove the redundant server-name check and parameter from the cache writer. - Rename the helper to `write_codex_apps_tools_cache` to reflect its narrower contract. - Update production and test callsites to use the simplified API. --- codex-rs/codex-mcp/src/codex_apps.rs | 8 +------- codex-rs/codex-mcp/src/connection_manager.rs | 5 ++--- codex-rs/codex-mcp/src/connection_manager_tests.rs | 12 +++--------- codex-rs/codex-mcp/src/rmcp_client.rs | 5 ++--- 4 files changed, 8 insertions(+), 22 deletions(-) diff --git a/codex-rs/codex-mcp/src/codex_apps.rs b/codex-rs/codex-mcp/src/codex_apps.rs index 53b546b8a0..437e750de8 100644 --- a/codex-rs/codex-mcp/src/codex_apps.rs +++ b/codex-rs/codex-mcp/src/codex_apps.rs @@ -8,7 +8,6 @@ use std::path::PathBuf; use std::time::Instant; -use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::runtime::emit_duration; use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC; use crate::tools::ToolInfo; @@ -125,16 +124,11 @@ pub(crate) fn normalize_codex_apps_callable_namespace( } } -pub(crate) fn write_cached_codex_apps_tools_if_needed( - server_name: &str, +pub(crate) fn write_codex_apps_tools_cache( cache_context: Option<&CodexAppsToolsCacheContext>, server_info: &McpServerInfo, tools: &[ToolInfo], ) { - if server_name != CODEX_APPS_MCP_SERVER_NAME { - return; - } - if let Some(cache_context) = cache_context { let cache_write_start = Instant::now(); write_cached_codex_apps_tools(cache_context, tools); diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index a881e865d2..be27843071 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -16,7 +16,7 @@ use std::time::Instant; use crate::McpAuthStatusEntry; use crate::codex_apps::CodexAppsToolsCacheContext; use crate::codex_apps::CodexAppsToolsCacheKey; -use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::codex_apps::write_codex_apps_tools_cache; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; @@ -521,8 +521,7 @@ impl McpConnectionManager { &[], ); - write_cached_codex_apps_tools_if_needed( - CODEX_APPS_MCP_SERVER_NAME, + write_codex_apps_tools_cache( managed_client.codex_apps_tools_cache_context.as_ref(), &managed_client.server_info, &tools, diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 05b3917027..4556973928 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -5,7 +5,7 @@ use crate::codex_apps::load_startup_cached_codex_apps_server_info; use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::read_cached_codex_apps_tools; use crate::codex_apps::write_cached_codex_apps_tools; -use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::codex_apps::write_codex_apps_tools_cache; use crate::declared_openai_file_input_param_names; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -704,12 +704,7 @@ fn startup_cached_codex_apps_tools_loads_from_disk_cache() { "calendar_search", )]; let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools_if_needed( - CODEX_APPS_MCP_SERVER_NAME, - Some(&cache_context), - &server_info, - &cached_tools, - ); + write_codex_apps_tools_cache(Some(&cache_context), &server_info, &cached_tools); let startup_tools = load_startup_cached_codex_apps_tools_snapshot(Some(&cache_context)) .expect("expected startup snapshot to load from cache"); @@ -758,8 +753,7 @@ fn codex_apps_server_info_cache_survives_legacy_tools_cache_write() { Some("user-one"), ); let server_info = create_test_server_info("Codex Apps"); - write_cached_codex_apps_tools_if_needed( - CODEX_APPS_MCP_SERVER_NAME, + write_codex_apps_tools_cache( Some(&cache_context), &server_info, &[create_test_tool( diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index f8173b6003..f9cc6a5e81 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -25,7 +25,7 @@ use crate::codex_apps::load_startup_cached_codex_apps_tools_snapshot; use crate::codex_apps::normalize_codex_apps_callable_name; use crate::codex_apps::normalize_codex_apps_callable_namespace; use crate::codex_apps::normalize_codex_apps_tool_title; -use crate::codex_apps::write_cached_codex_apps_tools_if_needed; +use crate::codex_apps::write_codex_apps_tools_cache; use crate::elicitation::ElicitationRequestManager; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; @@ -581,8 +581,7 @@ async fn start_server_task( ); let server_info = mcp_server_info_from_implementation(initialize_result.server_info); let codex_apps_tools_cache_context = if is_codex_apps_mcp_server { - write_cached_codex_apps_tools_if_needed( - &server_name, + write_codex_apps_tools_cache( codex_apps_tools_cache_context.as_ref(), &server_info, &tools, From 266dcbfe5b1972c027a4c04728e45949ccc1e820 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 23 Jun 2026 00:53:17 -0700 Subject: [PATCH 78/86] Group Codex Apps client setup (#29583) ## Why `McpConnectionManager::new` classified the Codex Apps server twice: once to create its tools cache context and again to select its runtime authentication provider. Keeping those decisions separate makes it harder to see that they belong to the same server-specific setup path. ## What changed - Group Codex Apps cache and authentication setup under one explicit branch. - Keep regular MCP server setup in the corresponding `else` branch. - Limit environment bearer-token inspection to the Codex Apps path where it affects runtime authentication. --- codex-rs/codex-mcp/src/connection_manager.rs | 77 ++++++++++++++------ 1 file changed, 55 insertions(+), 22 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index be27843071..3057161117 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -7,6 +7,7 @@ //! `codex-core`. use std::collections::HashMap; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::Ordering; @@ -40,6 +41,7 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use async_channel::Sender; +use codex_api::SharedAuthProvider; use codex_config::Constrained; use codex_config::McpServerTransportConfig; use codex_config::types::AuthKeyringBackendKind; @@ -171,29 +173,16 @@ impl McpConnectionManager { }, ) .await; - let codex_apps_tools_cache_context = if server_name == CODEX_APPS_MCP_SERVER_NAME { - Some(CodexAppsToolsCacheContext { - codex_home: codex_home.clone(), - user_key: codex_apps_tools_cache_key.clone(), - }) - } else { - None - }; - let uses_env_bearer_token = - server - .configured_config() - .is_some_and(|config| match &config.transport { - McpServerTransportConfig::StreamableHttp { - bearer_token_env_var, - .. - } => bearer_token_env_var.is_some(), - McpServerTransportConfig::Stdio { .. } => false, - }); - let runtime_auth_provider = - if server_name == CODEX_APPS_MCP_SERVER_NAME && !uses_env_bearer_token { - codex_apps_auth_provider.clone() + let (codex_apps_tools_cache_context, runtime_auth_provider) = + if server_name == CODEX_APPS_MCP_SERVER_NAME { + codex_apps_cache_context_and_auth_provider( + &server, + &codex_home, + &codex_apps_tools_cache_key, + codex_apps_auth_provider.clone(), + ) } else { - None + regular_mcp_cache_context_and_auth_provider() }; let async_managed_client = AsyncManagedClient::new( server_name.clone(), @@ -856,6 +845,50 @@ impl Drop for McpConnectionManager { } } +/// Creates the host-owned state used only by the Codex Apps server. +/// +/// The tools cache is scoped to the authenticated user. Runtime authentication is supplied only +/// when the server is not already configured to read a bearer token from the environment. +fn codex_apps_cache_context_and_auth_provider( + server: &EffectiveMcpServer, + codex_home: &Path, + codex_apps_tools_cache_key: &CodexAppsToolsCacheKey, + codex_apps_auth_provider: Option, +) -> ( + Option, + Option, +) { + let uses_env_bearer_token = + server + .configured_config() + .is_some_and(|config| match &config.transport { + McpServerTransportConfig::StreamableHttp { + bearer_token_env_var, + .. + } => bearer_token_env_var.is_some(), + McpServerTransportConfig::Stdio { .. } => false, + }); + ( + Some(CodexAppsToolsCacheContext { + codex_home: codex_home.to_path_buf(), + user_key: codex_apps_tools_cache_key.clone(), + }), + if uses_env_bearer_token { + None + } else { + codex_apps_auth_provider + }, + ) +} + +/// Keeps regular MCP servers isolated from the host-owned Codex Apps cache and auth provider. +fn regular_mcp_cache_context_and_auth_provider() -> ( + Option, + Option, +) { + (None, None) +} + async fn emit_update( submit_id: &str, tx_event: &Sender, From d2484697b1f9ce33d1d818ccad859ca3a4d721c6 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 09:17:52 +0100 Subject: [PATCH 79/86] Allow codex sandbox to consume MCP sandbox state (#29358) ## Summary - let `codex sandbox` accept the JSON value from `codex/sandbox-state-meta` - require the payload `permissionProfile` instead of falling back to ambient permissions - reuse the existing macOS, Linux, and Windows launch paths, treating external sandbox state conservatively as read-only - let opaque forwarders add runtime read roots and disable direct network access without decoding the payload Builds on #29113, which is now on `main`. ## Tests - `just test -p codex-cli debug_sandbox::tests` - `cargo build -p codex-rmcp-client --bin test_stdio_server` - `just test -p codex-core stdio_mcp_tool_call_includes_sandbox_state_meta` - `just test -p codex-mcp` - `just fmt` --- codex-rs/cli/src/debug_sandbox.rs | 122 +++++++++++++++++++++-- codex-rs/cli/src/lib.rs | 37 ++++++- codex-rs/codex-mcp/src/runtime.rs | 5 +- codex-rs/core/src/mcp_tool_call.rs | 2 +- codex-rs/core/tests/suite/rmcp_client.rs | 14 ++- 5 files changed, 162 insertions(+), 18 deletions(-) diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 5e5bcb6c4e..dd2d646aa6 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -6,6 +6,7 @@ mod seatbelt; use std::path::PathBuf; use std::process::Stdio; +use anyhow::Context as _; use codex_config::LoaderOverrides; use codex_core::config::Config; use codex_core::config::ConfigBuilder; @@ -16,6 +17,8 @@ use codex_core::exec_env::create_env; use codex_core::spawn::CODEX_SANDBOX_ENV_VAR; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use codex_protocol::config_types::SandboxMode; +use codex_protocol::models::PermissionProfile; +use codex_protocol::models::SandboxEnforcement; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_sandboxing::landlock::allow_network_for_proxy; use codex_sandboxing::landlock::create_linux_sandbox_command_args_for_permission_profile; @@ -45,6 +48,7 @@ pub async fn run_command_under_seatbelt( loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let SeatbeltCommand { + sandbox_state, permissions_profile, config_profile: _, cwd, @@ -60,6 +64,7 @@ pub async fn run_command_under_seatbelt( ); run_command_under_sandbox( DebugSandboxConfigOptions { + sandbox_state, permissions_profile, cwd, managed_requirements_mode, @@ -90,6 +95,7 @@ pub async fn run_command_under_landlock( loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let LandlockCommand { + sandbox_state, permissions_profile, config_profile: _, cwd, @@ -103,6 +109,7 @@ pub async fn run_command_under_landlock( ); run_command_under_sandbox( DebugSandboxConfigOptions { + sandbox_state, permissions_profile, cwd, managed_requirements_mode, @@ -124,6 +131,7 @@ pub async fn run_command_under_windows_sandbox( loader_overrides: LoaderOverrides, ) -> anyhow::Result<()> { let WindowsCommand { + sandbox_state, permissions_profile, config_profile: _, cwd, @@ -137,6 +145,7 @@ pub async fn run_command_under_windows_sandbox( ); run_command_under_sandbox( DebugSandboxConfigOptions { + sandbox_state, permissions_profile, cwd, managed_requirements_mode, @@ -161,6 +170,7 @@ enum SandboxType { #[derive(Debug)] struct DebugSandboxConfigOptions { + sandbox_state: crate::SandboxStateArgs, permissions_profile: Option, cwd: Option, managed_requirements_mode: ManagedRequirementsMode, @@ -187,7 +197,7 @@ impl ManagedRequirementsMode { } async fn run_command_under_sandbox( - config_options: DebugSandboxConfigOptions, + mut config_options: DebugSandboxConfigOptions, command: Vec, config_overrides: CliConfigOverrides, codex_linux_sandbox_exe: Option, @@ -196,6 +206,34 @@ async fn run_command_under_sandbox( #[cfg_attr(not(target_os = "macos"), allow(unused_variables))] allow_unix_sockets: &[AbsolutePathBuf], ) -> anyhow::Result<()> { + let sandbox_state = config_options + .sandbox_state + .sandbox_state_json + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(|err| anyhow::anyhow!("invalid --sandbox-state-json value: {err}"))?; + let sandbox_state_readable_root = config_options + .sandbox_state + .sandbox_state_readable_root + .clone(); + let sandbox_state_disable_network = config_options.sandbox_state.sandbox_state_disable_network; + let codex_linux_sandbox_exe = match sandbox_state.as_ref() { + Some(state) => { + config_options.cwd = Some( + state + .sandbox_cwd + .to_abs_path() + .context("sandbox state cwd is not native to this host")? + .to_path_buf(), + ); + state + .codex_linux_sandbox_exe + .clone() + .or(codex_linux_sandbox_exe) + } + None => codex_linux_sandbox_exe, + }; let config = load_debug_sandbox_config( config_overrides .parse_overrides() @@ -220,12 +258,75 @@ async fn run_command_under_sandbox( &config.permissions.shell_environment_policy, /*thread_id*/ None, ); + let mut permission_profile = match sandbox_state.as_ref() { + Some(state) => match &state.permission_profile { + PermissionProfile::External { .. } => { + // `External` only says that the producer relies on an outer sandbox; it does not + // include filesystem permissions we can recreate here. The consumer may not share + // that sandbox, so use a locally enforceable read-only profile instead of spawning + // without a sandbox. + PermissionProfile::read_only() + } + permission_profile => permission_profile.clone(), + }, + None => config.permissions.effective_permission_profile(), + }; + if matches!(permission_profile, PermissionProfile::Disabled) && sandbox_state_disable_network { + anyhow::bail!( + "--sandbox-state-disable-network cannot be applied to a disabled permission profile" + ); + } + if !matches!(permission_profile, PermissionProfile::Disabled) + && (!sandbox_state_readable_root.is_empty() || sandbox_state_disable_network) + { + let file_system = permission_profile + .file_system_sandbox_policy() + .with_additional_readable_roots(&cwd, &sandbox_state_readable_root); + let network = if sandbox_state_disable_network { + NetworkSandboxPolicy::Restricted + } else { + permission_profile.network_sandbox_policy() + }; + permission_profile = PermissionProfile::from_runtime_permissions(&file_system, network); + } + let use_legacy_landlock = sandbox_state.as_ref().map_or_else( + || config.features.use_legacy_landlock(), + |state| state.use_legacy_landlock, + ); + + match permission_profile.enforcement() { + SandboxEnforcement::Managed => {} + SandboxEnforcement::Disabled | SandboxEnforcement::External => { + let (program, args) = command + .split_first() + .context("sandbox command must not be empty")?; + let mut child = spawn_debug_sandbox_child( + PathBuf::from(program), + args.to_vec(), + /*arg0*/ None, + cwd.to_path_buf(), + permission_profile.network_sandbox_policy(), + env, + |_| {}, + ) + .await?; + handle_exit_status(child.wait().await?); + } + } // Special-case Windows sandbox: execute and exit the process to emulate inherited stdio. if let SandboxType::Windows = sandbox_type { #[cfg(target_os = "windows")] { - run_command_under_windows_session(&config, command, cwd, workspace_roots, env).await; + run_command_under_windows_session( + &config, + &permission_profile, + command, + cwd, + workspace_roots, + env, + ) + .await; } #[cfg(not(target_os = "windows"))] { @@ -244,7 +345,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile(), + &permission_profile, /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -266,7 +367,7 @@ async fn run_command_under_sandbox( None => None, }; let runtime_permission_profile = with_managed_mitm_ca_readable_root( - config.permissions.effective_permission_profile(), + permission_profile, managed_mitm_ca_trust_bundle_path.as_ref(), sandbox_policy_cwd.as_path(), ); @@ -308,7 +409,6 @@ async fn run_command_under_sandbox( let codex_linux_sandbox_exe = config .codex_linux_sandbox_exe .expect("codex-linux-sandbox executable not found"); - let use_legacy_landlock = config.features.use_legacy_landlock(); let network_sandbox_policy = runtime_permission_profile.network_sandbox_policy(); let args = create_linux_sandbox_command_args_for_permission_profile( command, @@ -364,6 +464,7 @@ async fn run_command_under_sandbox( #[cfg(target_os = "windows")] async fn run_command_under_windows_session( config: &Config, + permission_profile: &PermissionProfile, command: Vec, cwd: AbsolutePathBuf, workspace_roots: Vec, @@ -374,10 +475,9 @@ async fn run_command_under_windows_session( use codex_windows_sandbox::WindowsSandboxSessionRequest; use codex_windows_sandbox::spawn_windows_sandbox_session_for_level; - let permission_profile = config.permissions.effective_permission_profile(); let empty_paths: &[AbsolutePathBuf] = &[]; let spawned = spawn_windows_sandbox_session_for_level(WindowsSandboxSessionRequest { - permission_profile: &permission_profile, + permission_profile, workspace_roots: workspace_roots.as_slice(), codex_home: config.codex_home.as_path(), command, @@ -464,6 +564,7 @@ async fn load_debug_sandbox_config_with_codex_home( strict_config: bool, ) -> anyhow::Result { let DebugSandboxConfigOptions { + sandbox_state: _, permissions_profile, cwd, managed_requirements_mode, @@ -649,6 +750,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -717,6 +819,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -774,6 +877,7 @@ mod tests { cli_overrides, /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -832,6 +936,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: None, cwd: None, managed_requirements_mode: ManagedRequirementsMode::Include, @@ -859,6 +964,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: Some(":workspace".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, @@ -898,6 +1004,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: Some("limited-read-test".to_string()), cwd: None, managed_requirements_mode: ManagedRequirementsMode::Ignore, @@ -937,6 +1044,7 @@ mod tests { Vec::new(), /*codex_linux_sandbox_exe*/ None, DebugSandboxConfigOptions { + sandbox_state: Default::default(), permissions_profile: Some(":workspace".to_string()), cwd: Some(cwd.path().to_path_buf()), managed_requirements_mode: ManagedRequirementsMode::Ignore, diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs index 4e63df187e..1c0a2e7e02 100644 --- a/codex-rs/cli/src/lib.rs +++ b/codex-rs/cli/src/lib.rs @@ -2,6 +2,7 @@ pub(crate) mod debug_sandbox; mod exit_status; pub(crate) mod login; +use clap::Args; use clap::Parser; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cli::CliConfigOverrides; @@ -21,10 +22,36 @@ pub use login::run_login_with_device_code; pub use login::run_login_with_device_code_fallback_to_browser; pub use login::run_logout; +#[derive(Debug, Default, Args)] +pub struct SandboxStateArgs { + /// JSON value from `codex/sandbox-state-meta` to apply directly. + #[arg( + long = "sandbox-state-json", + value_name = "JSON", + conflicts_with_all = ["permissions_profile", "cwd", "include_managed_config"] + )] + pub sandbox_state_json: Option, + + /// Add a readable root to the supplied sandbox state. Repeat for multiple roots. + #[arg( + long, + requires = "sandbox_state_json", + value_parser = parse_absolute_path + )] + pub sandbox_state_readable_root: Vec, + + /// Disable direct network access in the supplied sandbox state. + #[arg(long, requires = "sandbox_state_json", default_value_t = false)] + pub sandbox_state_disable_network: bool, +} + // These command structs share common sandbox options, but remain separate // because each host backend has a slightly different option surface. #[derive(Debug, Parser)] pub struct SeatbeltCommand { + #[command(flatten)] + pub sandbox_state: SandboxStateArgs, + /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, @@ -51,7 +78,7 @@ pub struct SeatbeltCommand { pub include_managed_config: bool, /// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths. - #[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)] + #[arg(long = "allow-unix-socket", value_parser = parse_absolute_path)] pub allow_unix_sockets: Vec, /// While the command runs, capture macOS sandbox denials via `log stream` and print them after exit @@ -66,13 +93,16 @@ pub struct SeatbeltCommand { pub command: Vec, } -fn parse_allow_unix_socket_path(raw: &str) -> Result { +fn parse_absolute_path(raw: &str) -> Result { AbsolutePathBuf::relative_to_current_dir(raw) .map_err(|err| format!("invalid path {raw}: {err}")) } #[derive(Debug, Parser)] pub struct LandlockCommand { + #[command(flatten)] + pub sandbox_state: SandboxStateArgs, + /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, @@ -108,6 +138,9 @@ pub struct LandlockCommand { #[derive(Debug, Parser)] pub struct WindowsCommand { + #[command(flatten)] + pub sandbox_state: SandboxStateArgs, + /// Named permissions profile to apply from the active configuration stack. #[arg(long = "permissions-profile", short = 'P', value_name = "NAME")] pub permissions_profile: Option, diff --git a/codex-rs/codex-mcp/src/runtime.rs b/codex-rs/codex-mcp/src/runtime.rs index e52048349c..e48bdc7f29 100644 --- a/codex-rs/codex-mcp/src/runtime.rs +++ b/codex-rs/codex-mcp/src/runtime.rs @@ -17,11 +17,10 @@ use codex_utils_path_uri::PathUri; use serde::Deserialize; use serde::Serialize; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SandboxState { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub permission_profile: Option, + pub permission_profile: PermissionProfile, pub codex_linux_sandbox_exe: Option, pub sandbox_cwd: PathUri, #[serde(default)] diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 56d6e678a0..0cb0c11a2a 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -744,7 +744,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( }; let permission_profile = turn_context.permission_profile(); let sandbox_state = serde_json::to_value(SandboxState { - permission_profile: Some(permission_profile), + permission_profile, codex_linux_sandbox_exe: turn_context.config.codex_linux_sandbox_exe.clone(), sandbox_cwd, use_legacy_landlock: turn_context.config.features.use_legacy_landlock(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 78fa73ce50..8000ed1c00 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -26,6 +26,7 @@ use codex_exec_server::Environment; use codex_exec_server::HttpRequestParams; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; +use codex_mcp::SandboxState; use codex_models_manager::manager::RefreshStrategy; use codex_utils_path_uri::LegacyAppPathString; @@ -933,13 +934,16 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let sandbox_meta = meta .get(MCP_SANDBOX_STATE_META_CAPABILITY) .expect("sandbox state metadata should be present"); - assert_eq!(sandbox_meta.get("sandboxPolicy"), None); - let expected_sandbox_cwd = PathUri::from_abs_path(&fixture.config.cwd).to_string(); + let sandbox_state: SandboxState = serde_json::from_value(sandbox_meta.clone())?; assert_eq!( - sandbox_meta.get("sandboxCwd").and_then(Value::as_str), - Some(expected_sandbox_cwd.as_str()) + sandbox_state, + SandboxState { + permission_profile: PermissionProfile::read_only(), + codex_linux_sandbox_exe: fixture.config.codex_linux_sandbox_exe.clone(), + sandbox_cwd: PathUri::from_abs_path(&fixture.config.cwd), + use_legacy_landlock: false, + } ); - assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false))); server.verify().await; From 63f8f547c0b95800f3c61ee172a2a833b75faf3f Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 11:18:25 +0100 Subject: [PATCH 80/86] Stop persisting bridged log events (#29599) ## Why The 0.142.0 persistent-log filter disables target=log, but bridged log records are filtered using their original dependency target before tracing-log emits them as target=log. This allowed high-volume dependency TRACE events to keep reaching SQLite. This is a follow-up to #28224. ## What changed - Reject bridged target=log events inside the SQLite sink before formatting or queueing them. --- codex-rs/state/src/log_db.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/codex-rs/state/src/log_db.rs b/codex-rs/state/src/log_db.rs index 55de2a3077..2cf0f5d9af 100644 --- a/codex-rs/state/src/log_db.rs +++ b/codex-rs/state/src/log_db.rs @@ -199,6 +199,13 @@ where fn on_event(&self, event: &Event<'_>, ctx: tracing_subscriber::layer::Context<'_, S>) { let metadata = event.metadata(); + // `tracing-log` checks filters with the original log target before + // dispatching an event whose tracing target is `log`, so the outer + // target filter cannot reliably reject these bridged events. + if metadata.target() == "log" { + return; + } + // The SDK emits DEBUG timer meta-events every second per process; these // were over 30% of retained logs in measured high-fanout Codex environments. if metadata.target() == "opentelemetry_sdk" From 55bc38a22bc6b75f51ca7d3a4bd3e634979af479 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 12:45:42 +0100 Subject: [PATCH 81/86] Fix Codex Apps auth elicitation hang (#29615) ## Summary - Require the reserved Codex Apps MCP server name to be present in the connection manager before treating it as host-owned. - Update auth elicitation tests to model an installed host-owned Codex Apps server without sending startup events to the test session. ## Why PR #29518 replaced the old host-owned flag with a name-only check. That made non-host-owned tests with the reserved codex_apps name enter auth elicitation and wait forever for a response. --- codex-rs/codex-mcp/src/connection_manager.rs | 2 +- .../codex-mcp/src/connection_manager_tests.rs | 35 +++++++++++++++++++ codex-rs/core/src/mcp_tool_call_tests.rs | 16 +++++++-- 3 files changed, 49 insertions(+), 4 deletions(-) diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 3057161117..53e9efc6d7 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -391,7 +391,7 @@ impl McpConnectionManager { } pub fn is_host_owned_codex_apps_server(&self, server_name: &str) -> bool { - server_name == CODEX_APPS_MCP_SERVER_NAME + server_name == CODEX_APPS_MCP_SERVER_NAME && self.server_metadata.contains_key(server_name) } pub fn set_approval_policy(&self, approval_policy: &Constrained) { diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 4556973928..887a9b3d46 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -1177,6 +1177,41 @@ fn server_metadata_preserves_tool_approval_policy() { ); } +#[test] +fn host_owned_codex_apps_requires_server_metadata() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + + assert!(!manager.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME)); +} + +#[test] +fn host_owned_codex_apps_matches_reserved_name_with_server_metadata() { + let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); + let permission_profile = Constrained::allow_any(PermissionProfile::default()); + let mut manager = McpConnectionManager::new_uninitialized( + &approval_policy, + &permission_profile, + /*prefix_mcp_tool_names*/ true, + ); + let server = EffectiveMcpServer::configured(crate::codex_apps_mcp_server_config( + "https://chatgpt.com", + /*apps_mcp_product_sku*/ None, + )); + manager.server_metadata.insert( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + McpServerMetadata::from(&server), + ); + + assert!(manager.is_host_owned_codex_apps_server(CODEX_APPS_MCP_SERVER_NAME)); + assert!(!manager.is_host_owned_codex_apps_server("docs")); +} + #[tokio::test] async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { let approval_policy = Constrained::allow_any(AskForApproval::OnFailure); diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 2a951bfe55..f8b94de5aa 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1335,15 +1335,25 @@ fn codex_apps_auth_failure_metadata() -> McpToolApprovalMetadata { async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: &TurnContext) { let auth = session.services.auth_manager.auth().await; + let startup_cancellation_token = CancellationToken::new(); + startup_cancellation_token.cancel(); + let (tx_event, _rx_event) = async_channel::unbounded(); + let mcp_servers = HashMap::from([( + CODEX_APPS_MCP_SERVER_NAME.to_string(), + codex_mcp::EffectiveMcpServer::configured(codex_mcp::codex_apps_mcp_server_config( + "https://chatgpt.com", + /*apps_mcp_product_sku*/ None, + )), + )]); let manager = codex_mcp::McpConnectionManager::new( - &HashMap::new(), + &mcp_servers, turn_context.config.mcp_oauth_credentials_store_mode, turn_context.config.auth_keyring_backend_kind(), HashMap::new(), &turn_context.approval_policy, turn_context.sub_id.clone(), - session.get_tx_event(), - CancellationToken::new(), + tx_event, + startup_cancellation_token, turn_context.permission_profile(), codex_mcp::McpRuntimeContext::new( session.services.turn_environments.environment_manager(), From 49614a0391d83eec442ffeca1d4aa0fdeb119818 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 13:15:20 +0100 Subject: [PATCH 82/86] Namespace multi-agent v2 tools under collaboration (#29067) ## Summary Multi-agent v2 tools now use the fixed `collaboration` namespace when namespace tools are available. This keeps the model-visible hint and the actual tool surface aligned around `functions.collaboration.*`, without exposing an unshipped namespace knob to users. The PR also removes the old `features.multi_agent_v2.tool_namespace` config/schema surface, updates the MAv2 test fixtures for namespaced calls, and fixes stale `TurnContext.features` references that were breaking `codex-core` builds. ## Changes - Expose MAv2 tools under `collaboration` instead of relying on a configurable namespace. - Remove `tool_namespace` from MAv2 TOML config, resolved config, validation, schema, and tests. - Update tool-planning and integration fixtures to assert or emit namespaced MAv2 tool calls. - Read feature state through `TurnContext.config.features` in the multi-agent mode context paths. ## Testing - `just write-config-schema` - `just test -p codex-features` --- .../app-server/tests/suite/v2/turn_start.rs | 8 ++- codex-rs/core/src/config/mod.rs | 5 +- codex-rs/core/src/tools/spec_plan_tests.rs | 66 ++++++++++++++----- codex-rs/core/tests/suite/agent_execution.rs | 17 ++++- .../tests/suite/model_runtime_selectors.rs | 12 +++- codex-rs/core/tests/suite/pending_input.rs | 5 +- codex-rs/core/tests/suite/rollout_budget.rs | 11 +++- .../tests/suite/subagent_notifications.rs | 53 +++++++++++---- 8 files changed, 138 insertions(+), 39 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index dd211a5d89..622e4284ef 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -100,6 +100,7 @@ const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const TEST_ORIGINATOR: &str = "codex_vscode"; const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer."; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; const TINY_PNG_BYTES: &[u8] = &[ 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, @@ -3650,7 +3651,12 @@ async fn direct_input_to_multi_agent_v2_subagent_is_rejected() -> Result<()> { |req: &wiremock::Request| body_contains(req, PARENT_PROMPT), responses::sse(vec![ responses::ev_response_created("resp-parent-1"), - responses::ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + responses::ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), responses::ev_completed("resp-parent-1"), ]), ) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 953d0af1a1..81d2870c11 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -246,7 +246,8 @@ Payload: ``` You may also see them addressed as to=/root/..., which indicates your identity is /root/... "#; -const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.spawn_agent` without a configured namespace or `to=functions.agents.spawn_agent` with `tool_namespace = "agents"`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. +const DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE: &str = "collaboration"; +const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. All agents share the same directory. In detail: - All agents have access to the same container and filesystem as you. @@ -1157,7 +1158,7 @@ impl MultiAgentV2Config { DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, max_concurrent_threads_per_session, )), - tool_namespace: None, + tool_namespace: Some(DEFAULT_MULTI_AGENT_V2_TOOL_NAMESPACE.to_string()), hide_spawn_agent_metadata: true, non_code_mode_only: true, } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 5d70fb8f17..3fe33b141b 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -39,6 +39,8 @@ use crate::tools::router::ToolRouterParams; use crate::tools::router::ToolSuggestCandidates; use crate::tools::router::ToolSuggestPresentation; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; + #[derive(Default)] struct ToolPlanInputs { mcp_tools: Option>, @@ -1151,19 +1153,48 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { }); }) .await; - v2.assert_visible_contains(&[ + v2.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); + v2.assert_visible_lacks(&[ "spawn_agent", "send_message", "followup_task", "wait_agent", "interrupt_agent", "list_agents", + "send_input", + "resume_agent", + "assign_task", + "close_agent", ]); - v2.assert_visible_lacks(&["send_input", "resume_agent", "assign_task", "close_agent"]); - let spawn_agent_description = match v2.visible_spec("spawn_agent") { - ToolSpec::Function(tool) => tool.description.as_str(), - other => panic!("expected spawn_agent function spec, got {other:?}"), + for tool_name in [ + "spawn_agent", + "send_message", + "followup_task", + "wait_agent", + "interrupt_agent", + "list_agents", + ] { + assert!( + v2.namespace_function_names(MULTI_AGENT_V2_NAMESPACE) + .iter() + .any(|name| name == tool_name), + "expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace" + ); + } + let ToolSpec::Namespace(namespace) = v2.visible_spec(MULTI_AGENT_V2_NAMESPACE) else { + panic!("expected {MULTI_AGENT_V2_NAMESPACE} namespace"); }; + let Some(ResponsesApiNamespaceTool::Function(spawn_agent)) = + namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == "spawn_agent" + ) + }) + else { + panic!("expected spawn_agent in {MULTI_AGENT_V2_NAMESPACE} namespace"); + }; + let spawn_agent_description = spawn_agent.description.as_str(); assert!(!spawn_agent_description.contains("max_concurrent_threads_per_session")); assert!(spawn_agent_description.contains( "Note that passing `fork_turns=\"none\"` will not pass any surrounding context to the spawned subagent" @@ -1183,9 +1214,11 @@ async fn multi_agent_feature_selects_one_agent_tool_family() { }); }) .await; - direct_model_only.assert_visible_contains(&["spawn_agent", "send_message", "wait_agent"]); + direct_model_only.assert_visible_contains(&[MULTI_AGENT_V2_NAMESPACE]); + direct_model_only.assert_visible_lacks(&["spawn_agent", "send_message", "wait_agent"]); assert_eq!( - direct_model_only.exposure("spawn_agent"), + direct_model_only + .exposure(&ToolName::namespaced(MULTI_AGENT_V2_NAMESPACE, "spawn_agent").to_string()), ToolExposure::DirectModelOnly ); } @@ -1196,9 +1229,17 @@ async fn multi_agent_v2_message_schemas_are_encrypted() { set_feature(turn, Feature::MultiAgentV2, /*enabled*/ true); }) .await; + let ToolSpec::Namespace(namespace) = plan.visible_spec(MULTI_AGENT_V2_NAMESPACE) else { + panic!("expected {MULTI_AGENT_V2_NAMESPACE} namespace"); + }; for tool_name in ["spawn_agent", "send_message", "followup_task"] { - let ToolSpec::Function(tool) = plan.visible_spec(tool_name) else { - panic!("expected {tool_name} function spec"); + let Some(ResponsesApiNamespaceTool::Function(tool)) = namespace.tools.iter().find(|tool| { + matches!( + tool, + ResponsesApiNamespaceTool::Function(tool) if tool.name == tool_name + ) + }) else { + panic!("expected {tool_name} in {MULTI_AGENT_V2_NAMESPACE} namespace"); }; let properties = tool .parameters @@ -1464,12 +1505,7 @@ async fn hosted_tools_follow_provider_auth_model_and_config_gates() { codex_code_mode::WAIT_TOOL_NAME, "request_user_input", // Multi-agent v2 tools. - "spawn_agent", - "send_message", - "followup_task", - "wait_agent", - "interrupt_agent", - "list_agents", + MULTI_AGENT_V2_NAMESPACE, // Hosted Responses tools. "web_search", "image_generation", diff --git a/codex-rs/core/tests/suite/agent_execution.rs b/codex-rs/core/tests/suite/agent_execution.rs index f073d70089..69db20cc57 100644 --- a/codex-rs/core/tests/suite/agent_execution.rs +++ b/codex-rs/core/tests/suite/agent_execution.rs @@ -2,7 +2,7 @@ use anyhow::Result; use codex_features::Feature; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::sse; @@ -15,6 +15,7 @@ use std::time::Duration; const FIRST_PROMPT: &str = "spawn the first worker"; const FIRST_TASK: &str = "first worker task"; const SECOND_TASK: &str = "second worker task"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; fn body_contains(request: &wiremock::Request, text: &str) -> bool { serde_json::from_slice::(&request.body) @@ -47,7 +48,12 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> |request: &wiremock::Request| body_contains(request, FIRST_PROMPT), sse(vec![ ev_response_created("first-response"), - ev_function_call("first-call", "spawn_agent", &first_args), + ev_function_call_with_namespace( + "first-call", + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &first_args, + ), ev_completed("first-response"), ]), ) @@ -63,7 +69,12 @@ async fn v2_nested_spawn_checks_shared_active_execution_capacity() -> Result<()> }, sse(vec![ ev_response_created("first-worker-response"), - ev_function_call("second-call", "spawn_agent", &second_args), + ev_function_call_with_namespace( + "second-call", + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &second_args, + ), ev_completed("first-worker-response"), ]), ) diff --git a/codex-rs/core/tests/suite/model_runtime_selectors.rs b/codex-rs/core/tests/suite/model_runtime_selectors.rs index d893e389d3..0c6a87605d 100644 --- a/codex-rs/core/tests/suite/model_runtime_selectors.rs +++ b/codex-rs/core/tests/suite/model_runtime_selectors.rs @@ -37,6 +37,7 @@ use tokio::time::sleep; const CHILD_MODEL: &str = "test-multi-agent-child"; const ROOT_MODEL: &str = "test-multi-agent-root"; const ROOT_PROMPT: &str = "spawn a child"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const UNSUPPORTED_CODE_MODE_WARNING: &str = "does not advertise Code Mode support"; struct RemoteModelResponse { @@ -335,7 +336,7 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { .expect("test config should allow feature update"); }) .await?; - assert!(tool_names(&v2_body).contains(&"send_message".to_string())); + assert!(tool_names(&v2_body).contains(&MULTI_AGENT_V2_NAMESPACE.to_string())); let mut disabled_model = remote_model("test-multi-agent-disabled"); disabled_model.multi_agent_version = Some(MultiAgentVersion::Disabled); @@ -349,7 +350,12 @@ async fn remote_multi_agent_selector_overrides_feature_flags() -> Result<()> { let disabled_tools = tool_names(&disabled_body); assert!(disabled_tools.iter().all(|name| !matches!( name.as_str(), - "multi_agent_v1" | "spawn_agent" | "send_message" | "wait_agent" | "list_agents" + "multi_agent_v1" + | MULTI_AGENT_V2_NAMESPACE + | "spawn_agent" + | "send_message" + | "wait_agent" + | "list_agents" ))); Ok(()) @@ -432,7 +438,7 @@ async fn remote_multi_agent_selector_uses_model_selected_before_first_turn() -> .expect("expected response request") .body_json(), ) - .contains(&"send_message".to_string()), + .contains(&MULTI_AGENT_V2_NAMESPACE.to_string()), ), (1, Some(MultiAgentVersion::V2), true) ); diff --git a/codex-rs/core/tests/suite/pending_input.rs b/codex-rs/core/tests/suite/pending_input.rs index 269beae638..17e00513fe 100644 --- a/codex-rs/core/tests/suite/pending_input.rs +++ b/codex-rs/core/tests/suite/pending_input.rs @@ -20,6 +20,7 @@ use core_test_support::responses; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_message_item_added; use core_test_support::responses::ev_output_text_delta; use core_test_support::responses::ev_reasoning_item; @@ -290,11 +291,13 @@ async fn steer_interrupts_wait_agent_and_is_sent_in_follow_up_request() { const WAIT_CALL_ID: &str = "wait-call"; const INITIAL_PROMPT: &str = "wait for an agent"; const STEER_PROMPT: &str = "stop waiting and continue"; + const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; let first_chunks = vec![ chunk(ev_response_created("resp-1")), - chunk(ev_function_call( + chunk(ev_function_call_with_namespace( WAIT_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, "wait_agent", r#"{"timeout_ms":10000}"#, )), diff --git a/codex-rs/core/tests/suite/rollout_budget.rs b/codex-rs/core/tests/suite/rollout_budget.rs index c051f01af2..6aa211bcc2 100644 --- a/codex-rs/core/tests/suite/rollout_budget.rs +++ b/codex-rs/core/tests/suite/rollout_budget.rs @@ -10,7 +10,7 @@ use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_completed_with_tokens; -use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once_match; use core_test_support::responses::mount_sse_sequence; @@ -25,6 +25,8 @@ use std::time::Duration; use test_case::test_case; use tokio::time::timeout; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; + fn rollout_budget() -> RolloutBudgetConfig { RolloutBudgetConfig { limit_tokens: 100, @@ -130,7 +132,12 @@ async fn subagent_usage_draws_from_the_shared_budget() -> Result<()> { |request: &wiremock::Request| wire_request_contains(request, ROOT_PROMPT), sse(vec![ ev_response_created("root-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed_with_tokens("root-1", /*total_tokens*/ 10), ]), ) diff --git a/codex-rs/core/tests/suite/subagent_notifications.rs b/codex-rs/core/tests/suite/subagent_notifications.rs index c49e474965..53f9a97bc7 100644 --- a/codex-rs/core/tests/suite/subagent_notifications.rs +++ b/codex-rs/core/tests/suite/subagent_notifications.rs @@ -17,7 +17,6 @@ use core_test_support::hooks::trust_discovered_hooks; use core_test_support::responses::ResponsesRequest; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; -use core_test_support::responses::ev_function_call; use core_test_support::responses::ev_function_call_with_namespace; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_tool_search_call; @@ -48,6 +47,7 @@ use wiremock::MockServer; const SPAWN_CALL_ID: &str = "spawn-call-1"; const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; +const MULTI_AGENT_V2_NAMESPACE: &str = "collaboration"; const TURN_0_FORK_PROMPT: &str = "seed fork context"; const TURN_1_PROMPT: &str = "spawn a child and continue"; const TURN_2_NO_WAIT_PROMPT: &str = "follow up without wait"; @@ -63,6 +63,23 @@ const SUBAGENT_STOP_CONTINUATION: &str = "continue only the child"; const INTERNAL_SUBAGENT_PROMPT: &str = "internal subagent: review"; fn body_contains(req: &wiremock::Request, text: &str) -> bool { + decoded_body(req) + .and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + +fn request_has_input_type(req: &wiremock::Request, ty: &str) -> bool { + decoded_body(req) + .and_then(|body| serde_json::from_slice::(&body).ok()) + .and_then(|body| body.get("input").and_then(Value::as_array).cloned()) + .is_some_and(|items| { + items + .iter() + .any(|item| item.get("type").and_then(Value::as_str) == Some(ty)) + }) +} + +fn decoded_body(req: &wiremock::Request) -> Option> { let is_zstd = req .headers .get("content-encoding") @@ -72,14 +89,11 @@ fn body_contains(req: &wiremock::Request, text: &str) -> bool { .split(',') .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) }); - let bytes = if is_zstd { + if is_zstd { zstd::stream::decode_all(std::io::Cursor::new(&req.body)).ok() } else { Some(req.body.clone()) - }; - bytes - .and_then(|body| String::from_utf8(body).ok()) - .is_some_and(|body| body.contains(text)) + } } fn has_subagent_notification(req: &ResponsesRequest) -> bool { @@ -1040,14 +1054,19 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) .await; let child_request_log = mount_sse_once_match( &server, - |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + |req: &wiremock::Request| request_has_input_type(req, "agent_message"), sse(vec![ ev_response_created("resp-child-1"), ev_completed("resp-child-1"), @@ -1057,7 +1076,7 @@ async fn encrypted_multi_agent_v2_spawn_sends_agent_message_to_child() -> Result mount_sse_once_match( &server, |req: &wiremock::Request| { - body_contains(req, SPAWN_CALL_ID) && !body_contains(req, "\"type\":\"agent_message\"") + body_contains(req, SPAWN_CALL_ID) && !request_has_input_type(req, "agent_message") }, sse(vec![ ev_response_created("resp-parent-2"), @@ -1129,7 +1148,12 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( |req: &wiremock::Request| body_contains(req, TURN_1_PROMPT), sse(vec![ ev_response_created("resp-parent-1"), - ev_function_call(SPAWN_CALL_ID, "spawn_agent", &spawn_args), + ev_function_call_with_namespace( + SPAWN_CALL_ID, + MULTI_AGENT_V2_NAMESPACE, + "spawn_agent", + &spawn_args, + ), ev_completed("resp-parent-1"), ]), ) @@ -1144,7 +1168,7 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( }; let child_request = mount_response_once_match( &server, - |req: &wiremock::Request| body_contains(req, "\"type\":\"agent_message\""), + |req: &wiremock::Request| request_has_input_type(req, "agent_message"), sse_response(sse(child_events)).set_delay(Duration::from_secs(1)), ) .await; @@ -1183,7 +1207,12 @@ async fn plaintext_multi_agent_v2_completion_sends_agent_message( }, sse(vec![ ev_response_created("resp-parent-3"), - ev_function_call("wait-agent-call", "wait_agent", "{}"), + ev_function_call_with_namespace( + "wait-agent-call", + MULTI_AGENT_V2_NAMESPACE, + "wait_agent", + "{}", + ), ev_completed("resp-parent-3"), ]), ) From 414782450952a196bbb64b1605a458c2531c47b6 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 14:59:39 +0100 Subject: [PATCH 83/86] path-uri: add lexical containment (#29614) ## Why Executor-owned paths must stay portable while the orchestrator reasons about them. Converting a Windows or remote path to the orchestrator host's native path just to check containment breaks that boundary. ## What changed - Add lexical containment to `PathUri`. - Compare URI authorities and complete path segments, so `plugin-other` is not treated as a child of `plugin`. - Fail closed for encoded path separators and opaque fallback URIs. For example: ```text file:///C:/plugins/foo/assets/icon.svg is below file:///C:/plugins/foo file:///C:/plugins/foo2/icon.svg is not below file:///C:/plugins/foo ``` This is the shared foundation for keeping executor-owned plugin resources URI-native without consulting the orchestrator filesystem. --- codex-rs/utils/path-uri/src/lib.rs | 40 +++++++++++++++++++ codex-rs/utils/path-uri/src/tests.rs | 59 ++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+) diff --git a/codex-rs/utils/path-uri/src/lib.rs b/codex-rs/utils/path-uri/src/lib.rs index 6c92c5e1df..9794e7c709 100644 --- a/codex-rs/utils/path-uri/src/lib.rs +++ b/codex-rs/utils/path-uri/src/lib.rs @@ -247,6 +247,33 @@ impl PathUri { std::iter::successors(Some(self.clone()), Self::parent) } + /// Returns true when this URI is lexically equal to or below `base`. + /// + /// Containment is computed using URI authority and path-segment boundaries, + /// without consulting the host filesystem. Percent-encoded path separators + /// fail closed because native path conversion may interpret them as segment + /// boundaries. Opaque fallback URIs created by [`Self::from_abs_path`] only + /// contain themselves. + pub fn starts_with(&self, base: &Self) -> bool { + if self == base { + return true; + } + if decode_bad_path_uri(&self.0).is_some() || decode_bad_path_uri(&base.0).is_some() { + return false; + } + if self.0.host_str() != base.0.host_str() { + return false; + } + + let Some(path_segments) = containment_path_segments(&self.0) else { + return false; + }; + let Some(base_segments) = containment_path_segments(&base.0) else { + return false; + }; + path_segments.starts_with(&base_segments) + } + /// Lexically resolves native absolute or relative path text against this URI. /// /// Path text is interpreted using the POSIX or Windows convention inferred @@ -542,6 +569,19 @@ fn is_windows_drive_uri_segment(segment: &str) -> bool { matches!(segment.as_bytes(), [drive, b':'] if drive.is_ascii_alphabetic()) } +fn containment_path_segments(url: &Url) -> Option> { + let segments = url + .path_segments()? + .filter(|segment| !segment.is_empty()) + .collect::>(); + (!segments.iter().any(|segment| { + urlencoding::decode_binary(segment.as_bytes()) + .iter() + .any(|byte| matches!(*byte, b'/' | b'\\')) + })) + .then_some(segments) +} + fn infer_opaque_path_convention(path_bytes: &[u8]) -> Option { if path_bytes.starts_with(b"/") { return Some(PathConvention::Posix); diff --git a/codex-rs/utils/path-uri/src/tests.rs b/codex-rs/utils/path-uri/src/tests.rs index 0e45baaa12..c52c3f9e6a 100644 --- a/codex-rs/utils/path-uri/src/tests.rs +++ b/codex-rs/utils/path-uri/src/tests.rs @@ -283,9 +283,16 @@ fn structurally_valid_bad_path_uri_with_invalid_native_payload_fails_conversion( fn bad_path_uris_are_opaque_to_lexical_operations() { let uri = PathUri::parse("file:///%00/bad/path/YQ") .expect("canonical base64 fallback URI should parse"); + let other = PathUri::parse("file:///%00/bad/path/Yg") + .expect("canonical base64 fallback URI should parse"); + let root = PathUri::parse("file:///").expect("valid root URI"); assert_eq!(uri.basename(), None); assert_eq!(uri.parent(), None); + assert!(uri.starts_with(&uri)); + assert!(!uri.starts_with(&root)); + assert!(!uri.starts_with(&other)); + assert!(!other.starts_with(&uri)); assert_eq!(uri.join(""), Ok(uri.clone())); assert_eq!( uri.join("child"), @@ -698,6 +705,58 @@ fn join_uses_the_base_uri_path_convention() { } } +#[test] +fn starts_with_uses_uri_segment_boundaries() { + for (path, base, expected) in [ + ("file:///workspace/plugin", "file:///", true), + ("file:///workspace/plugin", "file:///workspace/plugin", true), + ( + "file:///workspace/plugin/assets/icon.svg", + "file:///workspace/plugin", + true, + ), + ( + "file:///workspace/plugin-other/icon.svg", + "file:///workspace/plugin", + false, + ), + ( + "file:///C:/plugins/foo/assets/icon.svg", + "file:///C:/plugins/foo", + true, + ), + ( + "file:///C:/plugins/foo2/assets/icon.svg", + "file:///C:/plugins/foo", + false, + ), + ( + "file://server/share/plugins/foo/icon.svg", + "file://server/share/plugins/foo", + true, + ), + ( + "file://other/share/plugins/foo/icon.svg", + "file://server/share/plugins/foo", + false, + ), + ( + "file:///workspace/plugin/%2F..%2Foutside", + "file:///workspace/plugin", + false, + ), + ( + "file:///workspace/plugin/%5C..%5Coutside", + "file:///workspace/plugin", + false, + ), + ] { + let path = PathUri::parse(path).expect("valid path URI"); + let base = PathUri::parse(base).expect("valid base URI"); + assert_eq!(path.starts_with(&base), expected); + } +} + #[test] fn to_url_returns_the_validated_url() { let uri = PathUri::parse("file://localhost/workspace/a%20file.rs").expect("valid file URI"); From 330ae6a516009e127db01843f5049497d3c46805 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Tue, 23 Jun 2026 10:23:25 -0400 Subject: [PATCH 84/86] Share resumed rollout history (#28426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Resuming a persisted thread currently deep-clones its complete rollout history several times. `InitialHistory` is retained for the app-server response, copied into thread persistence, and copied again by read-only accessors. These copies scale with the complete rollout rather than the bounded model context and add measurable latency for large sessions. This change stores resumed rollout history in `Arc>`. Rollout loading wraps the parsed vector once, while app-server response construction, session initialization, and thread persistence share it through inexpensive `Arc` clones. Read-only history access now returns a borrowed slice, and fork paths use `Arc::unwrap_or_clone` where they genuinely need mutable ownership. Rollout reconstruction also consumes its temporary context instead of cloning the reconstructed model history. The serialized representation remains unchanged. In an artificial 123 MB rollout benchmark, sharing resumed history reduced cold resume latency by roughly 9–10%. The affected crates compile with their test targets, all 80 thread-store tests pass, and the Bazel dependency lock remains valid. --- codex-rs/Cargo.toml | 2 +- .../request_processors/thread_processor.rs | 40 +++++++++---------- .../src/request_processors/turn_processor.rs | 2 +- codex-rs/core/src/agent/control/spawn.rs | 4 +- .../src/session/rollout_reconstruction.rs | 2 +- .../session/rollout_reconstruction_tests.rs | 28 +++++++------ codex-rs/core/src/session/tests.rs | 22 +++++----- codex-rs/core/src/thread_manager.rs | 21 +++++----- codex-rs/core/src/thread_manager_tests.rs | 14 +++---- codex-rs/core/tests/suite/fork_thread.rs | 4 +- codex-rs/core/tests/suite/resume_warning.rs | 6 ++- codex-rs/protocol/src/protocol.rs | 11 ++--- codex-rs/rollout/src/recorder.rs | 2 +- codex-rs/thread-store/src/in_memory.rs | 4 +- codex-rs/thread-store/src/live_thread.rs | 8 ++-- .../thread-store/src/thread_metadata_sync.rs | 14 +++++-- codex-rs/thread-store/src/types.rs | 3 +- 17 files changed, 102 insertions(+), 85 deletions(-) diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 9591f78afa..3290c4ea99 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -380,7 +380,7 @@ schemars = "0.8.22" seccompiler = "0.5.0" semver = "1.0" sentry = "0.46.0" -serde = "1" +serde = { version = "1", features = ["rc"] } serde_ignored = "0.1.14" serde_json = "1" serde_path_to_error = "0.1.20" diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index a3e4b49ce8..a59bb530d1 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -2604,8 +2604,8 @@ impl ThreadRequestProcessor { self.resume_thread_from_history(history.as_slice()) .await .map(|thread_history| (thread_history, None)) - } else if let Some(stored_thread) = stored_thread_from_running_probe { - self.stored_thread_to_initial_history(&stored_thread) + } else if let Some(mut stored_thread) = stored_thread_from_running_probe { + self.stored_thread_to_initial_history(&mut stored_thread) .await .map(|thread_history| (thread_history, Some(*stored_thread))) } else { @@ -2758,7 +2758,7 @@ impl ThreadRequestProcessor { let token_usage_thread = include_turns.then(|| thread.clone()); let mut initial_turns_page = if let Some(params) = initial_turns_page.as_ref() { match build_thread_resume_initial_turns_page( - &response_history.get_rollout_items(), + response_history.get_rollout_items(), thread.status.clone(), /*has_live_running_thread*/ false, /*active_turn*/ None, @@ -2803,7 +2803,7 @@ impl ThreadRequestProcessor { // rebuilding history only to attribute a replayed usage update. if let Some(token_usage_thread) = token_usage_thread { let token_usage_turn_id = latest_token_usage_turn_id_from_rollout_items( - &response_history.get_rollout_items(), + response_history.get_rollout_items(), token_usage_thread.turns.as_slice(), ); // The client needs restored usage before it starts another turn. @@ -2901,7 +2901,7 @@ impl ThreadRequestProcessor { } }; - if let Some((existing_thread_id, existing_thread, source_thread)) = running_thread { + if let Some((existing_thread_id, existing_thread, mut source_thread)) = running_thread { let existing_thread_rollout_path = existing_thread.rollout_path(); let active_path = existing_thread_rollout_path .as_ref() @@ -2963,8 +2963,8 @@ impl ThreadRequestProcessor { should_redact_thread_resume_payloads(app_server_client_name.as_deref()); let history_items = source_thread .history - .as_ref() - .map(|history| history.items.clone()) + .take() + .map(|history| history.items) .ok_or_else(|| { internal_error(format!( "thread {existing_thread_id} did not include persisted history" @@ -2988,10 +2988,8 @@ impl ThreadRequestProcessor { ) .await?; - let mut summary_source_thread = source_thread; - summary_source_thread.history = None; let mut thread_summary = self.stored_thread_to_api_thread( - summary_source_thread, + source_thread, config_snapshot.model_provider_id.as_str(), /*include_turns*/ false, ); @@ -3060,11 +3058,11 @@ impl ThreadRequestProcessor { thread_id: &str, path: Option<&PathBuf>, ) -> Result<(InitialHistory, StoredThread), JSONRPCErrorError> { - let stored_thread = self + let mut stored_thread = self .read_stored_thread_for_resume(thread_id, path, /*include_history*/ true) .await?; let history = self - .stored_thread_to_initial_history(&stored_thread) + .stored_thread_to_initial_history(&mut stored_thread) .await?; Ok((history, stored_thread)) } @@ -3112,13 +3110,13 @@ impl ThreadRequestProcessor { #[tracing::instrument(level = "trace", skip_all)] async fn stored_thread_to_initial_history( &self, - stored_thread: &StoredThread, + stored_thread: &mut StoredThread, ) -> Result { let thread_id = stored_thread.thread_id; let history = stored_thread .history - .as_ref() - .map(|history| history.items.clone()) + .take() + .map(|history| history.items) .ok_or_else(|| { internal_error(format!( "thread {thread_id} did not include persisted history" @@ -3126,7 +3124,7 @@ impl ThreadRequestProcessor { })?; Ok(InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history, + history: Arc::new(history), rollout_path: stored_thread.rollout_path.clone(), })) } @@ -3255,7 +3253,7 @@ impl ThreadRequestProcessor { let history_items = thread_history.get_rollout_items(); populate_thread_turns_from_history( &mut thread, - &history_items, + history_items, /*active_turn*/ None, ); } @@ -3313,7 +3311,7 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } - let source_thread = self + let mut source_thread = self .read_stored_thread_for_resume(&thread_id, path.as_ref(), /*include_history*/ true) .await?; let source_thread_id = source_thread.thread_id; @@ -3323,8 +3321,8 @@ impl ThreadRequestProcessor { .and_then(codex_core::util::normalize_thread_name); let history_items = source_thread .history - .as_ref() - .map(|history| history.items.clone()) + .take() + .map(|history| Arc::new(history.items)) .ok_or_else(|| { internal_error(format!( "thread {source_thread_id} did not include persisted history" @@ -3391,7 +3389,7 @@ impl ThreadRequestProcessor { config, InitialHistory::Resumed(ResumedHistory { conversation_id: source_thread_id, - history: history_items.clone(), + history: Arc::clone(&history_items), rollout_path: source_thread.rollout_path.clone(), }), thread_source.map(Into::into), diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 4b6e0fda3a..e0d79a0c3b 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1237,7 +1237,7 @@ impl TurnRequestProcessor { config.clone(), InitialHistory::Resumed(ResumedHistory { conversation_id: parent_thread_id, - history: parent_history.items, + history: Arc::new(parent_history.items), rollout_path: parent_thread.rollout_path(), }), /*thread_source*/ None, diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 37a3a8105e..e208b418d3 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -143,7 +143,7 @@ impl AgentControl { .items; let initial_history = InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history, + history: Arc::new(history), rollout_path: stored_thread.rollout_path, }); if initial_history.get_multi_agent_version() != Some(MultiAgentVersion::V2) { @@ -626,7 +626,7 @@ impl AgentControl { .items; let initial_history = InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history, + history: Arc::new(history), rollout_path: stored_thread.rollout_path, }); let parent_thread_id = stored_thread.parent_thread_id; diff --git a/codex-rs/core/src/session/rollout_reconstruction.rs b/codex-rs/core/src/session/rollout_reconstruction.rs index bf534c1e8a..6989ff80d0 100644 --- a/codex-rs/core/src/session/rollout_reconstruction.rs +++ b/codex-rs/core/src/session/rollout_reconstruction.rs @@ -355,7 +355,7 @@ impl Session { id: None, }); RolloutReconstruction { - history: history.raw_items().to_vec(), + history: history.into_raw_items(), previous_turn_settings, reference_context_item, window_number: window.number, diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index b28bd37c79..828c078586 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -69,7 +69,9 @@ async fn record_initial_history_reconstructs_typed_inter_agent_message() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![RolloutItem::InterAgentCommunication(communication.clone())], + history: Arc::new(vec![RolloutItem::InterAgentCommunication( + communication.clone(), + )]), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -112,7 +114,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -189,7 +191,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -757,7 +759,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -841,7 +843,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -871,7 +873,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_seed_referenc session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -898,7 +900,7 @@ async fn record_initial_history_resumed_does_not_seed_reference_context_item_aft session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1070,7 +1072,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1215,7 +1217,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1338,7 +1340,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1454,7 +1456,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1505,7 +1507,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_ session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1633,7 +1635,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 52bde2cc4a..a76cacabf2 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1697,7 +1697,7 @@ async fn record_initial_history_reconstructs_resumed_transcript() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1862,7 +1862,7 @@ async fn prepares_resumed_history_before_installing_it() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![RolloutItem::ResponseItem(resumed_item)], + history: Arc::new(vec![RolloutItem::ResponseItem(resumed_item)]), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -1905,7 +1905,7 @@ fn resolve_multi_agent_version_handles_unset_and_legacy_history() { resolve_multi_agent_version( &InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: Arc::new(Vec::new()), rollout_path: None, }), /*inherited_multi_agent_version*/ None, @@ -1916,7 +1916,7 @@ fn resolve_multi_agent_version_handles_unset_and_legacy_history() { resolve_multi_agent_version( &InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: Arc::new(Vec::new()), rollout_path: None, }), Some(MultiAgentVersion::V2), @@ -1927,10 +1927,10 @@ fn resolve_multi_agent_version_handles_unset_and_legacy_history() { resolve_multi_agent_version( &InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: vec![session_meta_item( + history: Arc::new(vec![session_meta_item( thread_id, Some(MultiAgentVersion::Disabled) - )], + )]), rollout_path: None, }), Some(MultiAgentVersion::V2), @@ -1991,7 +1991,7 @@ async fn resumed_history_injects_initial_context_on_first_context_update_only() session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -2085,7 +2085,7 @@ async fn record_initial_history_seeds_token_info_from_rollout() { session .record_initial_history(InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: rollout_items, + history: Arc::new(rollout_items), rollout_path: Some(PathBuf::from("/tmp/resume.jsonl")), })) .await; @@ -5508,7 +5508,7 @@ async fn resumed_root_session_uses_thread_id_as_session_id() { let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: Vec::new(), + history: Arc::new(Vec::new()), rollout_path: None, }), SessionSource::Exec, @@ -5543,7 +5543,7 @@ async fn resumed_subagent_session_restores_persisted_session_id() { let (session, rx_event) = make_session_with_history_source_and_agent_control_and_rx( InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: vec![RolloutItem::SessionMeta(SessionMetaLine { + history: Arc::new(vec![RolloutItem::SessionMeta(SessionMetaLine { meta: SessionMeta { session_id: parent_session_id, id: thread_id, @@ -5551,7 +5551,7 @@ async fn resumed_subagent_session_restores_persisted_session_id() { ..SessionMeta::default() }, git: None, - })], + })]), rollout_path: None, }), session_source, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index deba2864b6..816df89c39 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1602,7 +1602,7 @@ fn stored_thread_to_initial_history( })?; Ok(InitialHistory::Resumed(ResumedHistory { conversation_id: thread_id, - history: history.items, + history: Arc::new(history.items), rollout_path: rollout_path.or(stored_thread.rollout_path), })) } @@ -1639,7 +1639,7 @@ fn truncate_before_nth_user_message( n: usize, snapshot_state: &SnapshotTurnState, ) -> InitialHistory { - let items: Vec = history.get_rollout_items(); + let items = history.get_rollout_items().to_vec(); let user_positions = truncation::user_message_positions_in_rollout(&items); let rolled = if snapshot_state.ends_mid_turn && n >= user_positions.len() { if let Some(cut_idx) = snapshot_state @@ -1671,7 +1671,7 @@ struct SnapshotTurnState { fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { let rollout_items = history.get_rollout_items(); let mut builder = ThreadHistoryBuilder::new(); - for item in &rollout_items { + for item in rollout_items { builder.handle_rollout_item(item); } let active_turn_id = builder.active_turn_id_if_explicit(); @@ -1695,7 +1695,7 @@ fn snapshot_turn_state(history: &InitialHistory) -> SnapshotTurnState { }; } - let Some(last_user_position) = truncation::user_message_positions_in_rollout(&rollout_items) + let Some(last_user_position) = truncation::user_message_positions_in_rollout(rollout_items) .last() .copied() else { @@ -1736,7 +1736,9 @@ fn fork_history_from_snapshot( InitialHistory::New => InitialHistory::New, InitialHistory::Cleared => InitialHistory::Cleared, InitialHistory::Forked(history) => InitialHistory::Forked(history), - InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history), + InitialHistory::Resumed(resumed) => { + InitialHistory::Forked(Arc::unwrap_or_clone(resumed.history)) + } }; if snapshot_state.ends_mid_turn { append_interrupted_boundary( @@ -1782,12 +1784,13 @@ fn append_interrupted_boundary( history.push(aborted_event); InitialHistory::Forked(history) } - InitialHistory::Resumed(mut resumed) => { + InitialHistory::Resumed(resumed) => { + let mut history = Arc::unwrap_or_clone(resumed.history); if let Some(marker) = interrupted_turn_history_marker(interrupted_marker) { - resumed.history.push(RolloutItem::ResponseItem(marker)); + history.push(RolloutItem::ResponseItem(marker)); } - resumed.history.push(aborted_event); - InitialHistory::Forked(resumed.history) + history.push(aborted_event); + InitialHistory::Forked(history) } } } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index afb103b883..6375bd914c 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -116,7 +116,7 @@ fn truncates_before_requested_user_message() { RolloutItem::ResponseItem(items[2].clone()), ]; assert_eq!( - serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(got_items).unwrap(), serde_json::to_value(&expected_items).unwrap() ); @@ -256,7 +256,7 @@ async fn ignores_session_prefix_messages_when_truncating() { ]; assert_eq!( - serde_json::to_value(&got_items).unwrap(), + serde_json::to_value(got_items).unwrap(), serde_json::to_value(&expected).unwrap() ); } @@ -963,7 +963,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() { config.clone(), InitialHistory::Resumed(ResumedHistory { conversation_id: source.thread_id, - history: vec![RolloutItem::ResponseItem(user_msg("hello"))], + history: Arc::new(vec![RolloutItem::ResponseItem(user_msg("hello"))]), rollout_path: Some(rollout_path.clone()), }), auth_manager.clone(), @@ -1315,7 +1315,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor assert!(!snapshot_turn_state(&history).ends_mid_turn); let rollout_items: Vec<_> = history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem( @@ -1435,7 +1435,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { .expect("read forked rollout history"); let rollout_items: Vec<_> = history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); @@ -1522,7 +1522,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ let forked_rollout_items: Vec<_> = history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); let interrupted_marker_json = serde_json::to_value(RolloutItem::ResponseItem( @@ -1560,7 +1560,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ .expect("read re-forked rollout history"); let reforked_rollout_items: Vec<_> = reforked_history .get_rollout_items() - .into_iter() + .iter() .filter(|item| !matches!(item, RolloutItem::SessionMeta(_))) .collect(); diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index d51ba7d2f6..cc1d4e9177 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::parse_turn_item; @@ -196,7 +198,7 @@ async fn fork_thread_from_history_does_not_require_source_rollout_path() { test.config.clone(), InitialHistory::Resumed(ResumedHistory { conversation_id: test.session_configured.thread_id, - history: source_items.clone(), + history: Arc::new(source_items.clone()), rollout_path: None, }), /*thread_source*/ None, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index 073e6d8b49..50395b2ecb 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -1,5 +1,7 @@ #![allow(clippy::unwrap_used)] +use std::sync::Arc; + use codex_core::NewThread; use codex_login::CodexAuth; use codex_protocol::ThreadId; @@ -51,7 +53,7 @@ fn resume_history( InitialHistory::Resumed(ResumedHistory { conversation_id: ThreadId::default(), - history: vec![ + history: Arc::new(vec![ RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_id: turn_id.clone(), trace_id: None, @@ -75,7 +77,7 @@ fn resume_history( duration_ms: None, time_to_first_token_ms: None, })), - ], + ]), rollout_path: Some(rollout_path.to_path_buf()), }) } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index e1fe605462..8a678281d2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -10,6 +10,7 @@ use std::ops::Mul; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; +use std::sync::Arc; use std::time::Duration; use strum_macros::EnumIter; @@ -2460,7 +2461,7 @@ pub struct ConversationPathResponseEvent { #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ResumedHistory { pub conversation_id: ThreadId, - pub history: Vec, + pub history: Arc>, pub rollout_path: Option, } @@ -2505,11 +2506,11 @@ impl InitialHistory { } } - pub fn get_rollout_items(&self) -> Vec { + pub fn get_rollout_items(&self) -> &[RolloutItem] { match self { - InitialHistory::New | InitialHistory::Cleared => Vec::new(), - InitialHistory::Resumed(resumed) => resumed.history.clone(), - InitialHistory::Forked(items) => items.clone(), + InitialHistory::New | InitialHistory::Cleared => &[], + InitialHistory::Resumed(resumed) => &resumed.history, + InitialHistory::Forked(items) => items, } } diff --git a/codex-rs/rollout/src/recorder.rs b/codex-rs/rollout/src/recorder.rs index 084acb0d73..3229f5450a 100644 --- a/codex-rs/rollout/src/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -947,7 +947,7 @@ impl RolloutRecorder { info!("Resumed rollout successfully from {path:?}"); Ok(InitialHistory::Resumed(ResumedHistory { conversation_id, - history: items, + history: Arc::new(items), rollout_path: Some(compression::plain_rollout_path(path)), })) } diff --git a/codex-rs/thread-store/src/in_memory.rs b/codex-rs/thread-store/src/in_memory.rs index a93e3644c0..84e0484f78 100644 --- a/codex-rs/thread-store/src/in_memory.rs +++ b/codex-rs/thread-store/src/in_memory.rs @@ -266,7 +266,9 @@ impl InMemoryThreadStore { let mut state = self.state.lock().await; state.calls.resume_thread += 1; if let Some(history) = params.history { - state.histories.insert(params.thread_id, history); + state + .histories + .insert(params.thread_id, Arc::unwrap_or_clone(history)); } else { state.histories.entry(params.thread_id).or_default(); } diff --git a/codex-rs/thread-store/src/live_thread.rs b/codex-rs/thread-store/src/live_thread.rs index 6b94807511..da3d092da5 100644 --- a/codex-rs/thread-store/src/live_thread.rs +++ b/codex-rs/thread-store/src/live_thread.rs @@ -100,12 +100,13 @@ impl LiveThread { pub async fn resume( thread_store: Arc, - mut params: ResumeThreadParams, + params: ResumeThreadParams, ) -> ThreadStoreResult { let thread_id = params.thread_id; let should_load_history = params.history.is_none(); let include_archived = params.include_archived; - thread_store.resume_thread(params.clone()).await?; + let mut metadata_sync = ThreadMetadataSync::for_resume(¶ms); + thread_store.resume_thread(params).await?; if should_load_history { match thread_store .load_history(LoadThreadHistoryParams { @@ -114,7 +115,7 @@ impl LiveThread { }) .await { - Ok(history) => params.history = Some(history.items), + Ok(history) => metadata_sync.record_resume_history(&history.items), Err(err) => { if let Err(discard_err) = thread_store.discard_thread(thread_id).await { warn!( @@ -125,7 +126,6 @@ impl LiveThread { } } } - let metadata_sync = ThreadMetadataSync::for_resume(¶ms); Ok(Self { thread_id, thread_store, diff --git a/codex-rs/thread-store/src/thread_metadata_sync.rs b/codex-rs/thread-store/src/thread_metadata_sync.rs index 7940f1c80f..852f679126 100644 --- a/codex-rs/thread-store/src/thread_metadata_sync.rs +++ b/codex-rs/thread-store/src/thread_metadata_sync.rs @@ -1,3 +1,5 @@ +#[cfg(test)] +use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -108,13 +110,17 @@ impl ThreadMetadataSync { defer_resume_update_until_append: false, }; if let Some(history) = params.history.as_deref() { - let update = sync.observe_resume_history(history); - sync.merge_pending_update(update); - sync.defer_resume_update_until_append = sync.pending_update.is_some(); + sync.record_resume_history(history); } sync } + pub(crate) fn record_resume_history(&mut self, history: &[RolloutItem]) { + let update = self.observe_resume_history(history); + self.merge_pending_update(update); + self.defer_resume_update_until_append = self.pending_update.is_some(); + } + pub(crate) fn take_pending_update(&self) -> Option { self.pending_update .clone() @@ -555,7 +561,7 @@ mod tests { ResumeThreadParams { thread_id, rollout_path: None, - history: Some(history), + history: Some(Arc::new(history)), include_archived: false, metadata: ThreadPersistenceMetadata { cwd: None, diff --git a/codex-rs/thread-store/src/types.rs b/codex-rs/thread-store/src/types.rs index 74412bb9cd..2a82e42511 100644 --- a/codex-rs/thread-store/src/types.rs +++ b/codex-rs/thread-store/src/types.rs @@ -1,4 +1,5 @@ use std::path::PathBuf; +use std::sync::Arc; use chrono::DateTime; use chrono::Utc; @@ -96,7 +97,7 @@ pub struct ResumeThreadParams { /// Known local rollout path when the caller resumed from a specific file. pub rollout_path: Option, /// Known replay history for the resumed thread, if already loaded by the caller. - pub history: Option>, + pub history: Option>>, /// Whether archived threads may be reopened. pub include_archived: bool, /// Metadata for future writes appended to the resumed live thread. From bbe100689073815876d330e56a200217a96a9e35 Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 15:25:28 +0100 Subject: [PATCH 85/86] Update rmcp to 1.8.0 (#29634) ## Summary - Update `rmcp` and `rmcp-macros` from 1.7.0 to 1.8.0. - Adapt to the new shared `peer_info` return type. - Box OAuth status discovery at the MCP boundary to keep the expanded future type from overflowing Rust's trait recursion limit. This brings in custom OAuth HTTP client support from [modelcontextprotocol/rust-sdk#908](https://github.com/modelcontextprotocol/rust-sdk/pull/908). --- MODULE.bazel.lock | 4 ++-- codex-rs/Cargo.lock | 12 ++++++------ codex-rs/Cargo.toml | 2 +- codex-rs/codex-mcp/src/mcp/auth.rs | 2 ++ codex-rs/rmcp-client/src/rmcp_client.rs | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7cc8d83419..8ae5bef17c 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1449,8 +1449,8 @@ "resolv-conf_0.7.6": "{\"dependencies\":[],\"features\":{\"system\":[]}}", "rfc6979_0.4.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"hex-literal\",\"req\":\"^0.3\"},{\"default_features\":false,\"features\":[\"reset\"],\"name\":\"hmac\",\"req\":\"^0.12\"},{\"kind\":\"dev\",\"name\":\"sha2\",\"req\":\"^0.10\"},{\"default_features\":false,\"name\":\"subtle\",\"req\":\"^2\"}],\"features\":{}}", "ring_0.17.14": "{\"dependencies\":[{\"default_features\":false,\"kind\":\"build\",\"name\":\"cc\",\"req\":\"^1.2.8\"},{\"default_features\":false,\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"getrandom\",\"req\":\"^0.2.10\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(all(any(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), all(target_arch = \\\"arm\\\", target_endian = \\\"little\\\")), any(target_os = \\\"android\\\", target_os = \\\"linux\\\")))\"},{\"default_features\":false,\"name\":\"libc\",\"req\":\"^0.2.155\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_vendor = \\\"apple\\\", any(target_os = \\\"ios\\\", target_os = \\\"macos\\\", target_os = \\\"tvos\\\", target_os = \\\"visionos\\\", target_os = \\\"watchos\\\")))\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"libc\",\"req\":\"^0.2.148\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"name\":\"untrusted\",\"req\":\"^0.9\"},{\"default_features\":false,\"features\":[\"std\"],\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.37\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", target_os = \\\"unknown\\\"))\"},{\"features\":[\"Win32_Foundation\",\"Win32_System_Threading\"],\"name\":\"windows-sys\",\"req\":\"^0.52\",\"target\":\"cfg(all(all(target_arch = \\\"aarch64\\\", target_endian = \\\"little\\\"), target_os = \\\"windows\\\"))\"}],\"features\":{\"alloc\":[],\"default\":[\"alloc\",\"dev_urandom_fallback\"],\"dev_urandom_fallback\":[],\"less-safe-getrandom-custom-or-rdrand\":[],\"less-safe-getrandom-espidf\":[],\"slow_tests\":[],\"std\":[\"alloc\"],\"test_logging\":[],\"unstable-testing-arm-no-hw\":[],\"unstable-testing-arm-no-neon\":[],\"wasm32_unknown_unknown_js\":[\"getrandom/js\"]}}", - "rmcp-macros_1.7.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"local\":[]}}", - "rmcp_1.7.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.7.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", + "rmcp-macros_1.8.0": "{\"dependencies\":[{\"name\":\"darling\",\"req\":\"^0.23\"},{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"features\":[\"full\"],\"name\":\"syn\",\"req\":\"^2\"}],\"features\":{\"local\":[]}}", + "rmcp_1.8.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"anyhow\",\"req\":\"^1.0\"},{\"name\":\"async-trait\",\"req\":\"^0.1.89\"},{\"kind\":\"dev\",\"name\":\"async-trait\",\"req\":\"^0.1\"},{\"default_features\":false,\"features\":[\"http1\",\"tokio\"],\"kind\":\"dev\",\"name\":\"axum\",\"req\":\"^0.8\"},{\"name\":\"base64\",\"optional\":true,\"req\":\"^0.22\"},{\"name\":\"bytes\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"features\":[\"serde\",\"clock\",\"std\",\"oldtime\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\"))\"},{\"default_features\":false,\"features\":[\"serde\",\"now\"],\"name\":\"chrono\",\"req\":\"^0.4.38\",\"target\":\"cfg(not(all(target_family = \\\"wasm\\\", target_os = \\\"unknown\\\")))\"},{\"name\":\"futures\",\"req\":\"^0.3\"},{\"name\":\"http\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"http-body-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"client\",\"http1\"],\"name\":\"hyper\",\"optional\":true,\"req\":\"^1\"},{\"features\":[\"server\",\"http1\"],\"kind\":\"dev\",\"name\":\"hyper\",\"req\":\"^1\"},{\"features\":[\"tokio\"],\"name\":\"hyper-util\",\"optional\":true,\"req\":\"^0.1\"},{\"features\":[\"tokio\"],\"kind\":\"dev\",\"name\":\"hyper-util\",\"req\":\"^0.1\"},{\"name\":\"jsonwebtoken\",\"optional\":true,\"req\":\"^10\"},{\"default_features\":false,\"name\":\"oauth2\",\"optional\":true,\"req\":\"^5.0\"},{\"name\":\"pastey\",\"optional\":true,\"req\":\"^0.2.0\"},{\"name\":\"pin-project-lite\",\"req\":\"^0.2\"},{\"features\":[\"tokio1\"],\"name\":\"process-wrap\",\"optional\":true,\"req\":\"^9.0\"},{\"name\":\"rand\",\"optional\":true,\"req\":\"^0.10\"},{\"default_features\":false,\"features\":[\"json\",\"stream\"],\"name\":\"reqwest\",\"optional\":true,\"req\":\"^0.13.2\"},{\"name\":\"rmcp-macros\",\"optional\":true,\"req\":\"^1.8.0\"},{\"features\":[\"chrono04\"],\"name\":\"schemars\",\"optional\":true,\"req\":\"^1.0\"},{\"features\":[\"chrono04\"],\"kind\":\"dev\",\"name\":\"schemars\",\"req\":\"^1.1.0\"},{\"features\":[\"derive\",\"rc\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"sse-stream\",\"optional\":true,\"req\":\"^0.2\"},{\"name\":\"thiserror\",\"req\":\"^2\"},{\"features\":[\"sync\",\"macros\",\"rt\",\"time\"],\"name\":\"tokio\",\"req\":\"^1\"},{\"features\":[\"full\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"name\":\"tokio-stream\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"tokio-util\",\"req\":\"^0.7\"},{\"name\":\"tower-service\",\"optional\":true,\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"tower-service\",\"req\":\"^0.3\"},{\"name\":\"tracing\",\"req\":\"^0.1\"},{\"features\":[\"env-filter\",\"std\",\"fmt\"],\"kind\":\"dev\",\"name\":\"tracing-subscriber\",\"req\":\"^0.3\"},{\"name\":\"url\",\"optional\":true,\"req\":\"^2.4\"},{\"kind\":\"dev\",\"name\":\"url\",\"req\":\"^2.4\"},{\"features\":[\"v4\"],\"name\":\"uuid\",\"optional\":true,\"req\":\"^1\"},{\"name\":\"which\",\"optional\":true,\"req\":\"^8\"}],\"features\":{\"__reqwest\":[\"dep:reqwest\"],\"auth\":[\"dep:oauth2\",\"__reqwest\",\"dep:url\"],\"auth-client-credentials-jwt\":[\"auth\",\"dep:jsonwebtoken\",\"uuid\"],\"client\":[\"dep:tokio-stream\"],\"client-side-sse\":[\"dep:sse-stream\",\"dep:http\"],\"default\":[\"base64\",\"macros\",\"server\"],\"elicitation\":[\"dep:url\"],\"local\":[\"rmcp-macros?/local\"],\"macros\":[\"dep:rmcp-macros\",\"dep:pastey\"],\"reqwest\":[\"__reqwest\",\"reqwest?/rustls\"],\"reqwest-native-tls\":[\"__reqwest\",\"reqwest?/native-tls\"],\"reqwest-tls-no-provider\":[\"__reqwest\",\"reqwest?/rustls-no-provider\"],\"schemars\":[\"dep:schemars\"],\"server\":[\"transport-async-rw\",\"dep:schemars\",\"dep:pastey\"],\"server-side-http\":[\"uuid\",\"dep:rand\",\"dep:tokio-stream\",\"dep:http\",\"dep:http-body\",\"dep:http-body-util\",\"dep:bytes\",\"dep:sse-stream\",\"tower\"],\"tower\":[\"dep:tower-service\"],\"transport-async-rw\":[\"tokio/io-util\",\"tokio-util/codec\"],\"transport-child-process\":[\"transport-async-rw\",\"tokio/process\",\"dep:process-wrap\"],\"transport-io\":[\"transport-async-rw\",\"tokio/io-std\"],\"transport-streamable-http-client\":[\"client-side-sse\",\"transport-worker\"],\"transport-streamable-http-client-reqwest\":[\"transport-streamable-http-client\",\"__reqwest\"],\"transport-streamable-http-client-unix-socket\":[\"transport-streamable-http-client\",\"dep:hyper\",\"dep:hyper-util\",\"dep:http-body-util\",\"dep:http\",\"dep:bytes\",\"tokio/net\"],\"transport-streamable-http-server\":[\"transport-streamable-http-server-session\",\"server-side-http\",\"transport-worker\"],\"transport-streamable-http-server-session\":[\"transport-async-rw\",\"dep:tokio-stream\"],\"transport-worker\":[\"dep:tokio-stream\"],\"which-command\":[\"transport-child-process\",\"dep:which\"]}}", "rtrb_0.3.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.8\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"crossbeam-utils\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.10\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "rust-embed-impl_8.11.0": "{\"dependencies\":[{\"name\":\"proc-macro2\",\"req\":\"^1\"},{\"name\":\"quote\",\"req\":\"^1\"},{\"name\":\"rust-embed-utils\",\"req\":\"^8.11.0\"},{\"name\":\"shellexpand\",\"optional\":true,\"req\":\"^3\"},{\"default_features\":false,\"features\":[\"derive\",\"parsing\",\"proc-macro\",\"printing\"],\"name\":\"syn\",\"req\":\"^2\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"compression\":[],\"debug-embed\":[],\"deterministic-timestamps\":[],\"include-exclude\":[\"rust-embed-utils/include-exclude\"],\"interpolate-folder-path\":[\"shellexpand\"],\"mime-guess\":[\"rust-embed-utils/mime-guess\"]}}", "rust-embed-utils_8.11.0": "{\"dependencies\":[{\"name\":\"globset\",\"optional\":true,\"req\":\"^0.4.8\"},{\"name\":\"mime_guess\",\"optional\":true,\"req\":\"^2.0.4\"},{\"name\":\"sha2\",\"req\":\"^0.10.5\"},{\"name\":\"walkdir\",\"req\":\"^2.3.1\"}],\"features\":{\"debug-embed\":[],\"include-exclude\":[\"globset\"],\"mime-guess\":[\"mime_guess\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 65d581db6f..d892aea620 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -9449,7 +9449,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -10535,7 +10535,7 @@ version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "itertools 0.14.0", "log", "multimap", @@ -11455,9 +11455,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1d1f571c72940a19d9532fe52dbea8bc9912bf1d766c2970bb824056b86f3f59" dependencies = [ "async-trait", "base64 0.22.1", @@ -11490,9 +11490,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "1aad0035b69380782d78ea95b508327e6deaa2235909053e596eea8f27b5e1d5" dependencies = [ "darling 0.23.0", "proc-macro2", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3290c4ea99..17c2d1561b 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -368,7 +368,7 @@ rcgen = { version = "0.14.7", default-features = false, features = [ regex = "1.12.3" regex-lite = "0.1.8" reqwest = { version = "0.12", features = ["cookies"] } -rmcp = { version = "1.7.0", default-features = false } +rmcp = { version = "1.8.0", default-features = false } runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" } rustls = { version = "0.23", default-features = false, features = [ "aws_lc_rs", diff --git a/codex-rs/codex-mcp/src/mcp/auth.rs b/codex-rs/codex-mcp/src/mcp/auth.rs index ec67f07a82..78d55fb789 100644 --- a/codex-rs/codex-mcp/src/mcp/auth.rs +++ b/codex-rs/codex-mcp/src/mcp/auth.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::McpAuthStatus; use codex_rmcp_client::OAuthProviderError; use codex_rmcp_client::determine_streamable_http_auth_status; use codex_rmcp_client::discover_streamable_http_oauth; +use futures::FutureExt; use futures::future::join_all; use tracing::warn; @@ -217,6 +218,7 @@ async fn compute_auth_status( store_mode, keyring_backend_kind, ) + .boxed() .await } } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index f5c7da5297..003f65cd93 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -455,7 +455,7 @@ impl RmcpClient { .peer() .peer_info() .ok_or_else(|| anyhow!("handshake succeeded but server info was missing"))?; - let initialize_result = initialize_result_rmcp.clone(); + let initialize_result = initialize_result_rmcp.as_ref().clone(); { let mut initialize_context = self.initialize_context.lock().await; From c553cea9eae2648d74342e21ce68233893e81d2c Mon Sep 17 00:00:00 2001 From: jif Date: Tue, 23 Jun 2026 16:19:19 +0100 Subject: [PATCH 86/86] Update vulnerable Hono and fast-uri dependencies (#29650) ## Summary - Pin `hono` to 4.12.25, the first patched release for the recent Hono security advisories. - Pin `fast-uri` to 3.1.1 to fix the percent-encoded path traversal vulnerability. - Refresh `pnpm-lock.yaml` with only those dependency updates. `hono` 4.12.25 is used instead of the newer 4.12.27 because the repository requires dependencies to be at least seven days old. --- package.json | 2 ++ pnpm-lock.yaml | 26 ++++++++++++++------------ 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index c510d787de..ddda581bf2 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,11 @@ "@modelcontextprotocol/sdk": "1.26.0", "braces": "^3.0.3", "esbuild": "0.28.1", + "fast-uri": "3.1.1", "flatted": "3.4.2", "glob@10.4.5": "10.5.0", "handlebars": "4.7.9", + "hono": "4.12.25", "micromatch": "^4.0.8", "minimatch@3.1.2": "3.1.4", "minimatch@9.0.5": "9.0.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a363b29edc..2d4c6f3497 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,11 @@ overrides: '@modelcontextprotocol/sdk': 1.26.0 braces: ^3.0.3 esbuild: 0.28.1 + fast-uri: 3.1.1 flatted: 3.4.2 glob@10.4.5: 10.5.0 handlebars: 4.7.9 + hono: 4.12.25 micromatch: ^4.0.8 minimatch@3.1.2: 3.1.4 minimatch@9.0.5: 9.0.7 @@ -455,7 +457,7 @@ packages: resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} peerDependencies: - hono: ^4 + hono: 4.12.25 '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} @@ -1341,8 +1343,8 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-uri@3.1.1: + resolution: {integrity: sha512-h2r7rcm6Ee/J8o0LD5djLuFVcfbZxhvho4vvsbeV0aMvXjUgqv4YpxpkEx0d68l6+IleVfLAdVEfhR7QNMkGHQ==} fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} @@ -1484,8 +1486,8 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} - hono@4.12.12: - resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} engines: {node: '>=16.9.0'} html-escaper@2.0.2: @@ -2873,9 +2875,9 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 - '@hono/node-server@1.19.13(hono@4.12.12)': + '@hono/node-server@1.19.13(hono@4.12.25)': dependencies: - hono: 4.12.12 + hono: 4.12.25 '@humanfs/core@0.19.1': {} @@ -3095,7 +3097,7 @@ snapshots: '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.13(hono@4.12.12) + '@hono/node-server': 1.19.13(hono@4.12.25) ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) content-type: 1.0.5 @@ -3105,7 +3107,7 @@ snapshots: eventsource-parser: 3.0.6 express: 5.2.1 express-rate-limit: 8.3.2(express@5.2.1) - hono: 4.12.12 + hono: 4.12.25 jose: 6.1.3 json-schema-typed: 8.0.2 pkce-challenge: 5.0.0 @@ -3401,7 +3403,7 @@ snapshots: ajv@8.17.1: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 + fast-uri: 3.1.1 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3905,7 +3907,7 @@ snapshots: fast-levenshtein@2.0.6: {} - fast-uri@3.1.0: {} + fast-uri@3.1.1: {} fastq@1.19.1: dependencies: @@ -4054,7 +4056,7 @@ snapshots: dependencies: function-bind: 1.1.2 - hono@4.12.12: {} + hono@4.12.25: {} html-escaper@2.0.2: {}