From 9c5be7e1d500faeca63b467a73bccf83d420e495 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 6 Jul 2026 07:05:40 -0700 Subject: [PATCH 01/76] Make plugin guidance react to environment readiness (#30223) ## Why Generic plugin guidance is currently emitted only with initial context from host plugin state. An executor-selected plugin can become available later in the same turn, making its skills and tools usable without ever telling the model how plugin capabilities should be used. ## What - project every ready selected plugin package, including skill-only plugins - carry plugin availability with the exact MCP runtime projection while preserving MCP manager reuse when servers and connectors are unchanged - move generic plugin guidance from the static initial-context path into persisted World State - recognize legacy and retained plugin fragments so resume and compaction do not duplicate guidance ## Testing - `just test -p codex-mcp-extension` - `just test -p codex-core plugins_instructions` - `just test -p codex-core plugin_availability_change_reuses_the_mcp_manager` - `just test -p codex-app-server --test all selected_capabilit` --- .../suite/v2/selected_capability_stack.rs | 17 +++ codex-rs/core/src/config/mod.rs | 9 ++ .../context/available_plugins_instructions.rs | 17 +-- codex-rs/core/src/context/world_state/mod.rs | 18 ++- .../world_state/plugins_instructions.rs | 55 +++++++++ .../world_state/plugins_instructions_tests.rs | 72 ++++++++++++ .../core/src/context_manager/history_tests.rs | 4 + codex-rs/core/src/event_mapping.rs | 2 + codex-rs/core/src/mcp.rs | 50 +++++--- codex-rs/core/src/plugins/render.rs | 2 +- codex-rs/core/src/session/mcp.rs | 26 +++-- codex-rs/core/src/session/mcp_runtime.rs | 10 +- codex-rs/core/src/session/mod.rs | 6 - codex-rs/core/src/session/session.rs | 19 +-- codex-rs/core/src/session/tests.rs | 85 ++++++++++++++ codex-rs/core/src/session/world_state.rs | 4 + codex-rs/core/src/state/service.rs | 12 +- codex-rs/core/src/thread_manager_tests.rs | 4 +- .../ext/extension-api/src/contributors/mcp.rs | 4 +- codex-rs/ext/mcp/src/executor_plugin.rs | 13 +-- codex-rs/ext/mcp/tests/executor_plugin_mcp.rs | 108 ++++++++++++++---- 21 files changed, 443 insertions(+), 94 deletions(-) create mode 100644 codex-rs/core/src/context/world_state/plugins_instructions.rs create mode 100644 codex-rs/core/src/context/world_state/plugins_instructions_tests.rs diff --git a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs index 6a70116aef..22153f1af7 100644 --- a/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs +++ b/codex-rs/app-server/tests/suite/v2/selected_capability_stack.rs @@ -28,6 +28,7 @@ use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; +use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_path_uri::PathUri; use core_test_support::process::wait_for_pid_file; @@ -235,7 +236,9 @@ async fn selected_capability_stack_tracks_environment_availability_and_resume() for request in &requests[1..4] { assert_selected_skill_is_injected(request, /*expected_count*/ 1); assert_selected_plugin_tools(request); + assert_plugin_guidance_count(request, /*expected_count*/ 1); } + assert_plugin_guidance_count(&requests[4], /*expected_count*/ 1); assert_selected_skill_is_injected(&requests[5], /*expected_count*/ 2); assert_selected_plugin_tools(&requests[5]); let output = requests[2].function_call_output(MCP_CALL_ID); @@ -398,7 +401,9 @@ async fn selected_capabilities_become_available_between_samples_in_one_turn() -> assert_eq!(3, requests.len()); assert_selected_skill_catalog_available(&requests[1]); assert_selected_plugin_tools(&requests[1]); + assert_plugin_guidance_count(&requests[1], /*expected_count*/ 1); assert_selected_plugin_tools(&requests[2]); + assert_plugin_guidance_count(&requests[2], /*expected_count*/ 1); let output = requests[2].function_call_output(MCP_CALL_ID); let output = output["output"] .as_str() @@ -537,6 +542,7 @@ fn assert_selected_capabilities_absent(request: &ResponsesRequest) { .all(|text| !text.contains(SKILL_DESCRIPTION)) ); assert_selected_plugin_tools_absent(request); + assert_plugin_guidance_count(request, /*expected_count*/ 0); } fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) { @@ -555,6 +561,17 @@ fn assert_selected_plugin_tools_absent(request: &ResponsesRequest) { ); } +fn assert_plugin_guidance_count(request: &ResponsesRequest, expected_count: usize) { + assert_eq!( + expected_count, + request + .message_input_texts("developer") + .into_iter() + .filter(|text| text.starts_with(PLUGINS_INSTRUCTIONS_OPEN_TAG)) + .count() + ); +} + fn assert_selected_skill_is_injected(request: &ResponsesRequest, expected_count: usize) { assert_selected_skill_catalog_available(request); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index e29cb3875c..54fbb97f69 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -52,6 +52,7 @@ use codex_config::types::TuiNotificationSettings; use codex_config::types::TuiPetAnchor; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; +use codex_core_plugins::PluginLoadOutcome; use codex_core_plugins::PluginsConfigInput; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; @@ -1528,6 +1529,14 @@ impl Config { ) -> McpConfig { let plugins_input = self.plugins_config_input(); let loaded_plugins = plugins_manager.plugins_for_config(&plugins_input).await; + self.to_mcp_config_with_loaded_plugins(&loaded_plugins, additional_plugin_registrations) + } + + pub(crate) fn to_mcp_config_with_loaded_plugins( + &self, + loaded_plugins: &PluginLoadOutcome, + additional_plugin_registrations: impl IntoIterator, + ) -> McpConfig { let mut catalog = ResolvedMcpCatalog::builder(); for (plugin_order, plugin) in loaded_plugins .plugins() diff --git a/codex-rs/core/src/context/available_plugins_instructions.rs b/codex-rs/core/src/context/available_plugins_instructions.rs index 683d939aa0..a31d61d7fd 100644 --- a/codex-rs/core/src/context/available_plugins_instructions.rs +++ b/codex-rs/core/src/context/available_plugins_instructions.rs @@ -1,25 +1,10 @@ -use codex_plugin::PluginCapabilitySummary; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq)] -pub(crate) struct AvailablePluginsInstructions { - plugins: Vec, -} - -impl AvailablePluginsInstructions { - pub(crate) fn from_plugins(plugins: &[PluginCapabilitySummary]) -> Option { - if plugins.is_empty() { - return None; - } - - Some(Self { - plugins: plugins.to_vec(), - }) - } -} +pub(crate) struct AvailablePluginsInstructions; impl ContextualUserFragment for AvailablePluginsInstructions { fn role(&self) -> &'static str { diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index ea82ddd11f..5dc22030ba 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -1,5 +1,6 @@ mod agents_md; mod environment; +mod plugins_instructions; use crate::context::ContextualUserFragment; use codex_extension_api::PreviousWorldStateSection; @@ -17,6 +18,7 @@ use std::fmt; pub(crate) use agents_md::AgentsMdState; pub(crate) use environment::EnvironmentsState; +pub(crate) use plugins_instructions::PluginsInstructionsState; trait ErasedWorldStateSection: Send + Sync { fn snapshot(&self) -> Option; @@ -62,11 +64,11 @@ impl ErasedWorldStateSection for S { } fn has_retained_fragment_matcher(&self) -> bool { - false + S::has_retained_fragment_matcher() } - fn matches_retained_fragment(&self, _role: &str, _text: &str) -> bool { - false + fn matches_retained_fragment(&self, role: &str, text: &str) -> bool { + S::matches_retained_fragment(role, text) } fn render_diff( @@ -183,6 +185,16 @@ pub(crate) trait WorldStateSection: Send + Sync + 'static { false } + /// Whether retained history must still contain this section's rendered fragment. + fn has_retained_fragment_matcher() -> bool { + false + } + + /// Recognizes this section's rendered fragment in retained model history. + fn matches_retained_fragment(_role: &str, _text: &str) -> bool { + false + } + fn render_diff( &self, previous: PreviousSectionState<'_, Self::Snapshot>, diff --git a/codex-rs/core/src/context/world_state/plugins_instructions.rs b/codex-rs/core/src/context/world_state/plugins_instructions.rs new file mode 100644 index 0000000000..303307317d --- /dev/null +++ b/codex-rs/core/src/context/world_state/plugins_instructions.rs @@ -0,0 +1,55 @@ +use super::PreviousSectionState; +use super::WorldStateSection; +use crate::context::AvailablePluginsInstructions; +use crate::context::ContextualUserFragment; + +/// Whether generic plugin usage guidance should be visible to the model. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct PluginsInstructionsState { + available: bool, +} + +impl PluginsInstructionsState { + pub(crate) fn new(available: bool) -> Self { + Self { available } + } +} + +impl WorldStateSection for PluginsInstructionsState { + const ID: &'static str = "plugins_instructions"; + type Snapshot = bool; + + fn snapshot(&self) -> Self::Snapshot { + self.available + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && AvailablePluginsInstructions::matches_text(text) + } + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + if !self.available + || matches!(previous, PreviousSectionState::Known(previous) if *previous) + || matches!(previous, PreviousSectionState::Unknown) + { + return None; + } + + Some(Box::new(AvailablePluginsInstructions)) + } +} + +#[cfg(test)] +#[path = "plugins_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs b/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs new file mode 100644 index 0000000000..a688ff93ca --- /dev/null +++ b/codex-rs/core/src/context/world_state/plugins_instructions_tests.rs @@ -0,0 +1,72 @@ +use super::*; +use crate::context::ContextualUserFragment; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +fn render( + state: PluginsInstructionsState, + previous: PreviousSectionState<'_, bool>, +) -> Vec { + state + .render_diff(previous) + .into_iter() + .map(|fragment| fragment.render()) + .collect() +} + +#[test] +fn renders_only_when_plugins_become_available() { + let unavailable = PluginsInstructionsState::new(/*available*/ false); + let available = PluginsInstructionsState::new(/*available*/ true); + let false_snapshot = false; + let true_snapshot = true; + + assert_eq!( + render(unavailable, PreviousSectionState::Absent), + Vec::::new() + ); + assert_eq!(render(available, PreviousSectionState::Absent).len(), 1); + assert_eq!( + render(available, PreviousSectionState::Known(&false_snapshot)).len(), + 1 + ); + assert_eq!( + render(available, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); + assert_eq!( + render(unavailable, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); +} + +#[test] +fn legacy_guidance_is_not_injected_again() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(PluginsInstructionsState::new(/*available*/ true)); + let legacy: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions); + + assert!( + world_state + .render_history_diff(/*previous*/ None, &[legacy]) + .is_empty() + ); +} + +#[test] +fn persisted_guidance_is_restored_only_when_missing_from_history() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(PluginsInstructionsState::new(/*available*/ true)); + let snapshot = world_state.snapshot(); + let retained: ResponseItem = ContextualUserFragment::into(AvailablePluginsInstructions); + + assert_eq!( + world_state.render_history_diff(Some(&snapshot), &[]).len(), + 1 + ); + assert!( + world_state + .render_history_diff(Some(&snapshot), &[retained]) + .is_empty() + ); +} diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index cbe0209e21..df5abd4708 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -22,6 +22,7 @@ use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::InterAgentCommunication; +use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; use codex_utils_absolute_path::AbsolutePathBuf; @@ -1021,6 +1022,9 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { user_input_text_msg("turn 1 user"), assistant_msg("turn 1 assistant"), developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), + developer_msg(&format!( + "{PLUGINS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_PLUGIN_INSTRUCTIONS" + )), developer_msg("ROLLED_BACK_DEV_INSTRUCTIONS"), developer_msg("ROLLED_BACK_MULTI_AGENT_MODE"), user_input_text_msg( diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 392203c079..5e24b78d05 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -18,6 +18,7 @@ use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; +use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::REALTIME_CONVERSATION_OPEN_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::user_input::UserInput; @@ -33,6 +34,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", COLLABORATION_MODE_OPEN_TAG, MULTI_AGENT_MODE_OPEN_TAG, + PLUGINS_INSTRUCTIONS_OPEN_TAG, REALTIME_CONVERSATION_OPEN_TAG, SKILLS_INSTRUCTIONS_OPEN_TAG, "", diff --git a/codex-rs/core/src/mcp.rs b/codex-rs/core/src/mcp.rs index 45ca0d563d..835c47ff51 100644 --- a/codex-rs/core/src/mcp.rs +++ b/codex-rs/core/src/mcp.rs @@ -25,6 +25,12 @@ use codex_plugin::AppConnectorId; const LEGACY_CODEX_APPS_REGISTRATION_ID: &str = "legacy_codex_apps"; +/// MCP configuration and capability availability derived from the same inputs. +pub(crate) struct McpRuntimeProjection { + pub(crate) config: McpConfig, + pub(crate) plugins_available: bool, +} + enum OrderedMcpOverlay { Set { contributor_id: &'static str, @@ -75,6 +81,7 @@ impl McpManager { pub async fn runtime_config(&self, config: &Config) -> McpConfig { self.runtime_config_with_context(McpServerContributionContext::global(config)) .await + .config } pub(crate) async fn runtime_config_for_step( @@ -83,7 +90,7 @@ impl McpManager { thread_init: &ExtensionDataInit, thread_store: &ExtensionData, available_environment_ids: &[String], - ) -> McpConfig { + ) -> McpRuntimeProjection { self.runtime_config_with_context(McpServerContributionContext::for_step( config, thread_init, @@ -96,8 +103,9 @@ impl McpManager { async fn runtime_config_with_context( &self, context: McpServerContributionContext<'_, Config>, - ) -> McpConfig { + ) -> McpRuntimeProjection { let config = context.config(); + let mut selected_plugin_available = false; let mut selected_plugin_connector_sources = Vec::new(); let mut selected_plugin_registrations = Vec::new(); let mut overlays = Vec::new(); @@ -129,17 +137,22 @@ impl McpManager { *config, ), ), - McpServerContribution::SelectedPluginConnectors { + McpServerContribution::SelectedPluginPackage { plugin_id, plugin_display_name, connector_ids, - } => selected_plugin_connector_sources.push( - PluginConnectorSource::from_connector_ids( - plugin_id, - plugin_display_name, - connector_ids.into_iter().map(AppConnectorId), - ), - ), + } => { + selected_plugin_available = true; + if !connector_ids.is_empty() { + selected_plugin_connector_sources.push( + PluginConnectorSource::from_connector_ids( + plugin_id, + plugin_display_name, + connector_ids.into_iter().map(AppConnectorId), + ), + ); + } + } McpServerContribution::Remove { name } => { overlays.push(OrderedMcpOverlay::Remove { contributor_id: contributor.id(), @@ -152,12 +165,14 @@ impl McpManager { } } - let mut mcp_config = config - .to_mcp_config_with_plugin_registrations( - self.plugins_manager.as_ref(), - selected_plugin_registrations, - ) + let loaded_plugins = self + .plugins_manager + .plugins_for_config(&config.plugins_config_input()) .await; + let plugins_available = + selected_plugin_available || !loaded_plugins.capability_summaries().is_empty(); + let mut mcp_config = config + .to_mcp_config_with_loaded_plugins(&loaded_plugins, selected_plugin_registrations); let mut catalog = mcp_config.mcp_server_catalog.to_builder(); if mcp_config.apps_enabled { catalog.register(McpServerRegistration::from_compatibility( @@ -211,7 +226,10 @@ impl McpManager { .merged_with(&ConnectorSnapshot::from_plugin_sources( selected_plugin_connector_sources, )); - mcp_config + McpRuntimeProjection { + config: mcp_config, + plugins_available, + } } /// Returns config- and plugin-backed servers without runtime contributions. diff --git a/codex-rs/core/src/plugins/render.rs b/codex-rs/core/src/plugins/render.rs index fc197dbbea..4e7575bd62 100644 --- a/codex-rs/core/src/plugins/render.rs +++ b/codex-rs/core/src/plugins/render.rs @@ -6,7 +6,7 @@ use crate::plugins::PluginCapabilitySummary; #[cfg(test)] pub(crate) fn render_plugins_section(plugins: &[PluginCapabilitySummary]) -> Option { - AvailablePluginsInstructions::from_plugins(plugins).map(|instructions| instructions.render()) + (!plugins.is_empty()).then(|| AvailablePluginsInstructions.render()) } pub(crate) fn render_explicit_plugin_instructions( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 25d95e30ea..4cfb0a36de 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -1,4 +1,5 @@ use super::*; +use crate::mcp::McpRuntimeProjection; use codex_exec_server::ResolvedSelectedCapabilityRoot; use codex_mcp::ElicitationReviewRequest; use codex_mcp::ElicitationReviewer; @@ -92,6 +93,7 @@ impl Session { &available_environment_ids, ) .await + .config } pub(crate) async fn runtime_mcp_servers( @@ -123,7 +125,7 @@ impl Session { if current.available_environment_ids() == available_environment_ids { return current; } - let mcp_config = self + let mcp_projection = self .services .mcp_manager .runtime_config_for_step( @@ -133,6 +135,7 @@ impl Session { &available_environment_ids, ) .await; + let mcp_config = &mcp_projection.config; let changed_environment_is_used_by_mcp = mcp_config .mcp_server_catalog .configured_servers() @@ -156,6 +159,7 @@ impl Session { // replacing the live manager and restarting its processes. let runtime = Arc::new(McpRuntimeSnapshot::new( Arc::new(current.config().clone()), + mcp_projection.plugins_available, current.manager_arc(), current.runtime_context().clone(), available_environment_ids, @@ -165,7 +169,7 @@ impl Session { } self.refresh_mcp_servers_inner( turn_context, - mcp_config, + mcp_projection, environments, &available_environment_ids, Some(self.mcp_elicitation_reviewer()), @@ -309,12 +313,16 @@ impl Session { async fn refresh_mcp_servers_inner( &self, turn_context: &TurnContext, - mcp_config: McpConfig, + mcp_projection: McpRuntimeProjection, environments: &TurnEnvironmentSnapshot, available_environment_ids: &[String], elicitation_reviewer: Option, ) -> Arc { let auth = self.services.auth_manager.auth().await; + let McpRuntimeProjection { + config: mcp_config, + plugins_available, + } = mcp_projection; let mcp_config = Arc::new(mcp_config); let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config); let mcp_servers = effective_mcp_servers(&mcp_config, auth.as_ref()); @@ -376,6 +384,7 @@ impl Session { .set_elicitations_auto_deny(current_runtime.manager().elicitations_auto_deny()); self.services.publish_mcp_runtime( mcp_config, + plugins_available, mcp_runtime_context, available_environment_ids.to_vec(), refreshed_manager, @@ -448,7 +457,7 @@ impl Session { .latest_mcp_runtime() .available_environment_ids() .to_vec(); - let mut mcp_config = self + let mut mcp_projection = self .services .mcp_manager .runtime_config_for_step( @@ -458,12 +467,13 @@ impl Session { &available_environment_ids, ) .await; - mcp_config.mcp_server_catalog = mcp_config + mcp_projection.config.mcp_server_catalog = mcp_projection + .config .mcp_server_catalog .with_materialized_servers(mcp_servers); self.refresh_mcp_servers_inner( turn_context, - mcp_config, + mcp_projection, &turn_context.environments, &available_environment_ids, elicitation_reviewer, @@ -515,7 +525,7 @@ impl Session { .latest_mcp_runtime() .available_environment_ids() .to_vec(); - let mcp_config = self + let mcp_projection = self .services .mcp_manager .runtime_config_for_step( @@ -527,7 +537,7 @@ impl Session { .await; self.refresh_mcp_servers_inner( turn_context, - mcp_config, + mcp_projection, &turn_context.environments, &available_environment_ids, elicitation_reviewer, diff --git a/codex-rs/core/src/session/mcp_runtime.rs b/codex-rs/core/src/session/mcp_runtime.rs index 34cf07cc35..5e7aeffcee 100644 --- a/codex-rs/core/src/session/mcp_runtime.rs +++ b/codex-rs/core/src/session/mcp_runtime.rs @@ -5,9 +5,10 @@ use codex_mcp::McpConfig; use codex_mcp::McpConnectionManager; use codex_mcp::McpRuntimeContext; -/// MCP config, exact environment bindings, and manager used by one model request. +/// MCP config, plugin availability, exact environment bindings, and manager for one request. pub struct McpRuntimeSnapshot { config: Arc, + plugins_available: bool, manager: Arc, runtime_context: McpRuntimeContext, available_environment_ids: Vec, @@ -16,12 +17,14 @@ pub struct McpRuntimeSnapshot { impl McpRuntimeSnapshot { pub(crate) fn new( config: Arc, + plugins_available: bool, manager: Arc, runtime_context: McpRuntimeContext, available_environment_ids: Vec, ) -> Self { Self { config, + plugins_available, manager, runtime_context, available_environment_ids, @@ -32,6 +35,10 @@ impl McpRuntimeSnapshot { self.config.as_ref() } + pub(crate) fn plugins_available(&self) -> bool { + self.plugins_available + } + pub fn manager(&self) -> &McpConnectionManager { self.manager.as_ref() } @@ -86,6 +93,7 @@ impl McpRuntimeSnapshot { ); Arc::new(Self::new( Arc::new(mcp_config), + /*plugins_available*/ false, Arc::new(manager), runtime_context, Vec::new(), diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 978768ba00..560dcf5985 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -24,7 +24,6 @@ use crate::config::resolve_tool_suggest_config_from_layer_stack; use crate::connectors; use crate::context::ApprovedCommandPrefixSaved; use crate::context::AppsInstructions; -use crate::context::AvailablePluginsInstructions; use crate::context::AvailableSkillsInstructions; use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; @@ -3306,11 +3305,6 @@ impl Session { { contextual_user_sections.push(recommended_plugins.render()); } - if let Some(plugin_instructions) = - AvailablePluginsInstructions::from_plugins(loaded_plugins.capability_summaries()) - { - developer_sections.push(plugin_instructions.render()); - } let context_contributors = self.services.extensions.context_contributors().to_vec(); for contributor in &context_contributors { for fragment in contributor diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 5e7046b148..48a9ee447e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -679,7 +679,7 @@ impl Session { let mcp_runtime_context_for_auth = mcp_runtime_context.clone(); let auth_and_mcp_fut = async move { let auth = auth_manager_clone.auth().await; - let mcp_config = mcp_manager_for_mcp + let mcp_projection = mcp_manager_for_mcp .runtime_config_for_step( &config_for_mcp, mcp_thread_init_for_startup, @@ -687,8 +687,9 @@ impl Session { /*available_environment_ids*/ &[], ) .await; - let mcp_servers = codex_mcp::effective_mcp_servers(&mcp_config, auth.as_ref()); - let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(&mcp_config); + let mcp_config = &mcp_projection.config; + let mcp_servers = codex_mcp::effective_mcp_servers(mcp_config, auth.as_ref()); + let tool_plugin_provenance = codex_mcp::tool_plugin_provenance(mcp_config); let auth_statuses = compute_auth_statuses( mcp_servers.iter(), config_for_mcp.mcp_oauth_credentials_store_mode, @@ -699,7 +700,7 @@ impl Session { .await; ( auth, - mcp_config, + mcp_projection, mcp_servers, auth_statuses, tool_plugin_provenance, @@ -714,7 +715,7 @@ impl Session { let ( thread_persistence_result, state_db_ctx, - (auth, mcp_config, mcp_servers, auth_statuses, tool_plugin_provenance), + (auth, mcp_projection, mcp_servers, auth_statuses, tool_plugin_provenance), ) = tokio::join!(thread_persistence_fut, state_db_fut, auth_and_mcp_fut); let mut live_thread_init = @@ -1208,7 +1209,10 @@ impl Session { sess.services.mcp_manager.codex_apps_tools_cache(), codex_apps_tools_cache_key(auth), config.prefix_mcp_tool_names(), - mcp_config.client_elicitation_capability.clone(), + mcp_projection + .config + .client_elicitation_capability + .clone(), sess.services .supports_openai_form_elicitation .load(std::sync::atomic::Ordering::Relaxed), @@ -1224,7 +1228,8 @@ impl Session { .await; sess.services .install_mcp_connection_manager( - Arc::new(mcp_config), + Arc::new(mcp_projection.config), + mcp_projection.plugins_available, mcp_runtime_context, /*available_environment_ids*/ Vec::new(), mcp_connection_manager, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index ece9d446b5..9ae29a0917 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -7729,6 +7729,91 @@ async fn refresh_mcp_servers_keeps_the_previous_runtime_alive() { ); } +#[tokio::test] +async fn plugin_availability_change_reuses_the_mcp_manager() { + struct ReadyPluginContributor; + + impl codex_extension_api::McpServerContributor for ReadyPluginContributor { + fn id(&self) -> &'static str { + "ready_plugin_test" + } + + fn contribute<'a>( + &'a self, + context: codex_extension_api::McpServerContributionContext<'a, Config>, + ) -> codex_extension_api::ExtensionFuture<'a, Vec> + { + Box::pin(async move { + let available = context + .available_environment_ids() + .is_some_and(|ids| ids.iter().any(|id| id == "executor")); + available + .then( + || codex_extension_api::McpServerContribution::SelectedPluginPackage { + plugin_id: "skill-only".to_string(), + plugin_display_name: "Skill Only".to_string(), + connector_ids: Vec::new(), + }, + ) + .into_iter() + .collect() + }) + } + } + + let (mut session, turn_context) = make_session_and_context().await; + let mut registry = codex_extension_api::ExtensionRegistryBuilder::new(); + registry.mcp_server_contributor(Arc::new(ReadyPluginContributor)); + let registry = Arc::new(registry.build()); + session.services.extensions = Arc::clone(®istry); + session.services.mcp_manager = Arc::new(McpManager::new_with_extensions( + Arc::clone(&session.services.plugins_manager), + registry, + )); + let session = Arc::new(session); + session + .refresh_mcp_servers_now( + &turn_context, + &turn_context.config, + /*elicitation_reviewer*/ None, + ) + .await; + let old_runtime = session.services.latest_mcp_runtime(); + let old_manager = old_runtime.manager_arc(); + + let selected_root = codex_protocol::capabilities::SelectedCapabilityRoot { + id: "skill-only".to_string(), + location: codex_protocol::capabilities::CapabilityRootLocation::Environment { + environment_id: "executor".to_string(), + path: PathUri::from_host_native_path(turn_context.config.cwd.as_path()) + .expect("selected capability root URI"), + }, + }; + let environment = Arc::clone( + &turn_context + .environments + .primary() + .expect("ready test environment") + .environment, + ); + let captured_environments = HashMap::from([("executor".to_string(), Some(environment))]); + let resolved_roots = session + .services + .turn_environments + .environment_manager() + .resolve_selected_capability_roots(&[selected_root], &captured_environments) + .await; + + let new_runtime = session + .mcp_runtime_for_step(&turn_context, &turn_context.environments, &resolved_roots) + .await; + + assert!(!old_runtime.plugins_available()); + assert!(new_runtime.plugins_available()); + assert!(!Arc::ptr_eq(&old_runtime, &new_runtime)); + assert!(Arc::ptr_eq(&old_manager, &new_runtime.manager_arc())); +} + #[tokio::test] async fn built_tools_uses_the_step_mcp_runtime() -> anyhow::Result<()> { let (session, turn_context) = make_session_and_context().await; diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 10d6a5d0d1..a78116d457 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -2,6 +2,7 @@ use super::session::Session; use super::step_context::StepContext; use crate::context::world_state::AgentsMdState; use crate::context::world_state::EnvironmentsState; +use crate::context::world_state::PluginsInstructionsState; use crate::context::world_state::WorldState; use codex_extension_api::WorldStateContributionInput; @@ -35,6 +36,9 @@ impl Session { .with_subagents(environment_subagents), ); } + world_state.add_section(PluginsInstructionsState::new( + step_context.mcp.plugins_available(), + )); let environments = step_context.environments.to_selections(); let ready_selected_capability_roots = step_context .selected_capability_roots diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index a611b94887..c4b67a629b 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -107,18 +107,25 @@ impl SessionServices { pub(crate) async fn install_mcp_connection_manager( &self, config: Arc, + plugins_available: bool, runtime_context: McpRuntimeContext, available_environment_ids: Vec, manager: McpConnectionManager, ) -> Result<()> { - let runtime = - self.publish_mcp_runtime(config, runtime_context, available_environment_ids, manager); + let runtime = self.publish_mcp_runtime( + config, + plugins_available, + runtime_context, + available_environment_ids, + manager, + ); runtime.manager().validate_required_servers().await } pub(crate) fn publish_mcp_runtime( &self, config: Arc, + plugins_available: bool, runtime_context: McpRuntimeContext, available_environment_ids: Vec, manager: McpConnectionManager, @@ -129,6 +136,7 @@ impl SessionServices { self.mcp_connection_manager.store(Arc::clone(&manager)); let runtime = Arc::new(McpRuntimeSnapshot::new( config, + plugins_available, manager, runtime_context, available_environment_ids, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 4f0da0c9c7..3c1d0146e7 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -699,11 +699,11 @@ async fn start_thread_seeds_extension_data_for_mcp_and_lifecycle_contributors() .collect::>() }; assert_eq!( - selected_servers(&first_resolved), + selected_servers(&first_resolved.config), std::collections::BTreeMap::from([("selected-a".to_string(), "env-a".to_string())]) ); assert_eq!( - selected_servers(&second_resolved), + selected_servers(&second_resolved.config), std::collections::BTreeMap::from([("selected-b".to_string(), "env-b".to_string())]) ); } diff --git a/codex-rs/ext/extension-api/src/contributors/mcp.rs b/codex-rs/ext/extension-api/src/contributors/mcp.rs index 499657f556..3fe648a1e9 100644 --- a/codex-rs/ext/extension-api/src/contributors/mcp.rs +++ b/codex-rs/ext/extension-api/src/contributors/mcp.rs @@ -93,8 +93,8 @@ pub enum McpServerContribution { selection_order: usize, config: Box, }, - /// Adds connector IDs declared by a plugin selected for this thread. - SelectedPluginConnectors { + /// Records a plugin selected for this thread and any connector IDs it declares. + SelectedPluginPackage { plugin_id: String, plugin_display_name: String, connector_ids: Vec, diff --git a/codex-rs/ext/mcp/src/executor_plugin.rs b/codex-rs/ext/mcp/src/executor_plugin.rs index 32637b4f47..98cfcd3615 100644 --- a/codex-rs/ext/mcp/src/executor_plugin.rs +++ b/codex-rs/ext/mcp/src/executor_plugin.rs @@ -186,13 +186,12 @@ impl McpServerContributor for SelectedExecutorPluginMcpContributor { config: Box::new(config), } })); - if !plugin.connector_ids.is_empty() { - contributions.push(McpServerContribution::SelectedPluginConnectors { - plugin_id: plugin.plugin_id, - plugin_display_name: plugin.plugin_display_name, - connector_ids: plugin.connector_ids, - }); - } + // Keep the package visible even when it contributes only skills. + contributions.push(McpServerContribution::SelectedPluginPackage { + plugin_id: plugin.plugin_id, + plugin_display_name: plugin.plugin_display_name, + connector_ids: plugin.connector_ids, + }); } contributions diff --git a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs index 65a4248a32..da5c1fb6ad 100644 --- a/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs +++ b/codex-rs/ext/mcp/tests/executor_plugin_mcp.rs @@ -25,6 +25,13 @@ struct ContributionSummary { enabled: bool, } +#[derive(Debug, PartialEq, Eq)] +struct PackageSummary { + plugin_id: String, + plugin_display_name: String, + connector_ids: Vec, +} + #[tokio::test] async fn selected_plugin_servers_use_managed_requirements_for_the_selected_root_id() -> TestResult { let codex_home = tempfile::tempdir()?; @@ -92,10 +99,87 @@ command = "expected-command" Ok(()) } +#[tokio::test] +async fn selected_plugin_package_is_contributed_without_servers_or_connectors() -> TestResult { + let codex_home = tempfile::tempdir()?; + let plugin_root = tempfile::tempdir()?; + std::fs::create_dir_all(plugin_root.path().join(".codex-plugin"))?; + std::fs::create_dir_all(plugin_root.path().join("skills/deploy"))?; + std::fs::write( + plugin_root.path().join(".codex-plugin/plugin.json"), + r#"{"name":"skill-only","interface":{"displayName":"Skill Only"}}"#, + )?; + std::fs::write( + plugin_root.path().join("skills/deploy/SKILL.md"), + "---\nname: deploy\ndescription: Deploy the project.\n---\n", + )?; + let config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .build() + .await?; + + let contributions = raw_selected_plugin_contributions(&config, plugin_root.path()).await?; + let package = contributions.into_iter().find_map(|contribution| { + let McpServerContribution::SelectedPluginPackage { + plugin_id, + plugin_display_name, + connector_ids, + } = contribution + else { + return None; + }; + Some(PackageSummary { + plugin_id, + plugin_display_name, + connector_ids, + }) + }); + + assert_eq!( + package, + Some(PackageSummary { + plugin_id: "selected-root".to_string(), + plugin_display_name: "Skill Only".to_string(), + connector_ids: Vec::new(), + }) + ); + Ok(()) +} + async fn selected_plugin_contributions( config: &Config, plugin_root: &std::path::Path, ) -> Result, Box> { + Ok(raw_selected_plugin_contributions(config, plugin_root) + .await? + .into_iter() + .filter_map(|contribution| match contribution { + McpServerContribution::SelectedPlugin { + name, + plugin_id, + plugin_display_name, + selection_order, + config, + } => Some(ContributionSummary { + name, + plugin_id, + plugin_display_name, + selection_order, + enabled: config.enabled, + }), + McpServerContribution::SelectedPluginPackage { .. } => None, + McpServerContribution::Set { .. } | McpServerContribution::Remove { .. } => { + panic!("expected selected plugin contribution") + } + }) + .collect()) +} + +async fn raw_selected_plugin_contributions( + config: &Config, + plugin_root: &std::path::Path, +) -> Result, Box> { let mut builder = ExtensionRegistryBuilder::new(); codex_mcp_extension::install_executor_plugins( &mut builder, @@ -120,27 +204,5 @@ async fn selected_plugin_contributions( &thread_store, &available_environment_ids, )) - .await - .into_iter() - .map(|contribution| match contribution { - McpServerContribution::SelectedPlugin { - name, - plugin_id, - plugin_display_name, - selection_order, - config, - } => ContributionSummary { - name, - plugin_id, - plugin_display_name, - selection_order, - enabled: config.enabled, - }, - McpServerContribution::Set { .. } - | McpServerContribution::SelectedPluginConnectors { .. } - | McpServerContribution::Remove { .. } => { - panic!("expected selected plugin contribution") - } - }) - .collect()) + .await) } From bce481fdcb1c9ea454c677ed123ab1b07a9644ff Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 6 Jul 2026 10:55:35 -0400 Subject: [PATCH 02/76] Fix cancelled review leaving MCP startup busy (#31189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Cancelling an inline review could leave the TUI stuck in MCP startup state, so subsequent `/review` commands were rejected as though another task were still running. Specifically, the child’s “Starting MCP servers” event was incorrectly forwarded to the parent TUI, so the parent was marking itself as busy. This keeps delegate-session MCP startup events inside the delegate instead of exposing them as parent-session state. ## Reproduction Here's the before -- notice that after I cancel the first review, I'm prevented from running `/review` again: https://github.com/user-attachments/assets/571a0793-3253-4bcc-8f10-3782d176162f And the after -- notice that after I cancel, I can immediately start a new `/review`: https://github.com/user-attachments/assets/dd98b1d7-6a71-4a1f-a4b2-3295bc4848e5 --- codex-rs/core/src/codex_delegate.rs | 10 +-- codex-rs/core/src/codex_delegate_tests.rs | 65 ++++++++++------ codex-rs/core/tests/suite/review.rs | 90 +++++++++++++++++++++++ 3 files changed, 139 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index a671f0dd74..4cba4bddc5 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -299,11 +299,11 @@ async fn forward_events( match event { Event { id: _, - msg: EventMsg::TokenCount(_), - } => {} - Event { - id: _, - msg: EventMsg::SessionConfigured(_), + msg: + EventMsg::TokenCount(_) + | EventMsg::SessionConfigured(_) + | EventMsg::McpStartupUpdate(_) + | EventMsg::McpStartupComplete(_), } => {} Event { id, diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index 9513dcc56c..5cb2251a4a 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -14,6 +14,9 @@ use codex_protocol::protocol::GuardianAssessmentAction; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianCommandSource; use codex_protocol::protocol::McpInvocation; +use codex_protocol::protocol::McpStartupCompleteEvent; +use codex_protocol::protocol::McpStartupStatus; +use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::RawResponseItemEvent; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::TurnAbortReason; @@ -34,8 +37,8 @@ use tokio::sync::watch; use tokio::time::timeout; #[tokio::test] -async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { - let (tx_events, rx_events) = bounded(1); +async fn forward_events_filters_private_events_before_blocked_send_is_cancelled() { + let (tx_events, rx_events) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_sub, rx_sub) = bounded(SUBMISSION_CHANNEL_CAPACITY); let (_agent_status_tx, agent_status) = watch::channel(AgentStatus::PendingInit); let (session, ctx, _rx_evt) = crate::session::tests::make_session_and_context_with_rx().await; @@ -71,33 +74,53 @@ async fn forward_events_cancelled_while_send_blocked_shuts_down_delegate() { cancel.clone(), )); - tx_events - .send(Event { - id: "evt".to_string(), - msg: EventMsg::RawResponseItem(RawResponseItemEvent { - item: ResponseItem::CustomToolCall { - id: None, - status: None, - call_id: "call-1".to_string(), - name: "tool".to_string(), - namespace: None, - input: "{}".to_string(), - internal_chat_message_metadata_passthrough: None, - }, - }), - }) - .await - .unwrap(); + for msg in [ + EventMsg::McpStartupUpdate(McpStartupUpdateEvent { + server: "pending".to_string(), + status: McpStartupStatus::Starting, + }), + EventMsg::McpStartupComplete(McpStartupCompleteEvent::default()), + ] { + tx_events + .send(Event { + id: "delegate-startup".to_string(), + msg, + }) + .await + .unwrap(); + } + let visible_msg = EventMsg::RawResponseItem(RawResponseItemEvent { + item: ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-1".to_string(), + name: "tool".to_string(), + namespace: None, + input: "{}".to_string(), + internal_chat_message_metadata_passthrough: None, + }, + }); + for id in ["visible-1", "visible-2", "blocked"] { + tx_events + .send(Event { + id: id.to_string(), + msg: visible_msg.clone(), + }) + .await + .unwrap(); + } drop(tx_events); + let received = rx_out.recv().await.expect("prefilled event missing"); + assert_eq!(received.id, "full"); + let received = rx_out.recv().await.expect("visible event missing"); + assert_eq!(received.id, "visible-1"); cancel.cancel(); timeout(std::time::Duration::from_millis(1000), forward) .await .expect("forward_events hung") .expect("forward_events join error"); - let received = rx_out.recv().await.expect("prefilled event missing"); - assert_eq!("full", received.id); let mut ops = Vec::new(); while let Ok(sub) = rx_sub.try_recv() { ops.push(sub.op); diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index af44baf6ca..e403b7e1ff 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -29,6 +29,7 @@ use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use std::path::PathBuf; use std::sync::Arc; +use std::time::Duration; use tempfile::TempDir; use tokio::io::AsyncWriteExt as _; use uuid::Uuid; @@ -200,6 +201,95 @@ async fn review_op_emits_lifecycle_and_review_output() { server.verify().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancelled_review_does_not_forward_delegate_mcp_startup() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let request_log = responses::mount_response_once( + &server, + responses::sse_response(responses::sse(vec![responses::ev_response_created( + "resp-1", + )])) + .set_delay(Duration::from_secs(30)), + ) + .await; + let codex_home = Arc::new(TempDir::new().unwrap()); + let codex = new_conversation_for_server(&server, codex_home.clone(), |_| {}).await; + + // Consume the parent session's own empty startup round before starting the review. + wait_for_event(&codex, |event| { + matches!(event, EventMsg::McpStartupComplete(_)) + }) + .await; + + codex + .submit(Op::Review { + review_request: ReviewRequest { + target: ReviewTarget::Custom { + instructions: "Cancel this review".to_string(), + }, + user_facing_hint: None, + }, + }) + .await + .unwrap(); + + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match codex.next_event().await.expect("review event").msg { + event @ (EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_)) => { + panic!("review forwarded delegate MCP startup: {event:?}") + } + EventMsg::EnteredReviewMode(_) => break, + _ => {} + } + } + }) + .await + .expect("timed out waiting for review entry"); + + tokio::time::timeout(Duration::from_secs(5), async { + while request_log.requests().is_empty() { + tokio::task::yield_now().await; + } + }) + .await + .expect("review request did not reach the server"); + + codex.submit(Op::Interrupt).await.unwrap(); + + let mut exited_review = false; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match codex + .next_event() + .await + .expect("review cancellation event") + .msg + { + event @ (EventMsg::McpStartupUpdate(_) | EventMsg::McpStartupComplete(_)) => { + panic!("cancelled review forwarded delegate MCP startup: {event:?}") + } + EventMsg::ExitedReviewMode(ExitedReviewModeEvent { review_output }) => { + assert_eq!(review_output, None); + exited_review = true; + } + EventMsg::TurnAborted(_) if exited_review => break, + EventMsg::TurnAborted(_) => panic!("review turn aborted before review mode exited"), + _ => {} + } + } + }) + .await + .expect("timed out waiting for review cancellation"); + + assert_eq!(request_log.requests().len(), 1); + + let _codex_home_guard = codex_home; + server.verify().await; +} + /// When the model returns plain text that is not JSON, ensure the child /// lifecycle still occurs and the plain text is surfaced via /// ExitedReviewMode(Some(..)) as the overall_explanation. From 8917244f7dcc1a945f3d5eba3dea53f6dbb16349 Mon Sep 17 00:00:00 2001 From: Alexi Christakis <167903946+alexi-openai@users.noreply.github.com> Date: Mon, 6 Jul 2026 09:12:45 -0700 Subject: [PATCH 03/76] [core] Support interleaved response items (#30876) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - preserve reasoning item IDs on summary part and text delta events - track streamed response items by ID so reasoning summaries can continue after later items begin - keep TUI output complete and deduplicated when reasoning and final-answer events interleave ## Stack - **1/2: this PR — interleaved item support** - 2/2: #30752 — wire reasoning summary delivery configuration through the CLI and app-server ## Validation - just test -p codex-api preserves_reasoning_summary_item_ids - just test -p codex-core interleaved_reasoning_summary_events_keep_reasoning_item_metadata - just test -p codex-tui live_reasoning_summary_is_not_rendered_twice_when_item_completes - just fix -p codex-api -p codex-core -p codex-tui - just fmt --- codex-rs/codex-api/src/common.rs | 2 + codex-rs/codex-api/src/sse/responses.rs | 46 ++++++- codex-rs/core/src/session/turn.rs | 76 ++++++++---- codex-rs/core/tests/common/responses.rs | 13 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 1 + codex-rs/core/tests/suite/codex_delegate.rs | 2 +- codex-rs/core/tests/suite/items.rs | 112 ++++++++++++++---- codex-rs/core/tests/suite/otel.rs | 2 +- .../src/chatwidget/tests/history_replay.rs | 73 +++++++----- 9 files changed, 246 insertions(+), 81 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index f00cd742e6..cbd93caf5d 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -101,6 +101,7 @@ pub enum ResponseEvent { delta: String, }, ReasoningSummaryDelta { + item_id: String, delta: String, summary_index: i64, }, @@ -109,6 +110,7 @@ pub enum ResponseEvent { content_index: i64, }, ReasoningSummaryPartAdded { + item_id: String, summary_index: i64, }, RateLimits(RateLimitSnapshot), diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index bd742dfa14..bc32fc41c5 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -351,8 +351,11 @@ pub fn process_responses_event( } } "response.reasoning_summary_text.delta" => { - if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) { + if let (Some(item_id), Some(delta), Some(summary_index)) = + (event.item_id, event.delta, event.summary_index) + { return Ok(Some(ResponseEvent::ReasoningSummaryDelta { + item_id, delta, summary_index, })); @@ -444,8 +447,9 @@ pub fn process_responses_event( } } "response.reasoning_summary_part.added" => { - if let Some(summary_index) = event.summary_index { + if let (Some(item_id), Some(summary_index)) = (event.item_id, event.summary_index) { return Ok(Some(ResponseEvent::ReasoningSummaryPartAdded { + item_id, summary_index, })); } @@ -796,6 +800,44 @@ mod tests { } } + #[tokio::test] + async fn preserves_reasoning_summary_item_ids() { + let events = run_sse(vec![ + json!({ + "type": "response.reasoning_summary_part.added", + "item_id": "reasoning-1", + "summary_index": 0 + }), + json!({ + "type": "response.reasoning_summary_text.delta", + "item_id": "reasoning-1", + "summary_index": 0, + "delta": "Checking" + }), + json!({ + "type": "response.completed", + "response": { "id": "resp1" } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::ReasoningSummaryPartAdded { + item_id, + summary_index: 0, + } if item_id == "reasoning-1" + ); + assert_matches!( + &events[1], + ResponseEvent::ReasoningSummaryDelta { + item_id, + delta, + summary_index: 0, + } if item_id == "reasoning-1" && delta == "Checking" + ); + } + #[tokio::test] async fn error_when_missing_completed() { let item1 = json!({ diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f16b525002..1031c42919 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1928,6 +1928,7 @@ async fn try_run_sampling_request( let mut needs_follow_up = false; let mut last_agent_message: Option = None; let mut active_item: Option = None; + let mut streamed_items = HashMap::::new(); let mut active_tool_argument_diff_consumer: Option<( String, Box, @@ -1986,18 +1987,44 @@ async fn try_run_sampling_request( match event { ResponseEvent::Created => {} ResponseEvent::OutputItemDone(item) => { - if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take() + let completes_active_tool_call = active_tool_argument_diff_consumer + .as_ref() + .is_some_and(|(active_call_id, _)| { + matches!( + &item, + ResponseItem::CustomToolCall { call_id, .. } + if call_id == active_call_id + ) + }); + if completes_active_tool_call + && let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take() && let Ok(Some(event)) = consumer.finish() { sess.send_event(&turn_context, event).await; } - let previously_active_item = active_item.take(); - let previously_streamed_item = if active_item_is_streaming_to_client { - previously_active_item - } else { - None + let completed_item_id = item.id().map(str::to_owned); + let is_tool_call = matches!( + &item, + ResponseItem::LocalShellCall { .. } + | ResponseItem::FunctionCall { .. } + | ResponseItem::ToolSearchCall { .. } + | ResponseItem::CustomToolCall { .. } + ); + if completed_item_id.is_none() && !is_tool_call { + warn!("dropping non-tool output_item.done event without item ID"); + continue; + } + let completes_active_item = match (&completed_item_id, active_item.as_ref()) { + (Some(completed_item_id), Some(active)) => completed_item_id == &active.id(), + _ => false, }; - active_item_is_streaming_to_client = false; + let previously_streamed_item = completed_item_id + .as_deref() + .and_then(|item_id| streamed_items.remove(item_id)); + if completes_active_item { + active_item = None; + active_item_is_streaming_to_client = false; + } if let Some(previous) = previously_streamed_item.as_ref() && matches!(previous, TurnItem::AgentMessage(_)) { @@ -2152,6 +2179,7 @@ async fn try_run_sampling_request( ) .await; } + streamed_items.insert(turn_item.id(), turn_item.clone()); } active_item = Some(turn_item); active_item_is_streaming_to_client = stream_item_to_client; @@ -2288,39 +2316,47 @@ async fn try_run_sampling_request( } } ResponseEvent::ReasoningSummaryDelta { + item_id, delta, summary_index, } => { - if let Some(active) = active_item.as_ref() { - if !active_item_is_streaming_to_client { - continue; - } + if defer_streamed_turn_items_for_contributors { + continue; + } + if streamed_items.contains_key(&item_id) { let event = ReasoningContentDeltaEvent { thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), - item_id: active.id(), + item_id, delta, summary_index, }; sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event)) .await; } else { - error_or_panic("ReasoningSummaryDelta without active item".to_string()); + error_or_panic(format!( + "ReasoningSummaryDelta without streamed item {item_id}" + )); } } - ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { - if let Some(active) = active_item.as_ref() { - if !active_item_is_streaming_to_client { - continue; - } + ResponseEvent::ReasoningSummaryPartAdded { + item_id, + summary_index, + } => { + if defer_streamed_turn_items_for_contributors { + continue; + } + if streamed_items.contains_key(&item_id) { let event = EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { - item_id: active.id(), + item_id, summary_index, }); sess.send_event(&turn_context, event).await; } else { - error_or_panic("ReasoningSummaryPartAdded without active item".to_string()); + error_or_panic(format!( + "ReasoningSummaryPartAdded without streamed item {item_id}" + )); } } ResponseEvent::ReasoningContentDelta { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 9569df1c90..45d4cb3d99 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -784,11 +784,20 @@ pub fn ev_reasoning_item_added(id: &str, summary: &[&str]) -> Value { }) } -pub fn ev_reasoning_summary_text_delta(delta: &str) -> Value { +pub fn ev_reasoning_summary_part_added(item_id: &str, summary_index: i64) -> Value { + serde_json::json!({ + "type": "response.reasoning_summary_part.added", + "item_id": item_id, + "summary_index": summary_index, + }) +} + +pub fn ev_reasoning_summary_text_delta(item_id: &str, summary_index: i64, delta: &str) -> Value { serde_json::json!({ "type": "response.reasoning_summary_text.delta", + "item_id": item_id, "delta": delta, - "summary_index": 0, + "summary_index": summary_index, }) } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index d8142723b4..92f583c470 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1198,6 +1198,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> "call_id": call_id, "delta": "*** Add File: streamed.txt\n+hello", }), + ev_assistant_message("unrelated-message", ""), json!({ "type": "response.custom_tool_call_input.delta", "call_id": call_id, diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index f5db9856df..7dab8a0688 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -204,7 +204,7 @@ async fn codex_delegate_ignores_legacy_deltas() { let sse_stream = sse(vec![ ev_response_created("resp-1"), ev_reasoning_item_added("reason-1", &["initial"]), - ev_reasoning_summary_text_delta("think-1"), + ev_reasoning_summary_text_delta("reason-1", /*summary_index*/ 0, "think-1"), ev_completed("resp-1"), ]); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 515d496b8b..b29696fdad 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -20,17 +20,20 @@ use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; 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_image_generation_call; 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; use core_test_support::responses::ev_reasoning_item_added; +use core_test_support::responses::ev_reasoning_summary_part_added; use core_test_support::responses::ev_reasoning_summary_text_delta; use core_test_support::responses::ev_reasoning_text_delta; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_web_search_call_added_partial; use core_test_support::responses::ev_web_search_call_done; 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::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -1085,21 +1088,45 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { +async fn interleaved_reasoning_summary_events_keep_reasoning_item_metadata() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let TestCodex { codex, .. } = test_codex().build(&server).await?; - let stream = sse(vec![ - ev_response_created("resp-1"), - ev_reasoning_item_added("reasoning-1", &[""]), - ev_reasoning_summary_text_delta("step one"), - ev_reasoning_item("reasoning-1", &["step one"], &[]), - ev_completed("resp-1"), - ]); - mount_sse_once(&server, stream).await; + mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_reasoning_item_added("reasoning-1", &[""]), + ev_reasoning_summary_part_added("reasoning-1", /*summary_index*/ 0), + ev_reasoning_summary_text_delta( + "reasoning-1", + /*summary_index*/ 0, + "step one", + ), + ev_reasoning_item("", &["ignored"], &[]), + ev_function_call("call-1", "unknown_tool", "{}"), + ev_reasoning_summary_part_added("reasoning-1", /*summary_index*/ 1), + ev_reasoning_summary_text_delta( + "reasoning-1", + /*summary_index*/ 1, + "step two", + ), + ev_reasoning_item("reasoning-1", &["step one", "step two"], &[]), + ev_completed("resp-1"), + ]), + sse(vec![ + ev_message_item_added("message-1", ""), + ev_output_text_delta("Done"), + ev_assistant_message("message-1", "Done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; codex .submit(Op::UserInput { @@ -1114,22 +1141,59 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { }) .await?; - let reasoning_item = wait_for_event_match(&codex, |ev| match ev { - EventMsg::ItemStarted(ItemStartedEvent { - item: TurnItem::Reasoning(item), - .. - }) => Some(item.clone()), - _ => None, - }) - .await; + let mut reasoning_started = Vec::new(); + let mut reasoning_completed = Vec::new(); + let mut messages_started = Vec::new(); + let mut messages_completed = Vec::new(); + let mut summary_deltas = Vec::new(); + let mut summary_sections = Vec::new(); + loop { + match wait_for_event(&codex, |_| true).await { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::Reasoning(item), + .. + }) => reasoning_started.push(item.id), + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::Reasoning(item), + .. + }) => reasoning_completed.push(item.id), + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::AgentMessage(item), + .. + }) => messages_started.push(item.id), + EventMsg::ItemCompleted(ItemCompletedEvent { + item: TurnItem::AgentMessage(item), + .. + }) => messages_completed.push(item.id), + EventMsg::ReasoningContentDelta(event) => { + summary_deltas.push((event.item_id, event.delta, event.summary_index)); + } + EventMsg::AgentReasoningSectionBreak(event) => { + summary_sections.push((event.item_id, event.summary_index)); + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } - let delta_event = wait_for_event_match(&codex, |ev| match ev { - EventMsg::ReasoningContentDelta(event) => Some(event.clone()), - _ => None, - }) - .await; - assert_eq!(delta_event.item_id, reasoning_item.id); - assert_eq!(delta_event.delta, "step one"); + assert_eq!(reasoning_started, vec!["reasoning-1"]); + assert_eq!(reasoning_completed, vec!["reasoning-1"]); + assert_eq!(messages_started, vec!["message-1"]); + assert_eq!(messages_completed, vec!["message-1"]); + assert_eq!( + summary_deltas, + vec![ + ("reasoning-1".to_string(), "step one".to_string(), 0), + ("reasoning-1".to_string(), "step two".to_string(), 1), + ] + ); + assert_eq!( + summary_sections, + vec![ + ("reasoning-1".to_string(), 0), + ("reasoning-1".to_string(), 1) + ] + ); Ok(()) } diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 4a2b2993ad..060a9079a9 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -821,7 +821,7 @@ async fn record_responses_sets_span_fields_for_response_events() { ev_message_item_added("msg-added", "hi there"), ev_reasoning_item_added("reasoning-1", &["summary"]), ev_output_text_delta("delta"), - ev_reasoning_summary_text_delta("summary-delta"), + ev_reasoning_summary_text_delta("reasoning-1", /*summary_index*/ 0, "summary-delta"), ev_reasoning_text_delta("raw-delta"), ev_function_call("call-1", "fn", "{\"key\":\"value\"}"), ev_assistant_message("msg-1", "agent"), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index f8626dfcc0..300978cf4d 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -995,34 +995,30 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; - chat.handle_server_notification( - ServerNotification::TurnStarted(TurnStartedNotification { - thread_id: "thread-1".to_string(), - turn: AppServerTurn { - id: "turn-1".to_string(), - items_view: codex_app_server_protocol::TurnItemsView::Full, - items: Vec::new(), - status: AppServerTurnStatus::InProgress, - error: None, - started_at: Some(0), - completed_at: None, - duration_ms: None, - }, - }), - /*replay_kind*/ None, - ); - let _ = drain_insert_history(&mut rx); + handle_turn_started(&mut chat, "turn-1"); + while rx.try_recv().is_ok() {} + handle_agent_reasoning_delta(&mut chat, "**Step one**\nFirst summary"); + handle_agent_message_delta(&mut chat, "Final answer"); + complete_assistant_message( + &mut chat, + "msg-1", + "Final answer", + Some(MessagePhase::FinalAnswer), + ); chat.handle_server_notification( - ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { - thread_id: "thread-1".to_string(), - turn_id: "turn-1".to_string(), - item_id: "reasoning-1".to_string(), - delta: "Summary only".to_string(), - summary_index: 0, - }), + ServerNotification::ReasoningSummaryPartAdded( + codex_app_server_protocol::ReasoningSummaryPartAddedNotification { + thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + summary_index: 1, + }, + ), /*replay_kind*/ None, ); + handle_agent_reasoning_delta(&mut chat, "**Step two**\nSecond summary"); + handle_agent_reasoning_final(&mut chat); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { @@ -1031,20 +1027,35 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { completed_at_ms: 0, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), - summary: vec!["Summary only".to_string()], + summary: vec![ + "**Step one**\nFirst summary".to_string(), + "**Step two**\nSecond summary".to_string(), + ], content: Vec::new(), }, }), /*replay_kind*/ None, ); - let rendered = match rx.try_recv() { - Ok(AppEvent::InsertHistoryCell(cell)) => { - lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) + let mut consolidated_answer = None; + let mut inserted_history = String::new(); + while let Ok(event) = rx.try_recv() { + match event { + AppEvent::ConsolidateAgentMessage { source, .. } => { + consolidated_answer = Some(source); + } + AppEvent::InsertHistoryCell(cell) => { + inserted_history.push_str(&lines_to_single_string( + &cell.transcript_lines(/*width*/ 80), + )); + } + _ => {} } - other => panic!("expected InsertHistoryCell, got {other:?}"), - }; - assert_eq!(rendered.matches("Summary only").count(), 1); + } + + assert_eq!(consolidated_answer.as_deref(), Some("Final answer")); + assert_eq!(inserted_history.matches("First summary").count(), 1); + assert_eq!(inserted_history.matches("Second summary").count(), 1); } #[tokio::test] From 7094fa467ea3ae596dae32519c3b90ab5390c9cc Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Mon, 6 Jul 2026 10:04:26 -0700 Subject: [PATCH 04/76] [codex] Read retry model from buffering events (#31262) ## Summary - deserialize `retry_model` from streamed `safety_buffering` payloads - preserve the existing downstream faster-model API and legacy header fallback - update SSE, WebSocket, and end-to-end safety-buffering coverage ## Root cause Follow-up to #31064. The Responses API emits the retry target as `retry_model`, but the client was looking for `faster_model`, so the payload value was ignored in favor of the compatibility fallback. ## Behavior A non-null `retry_model` from the buffering payload takes precedence. An explicit null leaves the retry target unset, while an omitted field continues to fall back to the existing response header. ## Validation - `just test -p codex-api` (135 tests) - `just test -p codex-core safety_buffering` (2 tests) - `just fix -p codex-api` - `just fmt` - `git diff --check` --- codex-rs/codex-api/src/common.rs | 1 + .../codex-api/src/endpoint/responses_websocket.rs | 2 +- codex-rs/codex-api/src/sse/responses.rs | 14 +++++++------- codex-rs/core/tests/suite/safety_buffering.rs | 2 +- 4 files changed, 10 insertions(+), 9 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index cbd93caf5d..7319d13a79 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -123,6 +123,7 @@ pub struct SafetyBuffering { pub reasons: Vec, #[serde(skip)] pub show_buffering_ui: bool, + #[serde(rename = "retry_model")] pub faster_model: Option, } diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index c4c617ac99..7f261bba36 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -1061,7 +1061,7 @@ mod tests { "safety_buffering": { "use_cases": ["cyber"], "reasons": ["user_risk"], - "faster_model": "gpt-fast-wire" + "retry_model": "gpt-fast-wire" } })) .expect("deserialize safety buffering event"); diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index bc32fc41c5..626f460dac 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -236,10 +236,10 @@ impl ResponsesStreamEvent { treatment: &SafetyBufferingTreatment, ) -> Option { let value = self.safety_buffering.as_ref()?; - let faster_model_present = value.as_object()?.contains_key("faster_model"); + let retry_model_present = value.as_object()?.contains_key("retry_model"); let mut buffering: SafetyBuffering = serde_json::from_value(value.clone()).ok()?; buffering.show_buffering_ui = true; - if !faster_model_present { + if !retry_model_present { buffering.faster_model.clone_from(&treatment.faster_model); } Some(buffering) @@ -1405,7 +1405,7 @@ mod tests { "safety_buffering": { "use_cases": ["cyber"], "reasons": ["user_risk"], - "faster_model": "gpt-fast-wire" + "retry_model": "gpt-fast-wire" } }), json!({ @@ -1453,12 +1453,12 @@ mod tests { } #[test] - fn safety_buffering_prefers_wire_faster_model_and_only_falls_back_when_omitted() { + fn safety_buffering_prefers_wire_retry_model_and_only_falls_back_when_omitted() { let treatment = SafetyBufferingTreatment { faster_model: Some("gpt-fast-header".to_string()), }; - for (faster_model, expected_faster_model) in [ + for (retry_model, expected_faster_model) in [ (None, Some("gpt-fast-header")), (Some(Value::Null), None), (Some(json!("gpt-fast-wire")), Some("gpt-fast-wire")), @@ -1470,8 +1470,8 @@ mod tests { "reasons": ["user_risk"] } }); - if let Some(faster_model) = faster_model { - event["safety_buffering"]["faster_model"] = faster_model; + if let Some(retry_model) = retry_model { + event["safety_buffering"]["retry_model"] = retry_model; } let event: ResponsesStreamEvent = serde_json::from_value(event).expect("deserialize safety buffering event"); diff --git a/codex-rs/core/tests/suite/safety_buffering.rs b/codex-rs/core/tests/suite/safety_buffering.rs index 5a8393f864..473cc18ced 100644 --- a/codex-rs/core/tests/suite/safety_buffering.rs +++ b/codex-rs/core/tests/suite/safety_buffering.rs @@ -83,7 +83,7 @@ async fn emits_safety_buffering_with_the_responses_api_model_without_header_gati created["safety_buffering"] = json!({ "use_cases": ["cyber"], "reasons": ["policy-check"], - "faster_model": FASTER_MODEL, + "retry_model": FASTER_MODEL, }); mount_response_once( &server, From 7b4e70d567ca2c15b2db8ac16c729e894843cdd3 Mon Sep 17 00:00:00 2001 From: Alexi Christakis <167903946+alexi-openai@users.noreply.github.com> Date: Mon, 6 Jul 2026 10:09:54 -0700 Subject: [PATCH 05/76] Revert "[core] Support interleaved response items" (#31261) Reverts openai/codex#30876 --- codex-rs/codex-api/src/common.rs | 2 - codex-rs/codex-api/src/sse/responses.rs | 46 +------ codex-rs/core/src/session/turn.rs | 76 ++++-------- codex-rs/core/tests/common/responses.rs | 13 +- codex-rs/core/tests/suite/apply_patch_cli.rs | 1 - codex-rs/core/tests/suite/codex_delegate.rs | 2 +- codex-rs/core/tests/suite/items.rs | 112 ++++-------------- codex-rs/core/tests/suite/otel.rs | 2 +- .../src/chatwidget/tests/history_replay.rs | 73 +++++------- 9 files changed, 81 insertions(+), 246 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 7319d13a79..254a5cb38f 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -101,7 +101,6 @@ pub enum ResponseEvent { delta: String, }, ReasoningSummaryDelta { - item_id: String, delta: String, summary_index: i64, }, @@ -110,7 +109,6 @@ pub enum ResponseEvent { content_index: i64, }, ReasoningSummaryPartAdded { - item_id: String, summary_index: i64, }, RateLimits(RateLimitSnapshot), diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 626f460dac..17d31a02ee 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -351,11 +351,8 @@ pub fn process_responses_event( } } "response.reasoning_summary_text.delta" => { - if let (Some(item_id), Some(delta), Some(summary_index)) = - (event.item_id, event.delta, event.summary_index) - { + if let (Some(delta), Some(summary_index)) = (event.delta, event.summary_index) { return Ok(Some(ResponseEvent::ReasoningSummaryDelta { - item_id, delta, summary_index, })); @@ -447,9 +444,8 @@ pub fn process_responses_event( } } "response.reasoning_summary_part.added" => { - if let (Some(item_id), Some(summary_index)) = (event.item_id, event.summary_index) { + if let Some(summary_index) = event.summary_index { return Ok(Some(ResponseEvent::ReasoningSummaryPartAdded { - item_id, summary_index, })); } @@ -800,44 +796,6 @@ mod tests { } } - #[tokio::test] - async fn preserves_reasoning_summary_item_ids() { - let events = run_sse(vec![ - json!({ - "type": "response.reasoning_summary_part.added", - "item_id": "reasoning-1", - "summary_index": 0 - }), - json!({ - "type": "response.reasoning_summary_text.delta", - "item_id": "reasoning-1", - "summary_index": 0, - "delta": "Checking" - }), - json!({ - "type": "response.completed", - "response": { "id": "resp1" } - }), - ]) - .await; - - assert_matches!( - &events[0], - ResponseEvent::ReasoningSummaryPartAdded { - item_id, - summary_index: 0, - } if item_id == "reasoning-1" - ); - assert_matches!( - &events[1], - ResponseEvent::ReasoningSummaryDelta { - item_id, - delta, - summary_index: 0, - } if item_id == "reasoning-1" && delta == "Checking" - ); - } - #[tokio::test] async fn error_when_missing_completed() { let item1 = json!({ diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 1031c42919..f16b525002 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1928,7 +1928,6 @@ async fn try_run_sampling_request( let mut needs_follow_up = false; let mut last_agent_message: Option = None; let mut active_item: Option = None; - let mut streamed_items = HashMap::::new(); let mut active_tool_argument_diff_consumer: Option<( String, Box, @@ -1987,44 +1986,18 @@ async fn try_run_sampling_request( match event { ResponseEvent::Created => {} ResponseEvent::OutputItemDone(item) => { - let completes_active_tool_call = active_tool_argument_diff_consumer - .as_ref() - .is_some_and(|(active_call_id, _)| { - matches!( - &item, - ResponseItem::CustomToolCall { call_id, .. } - if call_id == active_call_id - ) - }); - if completes_active_tool_call - && let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take() + if let Some((_, mut consumer)) = active_tool_argument_diff_consumer.take() && let Ok(Some(event)) = consumer.finish() { sess.send_event(&turn_context, event).await; } - let completed_item_id = item.id().map(str::to_owned); - let is_tool_call = matches!( - &item, - ResponseItem::LocalShellCall { .. } - | ResponseItem::FunctionCall { .. } - | ResponseItem::ToolSearchCall { .. } - | ResponseItem::CustomToolCall { .. } - ); - if completed_item_id.is_none() && !is_tool_call { - warn!("dropping non-tool output_item.done event without item ID"); - continue; - } - let completes_active_item = match (&completed_item_id, active_item.as_ref()) { - (Some(completed_item_id), Some(active)) => completed_item_id == &active.id(), - _ => false, + let previously_active_item = active_item.take(); + let previously_streamed_item = if active_item_is_streaming_to_client { + previously_active_item + } else { + None }; - let previously_streamed_item = completed_item_id - .as_deref() - .and_then(|item_id| streamed_items.remove(item_id)); - if completes_active_item { - active_item = None; - active_item_is_streaming_to_client = false; - } + active_item_is_streaming_to_client = false; if let Some(previous) = previously_streamed_item.as_ref() && matches!(previous, TurnItem::AgentMessage(_)) { @@ -2179,7 +2152,6 @@ async fn try_run_sampling_request( ) .await; } - streamed_items.insert(turn_item.id(), turn_item.clone()); } active_item = Some(turn_item); active_item_is_streaming_to_client = stream_item_to_client; @@ -2316,47 +2288,39 @@ async fn try_run_sampling_request( } } ResponseEvent::ReasoningSummaryDelta { - item_id, delta, summary_index, } => { - if defer_streamed_turn_items_for_contributors { - continue; - } - if streamed_items.contains_key(&item_id) { + if let Some(active) = active_item.as_ref() { + if !active_item_is_streaming_to_client { + continue; + } let event = ReasoningContentDeltaEvent { thread_id: sess.thread_id.to_string(), turn_id: turn_context.sub_id.clone(), - item_id, + item_id: active.id(), delta, summary_index, }; sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event)) .await; } else { - error_or_panic(format!( - "ReasoningSummaryDelta without streamed item {item_id}" - )); + error_or_panic("ReasoningSummaryDelta without active item".to_string()); } } - ResponseEvent::ReasoningSummaryPartAdded { - item_id, - summary_index, - } => { - if defer_streamed_turn_items_for_contributors { - continue; - } - if streamed_items.contains_key(&item_id) { + ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { + if let Some(active) = active_item.as_ref() { + if !active_item_is_streaming_to_client { + continue; + } let event = EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { - item_id, + item_id: active.id(), summary_index, }); sess.send_event(&turn_context, event).await; } else { - error_or_panic(format!( - "ReasoningSummaryPartAdded without streamed item {item_id}" - )); + error_or_panic("ReasoningSummaryPartAdded without active item".to_string()); } } ResponseEvent::ReasoningContentDelta { diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 45d4cb3d99..9569df1c90 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -784,20 +784,11 @@ pub fn ev_reasoning_item_added(id: &str, summary: &[&str]) -> Value { }) } -pub fn ev_reasoning_summary_part_added(item_id: &str, summary_index: i64) -> Value { - serde_json::json!({ - "type": "response.reasoning_summary_part.added", - "item_id": item_id, - "summary_index": summary_index, - }) -} - -pub fn ev_reasoning_summary_text_delta(item_id: &str, summary_index: i64, delta: &str) -> Value { +pub fn ev_reasoning_summary_text_delta(delta: &str) -> Value { serde_json::json!({ "type": "response.reasoning_summary_text.delta", - "item_id": item_id, "delta": delta, - "summary_index": summary_index, + "summary_index": 0, }) } diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 92f583c470..d8142723b4 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -1198,7 +1198,6 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()> "call_id": call_id, "delta": "*** Add File: streamed.txt\n+hello", }), - ev_assistant_message("unrelated-message", ""), json!({ "type": "response.custom_tool_call_input.delta", "call_id": call_id, diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 7dab8a0688..f5db9856df 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -204,7 +204,7 @@ async fn codex_delegate_ignores_legacy_deltas() { let sse_stream = sse(vec![ ev_response_created("resp-1"), ev_reasoning_item_added("reason-1", &["initial"]), - ev_reasoning_summary_text_delta("reason-1", /*summary_index*/ 0, "think-1"), + ev_reasoning_summary_text_delta("think-1"), ev_completed("resp-1"), ]); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index b29696fdad..515d496b8b 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -20,20 +20,17 @@ use codex_utils_absolute_path::AbsolutePathBuf; use core_test_support::PathBufExt; 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_image_generation_call; 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; use core_test_support::responses::ev_reasoning_item_added; -use core_test_support::responses::ev_reasoning_summary_part_added; use core_test_support::responses::ev_reasoning_summary_text_delta; use core_test_support::responses::ev_reasoning_text_delta; use core_test_support::responses::ev_response_created; use core_test_support::responses::ev_web_search_call_added_partial; use core_test_support::responses::ev_web_search_call_done; 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::responses::start_mock_server; use core_test_support::skip_if_no_network; @@ -1088,45 +1085,21 @@ async fn plan_mode_handles_missing_plan_close_tag() -> anyhow::Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn interleaved_reasoning_summary_events_keep_reasoning_item_metadata() -> anyhow::Result<()> { +async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; let TestCodex { codex, .. } = test_codex().build(&server).await?; - mount_sse_sequence( - &server, - vec![ - sse(vec![ - ev_response_created("resp-1"), - ev_reasoning_item_added("reasoning-1", &[""]), - ev_reasoning_summary_part_added("reasoning-1", /*summary_index*/ 0), - ev_reasoning_summary_text_delta( - "reasoning-1", - /*summary_index*/ 0, - "step one", - ), - ev_reasoning_item("", &["ignored"], &[]), - ev_function_call("call-1", "unknown_tool", "{}"), - ev_reasoning_summary_part_added("reasoning-1", /*summary_index*/ 1), - ev_reasoning_summary_text_delta( - "reasoning-1", - /*summary_index*/ 1, - "step two", - ), - ev_reasoning_item("reasoning-1", &["step one", "step two"], &[]), - ev_completed("resp-1"), - ]), - sse(vec![ - ev_message_item_added("message-1", ""), - ev_output_text_delta("Done"), - ev_assistant_message("message-1", "Done"), - ev_completed("resp-2"), - ]), - ], - ) - .await; + let stream = sse(vec![ + ev_response_created("resp-1"), + ev_reasoning_item_added("reasoning-1", &[""]), + ev_reasoning_summary_text_delta("step one"), + ev_reasoning_item("reasoning-1", &["step one"], &[]), + ev_completed("resp-1"), + ]); + mount_sse_once(&server, stream).await; codex .submit(Op::UserInput { @@ -1141,59 +1114,22 @@ async fn interleaved_reasoning_summary_events_keep_reasoning_item_metadata() -> }) .await?; - let mut reasoning_started = Vec::new(); - let mut reasoning_completed = Vec::new(); - let mut messages_started = Vec::new(); - let mut messages_completed = Vec::new(); - let mut summary_deltas = Vec::new(); - let mut summary_sections = Vec::new(); - loop { - match wait_for_event(&codex, |_| true).await { - EventMsg::ItemStarted(ItemStartedEvent { - item: TurnItem::Reasoning(item), - .. - }) => reasoning_started.push(item.id), - EventMsg::ItemCompleted(ItemCompletedEvent { - item: TurnItem::Reasoning(item), - .. - }) => reasoning_completed.push(item.id), - EventMsg::ItemStarted(ItemStartedEvent { - item: TurnItem::AgentMessage(item), - .. - }) => messages_started.push(item.id), - EventMsg::ItemCompleted(ItemCompletedEvent { - item: TurnItem::AgentMessage(item), - .. - }) => messages_completed.push(item.id), - EventMsg::ReasoningContentDelta(event) => { - summary_deltas.push((event.item_id, event.delta, event.summary_index)); - } - EventMsg::AgentReasoningSectionBreak(event) => { - summary_sections.push((event.item_id, event.summary_index)); - } - EventMsg::TurnComplete(_) => break, - _ => {} - } - } + let reasoning_item = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::Reasoning(item), + .. + }) => Some(item.clone()), + _ => None, + }) + .await; - assert_eq!(reasoning_started, vec!["reasoning-1"]); - assert_eq!(reasoning_completed, vec!["reasoning-1"]); - assert_eq!(messages_started, vec!["message-1"]); - assert_eq!(messages_completed, vec!["message-1"]); - assert_eq!( - summary_deltas, - vec![ - ("reasoning-1".to_string(), "step one".to_string(), 0), - ("reasoning-1".to_string(), "step two".to_string(), 1), - ] - ); - assert_eq!( - summary_sections, - vec![ - ("reasoning-1".to_string(), 0), - ("reasoning-1".to_string(), 1) - ] - ); + let delta_event = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ReasoningContentDelta(event) => Some(event.clone()), + _ => None, + }) + .await; + assert_eq!(delta_event.item_id, reasoning_item.id); + assert_eq!(delta_event.delta, "step one"); Ok(()) } diff --git a/codex-rs/core/tests/suite/otel.rs b/codex-rs/core/tests/suite/otel.rs index 060a9079a9..4a2b2993ad 100644 --- a/codex-rs/core/tests/suite/otel.rs +++ b/codex-rs/core/tests/suite/otel.rs @@ -821,7 +821,7 @@ async fn record_responses_sets_span_fields_for_response_events() { ev_message_item_added("msg-added", "hi there"), ev_reasoning_item_added("reasoning-1", &["summary"]), ev_output_text_delta("delta"), - ev_reasoning_summary_text_delta("reasoning-1", /*summary_index*/ 0, "summary-delta"), + ev_reasoning_summary_text_delta("summary-delta"), ev_reasoning_text_delta("raw-delta"), ev_function_call("call-1", "fn", "{\"key\":\"value\"}"), ev_assistant_message("msg-1", "agent"), diff --git a/codex-rs/tui/src/chatwidget/tests/history_replay.rs b/codex-rs/tui/src/chatwidget/tests/history_replay.rs index 300978cf4d..f8626dfcc0 100644 --- a/codex-rs/tui/src/chatwidget/tests/history_replay.rs +++ b/codex-rs/tui/src/chatwidget/tests/history_replay.rs @@ -995,30 +995,34 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; - handle_turn_started(&mut chat, "turn-1"); - while rx.try_recv().is_ok() {} - - handle_agent_reasoning_delta(&mut chat, "**Step one**\nFirst summary"); - handle_agent_message_delta(&mut chat, "Final answer"); - complete_assistant_message( - &mut chat, - "msg-1", - "Final answer", - Some(MessagePhase::FinalAnswer), - ); chat.handle_server_notification( - ServerNotification::ReasoningSummaryPartAdded( - codex_app_server_protocol::ReasoningSummaryPartAddedNotification { - thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), - turn_id: "turn-1".to_string(), - item_id: "reasoning-1".to_string(), - summary_index: 1, + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items_view: codex_app_server_protocol::TurnItemsView::Full, + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, }, - ), + }), + /*replay_kind*/ None, + ); + let _ = drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item_id: "reasoning-1".to_string(), + delta: "Summary only".to_string(), + summary_index: 0, + }), /*replay_kind*/ None, ); - handle_agent_reasoning_delta(&mut chat, "**Step two**\nSecond summary"); - handle_agent_reasoning_final(&mut chat); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { @@ -1027,35 +1031,20 @@ async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { completed_at_ms: 0, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), - summary: vec![ - "**Step one**\nFirst summary".to_string(), - "**Step two**\nSecond summary".to_string(), - ], + summary: vec!["Summary only".to_string()], content: Vec::new(), }, }), /*replay_kind*/ None, ); - let mut consolidated_answer = None; - let mut inserted_history = String::new(); - while let Ok(event) = rx.try_recv() { - match event { - AppEvent::ConsolidateAgentMessage { source, .. } => { - consolidated_answer = Some(source); - } - AppEvent::InsertHistoryCell(cell) => { - inserted_history.push_str(&lines_to_single_string( - &cell.transcript_lines(/*width*/ 80), - )); - } - _ => {} + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => { + lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) } - } - - assert_eq!(consolidated_answer.as_deref(), Some("Final answer")); - assert_eq!(inserted_history.matches("First summary").count(), 1); - assert_eq!(inserted_history.matches("Second summary").count(), 1); + other => panic!("expected InsertHistoryCell, got {other:?}"), + }; + assert_eq!(rendered.matches("Summary only").count(), 1); } #[tokio::test] From dbf67f34a0a37be77d12e2801575f33946f7d629 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 6 Jul 2026 10:17:15 -0700 Subject: [PATCH 06/76] Emit exec-policy warnings for freshly loaded thread config (#31253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why App-server initialization captures exec-policy parse warnings only once. Each `thread/start` then reloads config for that thread's cwd and rereads its `.rules` files, so rules that become malformed after initialization—or belong to a different project—are dropped without a fresh warning to the requesting client. ## What changed - Validate exec-policy rules against the freshly loaded per-thread config during `thread/start`. - Send the existing structured `configWarning` only to the connection that requested the thread. - Preserve nonfatal startup and avoid repeating an identical warning already delivered during initialization. This is intentionally separate from the TUI `legacy_core` cleanup in #31179 and the managed-requirements fallback fix in #31188. --- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/lib.rs | 19 +-- codex-rs/app-server/src/message_processor.rs | 3 +- .../request_processors/thread_processor.rs | 21 +++ .../suite/v2/connection_handling_websocket.rs | 103 ++++++++++++++ .../app-server/tests/suite/v2/thread_start.rs | 133 ++++++++++++++++++ 6 files changed, 271 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 43e7dbd3eb..1dfa6b4164 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1301,7 +1301,7 @@ Event notifications are the server-initiated event stream for thread lifecycles, Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`. -Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics. +Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics, or to the requesting connection during `thread/start` when that thread's exec-policy rules fail to parse. Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session. diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 0edb57d8a2..fa6b6cd811 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -320,6 +320,16 @@ fn exec_policy_warning_location(err: &ExecPolicyError) -> (Option, Optio } } +fn exec_policy_config_warning(err: &ExecPolicyError) -> ConfigWarningNotification { + let (path, range) = exec_policy_warning_location(err); + ConfigWarningNotification { + summary: "Error parsing rules; custom rules not applied.".to_string(), + details: Some(err.to_string()), + path, + range, + } +} + fn app_text_range(range: &CoreTextRange) -> AppTextRange { AppTextRange { start: AppTextPosition { @@ -612,14 +622,7 @@ pub async fn run_main_with_transport_options( } if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await { - let (path, range) = exec_policy_warning_location(&err); - let message = ConfigWarningNotification { - summary: "Error parsing rules; custom rules not applied.".to_string(), - details: Some(err.to_string()), - path, - range, - }; - config_warnings.push(message); + config_warnings.push(exec_policy_config_warning(&err)); } if let Some(warning) = project_config_warning(&config) { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 375307e804..6ae73784ff 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -447,7 +447,7 @@ impl MessageProcessor { outgoing.clone(), analytics_events_client.clone(), Arc::clone(&config), - config_warnings, + config_warnings.clone(), rpc_transport, ); let marketplace_processor = MarketplaceRequestProcessor::new( @@ -495,6 +495,7 @@ impl MessageProcessor { state_db.clone(), log_db, Arc::clone(&skills_watcher), + config_warnings, ); let turn_processor = TurnRequestProcessor::new( auth_manager.clone(), 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 b78c53a383..af8525873f 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -364,6 +364,7 @@ pub(crate) struct ThreadRequestProcessor { pub(super) log_db: Option, pub(super) background_tasks: TaskTracker, pub(super) skills_watcher: Arc, + pub(super) initial_config_warnings: Arc>, } /// Outcome of trying to satisfy a resume request from an already loaded thread. @@ -395,6 +396,7 @@ impl ThreadRequestProcessor { state_db: Option, log_db: Option, skills_watcher: Arc, + initial_config_warnings: Vec, ) -> Self { Self { auth_manager, @@ -413,6 +415,7 @@ impl ThreadRequestProcessor { log_db, background_tasks: TaskTracker::new(), skills_watcher, + initial_config_warnings: Arc::new(initial_config_warnings), } } @@ -968,6 +971,7 @@ impl ThreadRequestProcessor { }; let request_trace = request_context.request_trace(); let config_manager = self.config_manager.clone(); + let initial_config_warnings = Arc::clone(&self.initial_config_warnings); let outgoing = Arc::clone(&listener_task_context.outgoing); let error_request_id = request_id.clone(); let thread_start_task = async move { @@ -990,6 +994,7 @@ impl ThreadRequestProcessor { allow_provider_model_fallback, experimental_raw_events, request_trace, + initial_config_warnings, ) .await { @@ -1066,6 +1071,7 @@ impl ThreadRequestProcessor { allow_provider_model_fallback: bool, experimental_raw_events: bool, request_trace: Option, + initial_config_warnings: Arc>, ) -> Result<(), JSONRPCErrorError> { let thread_start_started_at = std::time::Instant::now(); let requested_cwd = typesafe_overrides.cwd.clone(); @@ -1139,6 +1145,21 @@ impl ThreadRequestProcessor { .map_err(|err| config_load_error(&err))?; } + if let Ok(Some(err)) = + codex_core::check_execpolicy_for_warnings(&config.config_layer_stack).await + { + let notification = crate::exec_policy_config_warning(&err); + if !initial_config_warnings.contains(¬ification) { + listener_task_context + .outgoing + .send_server_notification_to_connections( + &[request_id.connection_id], + ServerNotification::ConfigWarning(notification), + ) + .await; + } + } + let environments = environments.unwrap_or_else(|| { listener_task_context .thread_manager diff --git a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs index 648d60f3b5..ece66c8405 100644 --- a/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs +++ b/codex-rs/app-server/tests/suite/v2/connection_handling_websocket.rs @@ -7,6 +7,7 @@ use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; @@ -18,6 +19,8 @@ use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; use futures::SinkExt; use futures::StreamExt; use hmac::Hmac; @@ -104,6 +107,106 @@ async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Ok(()) } +#[tokio::test] +async fn thread_start_routes_project_exec_policy_warning_to_requester() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let project = TempDir::new()?; + std::fs::create_dir(project.path().join(".git"))?; + let rules_dir = project.path().join(".codex/rules"); + std::fs::create_dir_all(&rules_dir)?; + let rules_path = rules_dir.join("broken.rules"); + std::fs::write(&rules_path, "prefix_rule(")?; + set_project_trust_level(codex_home.path(), project.path(), TrustLevel::Trusted)?; + + let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; + let mut requester = connect_websocket(bind_addr).await?; + let mut other_client = connect_websocket(bind_addr).await?; + + send_initialize_request(&mut requester, /*id*/ 1, "requester").await?; + read_response_for_id(&mut requester, /*id*/ 1).await?; + send_initialize_request(&mut other_client, /*id*/ 2, "other_client").await?; + read_response_for_id(&mut other_client, /*id*/ 2).await?; + + send_request( + &mut requester, + "thread/start", + /*id*/ 3, + Some(serde_json::to_value(ThreadStartParams { + cwd: Some(project.path().display().to_string()), + model: Some("mock-model".to_string()), + ..Default::default() + })?), + ) + .await?; + + let target_id = RequestId::Integer(3); + let warning_summary = "Error parsing rules; custom rules not applied."; + let is_exec_policy_warning = |notification: &JSONRPCNotification| { + notification.method == "configWarning" + && notification + .params + .as_ref() + .and_then(|params| params.get("summary")) + .and_then(serde_json::Value::as_str) + == Some(warning_summary) + }; + let mut response = None; + let mut warning = None; + while response.is_none() || warning.is_none() { + match read_jsonrpc_message(&mut requester).await? { + JSONRPCMessage::Response(candidate) if candidate.id == target_id => { + response = Some(candidate); + } + JSONRPCMessage::Notification(candidate) if is_exec_policy_warning(&candidate) => { + warning = Some(candidate); + } + _ => {} + } + } + + let _: ThreadStartResponse = to_response(response.context("missing thread/start response")?)?; + let warning: ConfigWarningNotification = serde_json::from_value( + warning + .context("missing exec-policy configWarning")? + .params + .context("configWarning should include params")?, + )?; + assert_eq!( + warning + .path + .as_deref() + .map(Path::new) + .and_then(Path::file_name), + Some(std::ffi::OsStr::new("broken.rules")) + ); + + match timeout(Duration::from_millis(250), async { + loop { + let message = read_jsonrpc_message(&mut other_client).await?; + if let JSONRPCMessage::Notification(notification) = message + && is_exec_policy_warning(¬ification) + { + return Ok::<_, anyhow::Error>(notification); + } + } + }) + .await + { + Ok(Ok(_)) => bail!("exec-policy configWarning leaked to another connection"), + Ok(Err(err)) => return Err(err), + Err(_) => {} + } + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + #[tokio::test] async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Result<()> { let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index f717dd0014..f96653b02a 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -7,14 +7,18 @@ use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::AskForApproval; +use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; +use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; +use codex_app_server_protocol::TextPosition; +use codex_app_server_protocol::TextRange; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadSource; use codex_app_server_protocol::ThreadStartParams; @@ -54,6 +58,17 @@ use super::analytics::wait_for_analytics_payload; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const INVALID_REQUEST_ERROR_CODE: i64 = -32600; +const EXEC_POLICY_PARSE_WARNING_SUMMARY: &str = "Error parsing rules; custom rules not applied."; + +fn is_exec_policy_config_warning(notification: &JSONRPCNotification) -> bool { + notification.method == "configWarning" + && notification + .params + .as_ref() + .and_then(|params| params.get("summary")) + .and_then(Value::as_str) + == Some(EXEC_POLICY_PARSE_WARNING_SUMMARY) +} async fn start_thread_with_model( mcp: &mut TestAppServer, @@ -104,6 +119,124 @@ model = "gpt-5.4-mini" Ok(()) } +#[tokio::test] +async fn thread_start_warns_for_exec_policy_parse_failure_after_initialize() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let rules_dir = codex_home.path().join("rules"); + std::fs::create_dir_all(&rules_dir)?; + let rules_path = rules_dir.join("broken.rules"); + std::fs::write(&rules_path, "prefix_rule(")?; + let rules_path = std::fs::canonicalize(rules_path)?; + + let request_id = mcp + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadStartResponse = to_response(response)?; + + let notification = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification( + "exec-policy configWarning", + is_exec_policy_config_warning, + ), + ) + .await??; + let notification: ServerNotification = notification.try_into()?; + let ServerNotification::ConfigWarning(warning) = notification else { + anyhow::bail!("unexpected notification variant"); + }; + let ConfigWarningNotification { + summary, + details, + path, + range, + } = warning; + assert_eq!( + (summary, range), + ( + "Error parsing rules; custom rules not applied.".to_string(), + Some(TextRange { + start: TextPosition { + line: 1, + column: 13, + }, + end: TextPosition { + line: 1, + column: 13, + }, + }), + ) + ); + let path = path.context("warning should include a path")?; + assert_eq!( + normalize_path_for_comparison(path), + normalize_path_for_comparison(&rules_path) + ); + let details = details.context("warning should include details")?; + assert!( + details.contains("failed to parse rules file") && details.contains("broken.rules"), + "unexpected warning details: {details}" + ); + assert!( + details.contains("Parse error"), + "unexpected warning details: {details}" + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_does_not_repeat_initialize_exec_policy_warning() -> Result<()> { + let codex_home = TempDir::new()?; + let rules_dir = codex_home.path().join("rules"); + std::fs::create_dir_all(&rules_dir)?; + std::fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?; + + let mut mcp = TestAppServer::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_matching_notification( + "initialize exec-policy configWarning", + is_exec_policy_config_warning, + ), + ) + .await??; + + let request_id = mcp + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let _: ThreadStartResponse = to_response(response)?; + + let duplicate_warning = timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_matching_notification( + "duplicate exec-policy configWarning", + is_exec_policy_config_warning, + ), + ) + .await; + assert!( + duplicate_warning.is_err(), + "thread/start repeated the initialize exec-policy warning" + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_provider_model_fallback_uses_bedrock_static_catalog() -> Result<()> { let codex_home = TempDir::new()?; From 8f5bb6171e0b6a64ed01d9b7cf865604ad79ec46 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 6 Jul 2026 10:22:25 -0700 Subject: [PATCH 07/76] Remove TUI exec-policy core exports (#31179) ## Why The TUI still reached through `codex_app_server_client::legacy_core` to validate exec-policy rules during local startup. The app server already owns this validation and reports parse failures through `configWarning`, so the duplicate TUI preflight preserved an unnecessary core dependency and could inspect the wrong machine when connected to a remote app server. ## What changed - Remove the TUI's direct exec-policy startup check. - Remove the two exec-policy re-exports from `legacy_core`. - Rely on the app server's existing config-warning flow for malformed custom rules in both embedded and remote sessions. --- codex-rs/app-server-client/src/lib.rs | 3 --- codex-rs/app-server/src/in_process.rs | 12 +++++++++++- codex-rs/tui/src/lib.rs | 14 -------------- 3 files changed, 11 insertions(+), 18 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index b7a958eff1..4eec8a2348 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -78,9 +78,6 @@ pub use crate::remote::RemoteAppServerEndpoint; /// module exists so clients can remove a direct `codex-core` dependency /// while legacy startup/config paths are migrated to RPCs. pub mod legacy_core { - pub use codex_core::check_execpolicy_for_warnings; - pub use codex_core::format_exec_policy_error_with_source; - pub mod config { pub use codex_core::config::*; diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 938d539a9f..d8a2bc2276 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -80,6 +80,7 @@ use codex_arg0::Arg0DispatchPaths; use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_config::ThreadConfigLoader; +use codex_core::check_execpolicy_for_warnings; use codex_core::config::Config; use codex_core::resolve_installation_id; use codex_exec_server::EnvironmentManager; @@ -349,7 +350,16 @@ impl InProcessClientHandle { /// This function sends `initialize` followed by `initialized` before returning /// the handle, so callers receive a ready-to-use runtime. If initialize fails, /// the runtime is shut down and an `InvalidData` error is returned. -pub async fn start(args: InProcessStartArgs) -> IoResult { +pub async fn start(mut args: InProcessStartArgs) -> IoResult { + if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await { + let (path, range) = crate::exec_policy_warning_location(&err); + args.config_warnings.push(ConfigWarningNotification { + summary: "Error parsing rules; custom rules not applied.".to_string(), + details: Some(err.to_string()), + path, + range, + }); + } let initialize = args.initialize.clone(); let client = start_uninitialized(args).await?; diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 96f442a88c..dc8262f7db 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -3,7 +3,6 @@ // alternate‑screen mode starts; that file opts‑out locally via `allow`. #![deny(clippy::print_stdout, clippy::print_stderr)] #![deny(clippy::disallowed_methods)] -use crate::legacy_core::check_execpolicy_for_warnings; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; @@ -13,7 +12,6 @@ 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; use crate::session_resume::ResolveCwdOutcome; use crate::session_resume::resolve_cwd_for_resume_or_fork; pub use crate::startup_error::LocalStateDbStartupError; @@ -1141,18 +1139,6 @@ pub async fn run_main( .as_table() .is_some_and(|table| table.contains_key("log_dir")); - #[allow(clippy::print_stderr)] - match check_execpolicy_for_warnings(&config.config_layer_stack).await { - Ok(None) => {} - Ok(Some(err)) | Err(err) => { - eprintln!( - "Error loading rules:\n{}", - format_exec_policy_error_with_source(&err) - ); - std::process::exit(1); - } - } - set_default_client_residency_requirement(config.enforce_residency.value()); if let Some(warning) = add_dir_warning_message( From 8268cbfb0e5f39cb4efff928264fe8f29ddacafb Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 6 Jul 2026 10:27:39 -0700 Subject: [PATCH 08/76] Conditional codex_home dotenv (#29959) ## Summary Adds conditional dotenv overlays under `CODEX_HOME`. After loading the current `.env`, Codex discovers `.env.*` files in lexicographic order and applies each overlay when its TCP condition passes. Evaluation and environment mutation occur during single-threaded startup, before Codex creates its runtime, workers, sessions, or network clients. ## Supported behavior - TCP connectivity checks using either: - Explicit `host` and `port`. - A URL or authority stored in an overlay assignment referenced by `from`. - Direct negation of a TCP check using `not`. - Setting dotenv assignments when a condition passes. - Unsetting variables with `# codex-env-unset`. - A default 500 ms connection timeout with a maximum of 5 seconds. - Ignores filenames ending in `~` or a case-insensitive final suffix of `bak`, `back`, `backup`, `bkp`, `old`, `orig`, `original`, `save`, `saved`, `disable`, `disabled`, `inactive`, `off`, `tmp`, `temp`, `swp`, `swo`, `example`, `sample`, `template`, or `dist`. - Fail-closed handling of malformed overlays without exposing environment values. - Case-insensitive protection against setting or unsetting `CODEX_*` variables. Files without a `# codex-env-if:` directive as their first non-empty line are ignored. ## Usage Set variables when an endpoint is reachable: ```dotenv # ~/.codex/.env.10-proxy-on # codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500} HTTPS_PROXY=http://proxy.example.com:8080 HTTP_PROXY=http://proxy.example.com:8080 ALL_PROXY=http://proxy.example.com:8080 NO_PROXY=localhost,127.0.0.1,.example.com ``` Unset variables when the endpoint is unreachable: ```dotenv # ~/.codex/.env.20-proxy-off # codex-env-if: {"not":{"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":500}} # codex-env-unset: ["HTTPS_PROXY","HTTP_PROXY","ALL_PROXY","NO_PROXY"] ``` Each overlay is evaluated independently. A full Codex restart is required after changing overlays or moving between networks. The timeout bounds TCP connection attempts but does not bound synchronous DNS resolution. ## Testing ```console just test -p codex-arg0 ``` For manual validation: 1. Configure an overlay with a reachable TCP endpoint and a test assignment. 2. Start Codex and verify the assignment is present in a spawned command. 3. Restart Codex with the endpoint unreachable and verify a negated overlay removes inherited variables. 4. Verify malformed overlays are skipped and `CODEX_*` variables remain unchanged. ## Future ideas: - File-existence conditions. - Environment-variable equality conditions. - Operating-system conditions. - General condition composition with `all`, `any`, and arbitrarily nested `not`. --- codex-rs/Cargo.lock | 3 + codex-rs/arg0/Cargo.toml | 5 +- .../conditional_dotenv_tests.rs | 281 +++++++++++ codex-rs/arg0/src/conditional_dotenv/mod.rs | 465 ++++++++++++++++++ codex-rs/arg0/src/lib.rs | 24 +- 5 files changed, 771 insertions(+), 7 deletions(-) create mode 100644 codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs create mode 100644 codex-rs/arg0/src/conditional_dotenv/mod.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6ac51d403f..e590d17497 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2227,8 +2227,11 @@ dependencies = [ "codex-windows-sandbox", "dotenvy", "pretty_assertions", + "serde", + "serde_json", "tempfile", "tokio", + "url", ] [[package]] diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index bb45db4552..400e3fbb9c 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -23,8 +23,11 @@ codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["rt-multi-thread"] } +tokio = { workspace = true, features = ["net", "rt-multi-thread", "time"] } +url = { workspace = true } [target.'cfg(windows)'.dependencies] codex-windows-sandbox = { workspace = true } diff --git a/codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs b/codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs new file mode 100644 index 0000000000..f4b5dfb1e6 --- /dev/null +++ b/codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs @@ -0,0 +1,281 @@ +use super::*; +use pretty_assertions::assert_eq; +use std::cell::Cell; +use std::cell::RefCell; +use std::collections::BTreeMap; +use std::fs; +use std::time::Duration; + +#[derive(Debug, Default, PartialEq, Eq)] +struct TestEnvironment(BTreeMap); + +impl StartupEnvironment for TestEnvironment { + fn set(&mut self, key: &str, value: &str) { + self.0.insert(key.to_string(), value.to_string()); + } + + fn remove(&mut self, key: &str) { + self.0.remove(key); + } +} + +fn write_overlay(codex_home: &Path, name: &str, contents: &str) -> std::io::Result<()> { + fs::write(codex_home.join(name), contents) +} + +fn test_environment(values: [(&str, &str); N]) -> TestEnvironment { + TestEnvironment(BTreeMap::from( + values.map(|(key, value)| (key.to_string(), value.to_string())), + )) +} + +#[test] +fn proxy_overlays_set_or_unset_environment_for_network() -> anyhow::Result<()> { + let codex_home = tempfile::tempdir()?; + write_overlay( + codex_home.path(), + ".env.10-proxy-on", + r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500} +HTTPS_PROXY=http://user:password@proxy.example.com:8080 +HTTP_PROXY=http://proxy.example.com:8080 +ALL_PROXY=http://proxy.example.com:8080 +NO_PROXY=localhost,127.0.0.1,.example.com +"#, + )?; + write_overlay( + codex_home.path(), + ".env.20-proxy-off", + r#"# codex-env-if: {"not":{"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":500}} +# codex-env-unset: ["HTTPS_PROXY","HTTP_PROXY","ALL_PROXY","NO_PROXY"] +"#, + )?; + + let on_network_calls = Cell::new(0); + let on_network_connector = |host: &str, port: u16, timeout: Duration| { + on_network_calls.set(on_network_calls.get() + 1); + assert_eq!(host, "proxy.example.com"); + assert_eq!(port, 8080); + assert_eq!(timeout, Duration::from_millis(500)); + true + }; + let mut on_network_environment = test_environment([ + ("HTTPS_PROXY", "http://stale.example.com:8080"), + ("UNRELATED", "preserved"), + ]); + + let warnings = load_conditional_dotenv_overlays( + codex_home.path(), + &mut on_network_environment, + &on_network_connector, + ); + + assert_eq!(on_network_calls.get(), 2); + assert_eq!(warnings, Vec::::new()); + assert_eq!( + on_network_environment, + test_environment([ + ("ALL_PROXY", "http://proxy.example.com:8080"), + ("HTTPS_PROXY", "http://user:password@proxy.example.com:8080"), + ("HTTP_PROXY", "http://proxy.example.com:8080"), + ("NO_PROXY", "localhost,127.0.0.1,.example.com"), + ("UNRELATED", "preserved"), + ]) + ); + + let off_network_calls = Cell::new(0); + let off_network_connector = |host: &str, port: u16, timeout: Duration| { + off_network_calls.set(off_network_calls.get() + 1); + assert_eq!(host, "proxy.example.com"); + assert_eq!(port, 8080); + assert_eq!(timeout, Duration::from_millis(500)); + false + }; + let mut off_network_environment = test_environment([ + ("ALL_PROXY", "http://inherited.example.com:8080"), + ("HTTPS_PROXY", "http://inherited.example.com:8080"), + ("HTTP_PROXY", "http://inherited.example.com:8080"), + ("NO_PROXY", "localhost"), + ("UNRELATED", "preserved"), + ]); + + let warnings = load_conditional_dotenv_overlays( + codex_home.path(), + &mut off_network_environment, + &off_network_connector, + ); + + assert_eq!(off_network_calls.get(), 2); + assert_eq!(warnings, Vec::::new()); + assert_eq!( + off_network_environment, + test_environment([("UNRELATED", "preserved")]) + ); + Ok(()) +} + +#[test] +fn overlay_filters_reserved_names_for_set_and_unset() -> anyhow::Result<()> { + let codex_home = tempfile::tempdir()?; + write_overlay( + codex_home.path(), + ".env.proxy", + r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080} +# codex-env-unset: ["ALL_PROXY","codex_internal"] +HTTP_PROXY=http://proxy.example.com:8080 +codex_internal=changed +"#, + )?; + let mut environment = test_environment([ + ("ALL_PROXY", "http://old.example.com:8080"), + ("codex_internal", "preserved"), + ]); + + let warnings = + load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &|_, _, _| true); + + assert_eq!(warnings, Vec::::new()); + assert_eq!( + environment, + test_environment([ + ("HTTP_PROXY", "http://proxy.example.com:8080"), + ("codex_internal", "preserved"), + ]) + ); + Ok(()) +} + +#[test] +fn invalid_timeout_fails_closed_without_blocking_valid_overlays() -> anyhow::Result<()> { + let codex_home = tempfile::tempdir()?; + write_overlay(codex_home.path(), ".env.example", "SHOULD_NOT_LOAD=1\n")?; + write_overlay( + codex_home.path(), + ".env.10-timeout", + r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":5001} +SHOULD_NOT_LOAD=2 +"#, + )?; + write_overlay( + codex_home.path(), + ".env.20-maximum-timeout", + r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":5000} +MAXIMUM_TIMEOUT=loaded +"#, + )?; + write_overlay( + codex_home.path(), + ".env.30-default-timeout", + r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080} +DEFAULT_TIMEOUT=loaded +"#, + )?; + let observed_timeouts = RefCell::new(Vec::new()); + let connector = |_: &str, _: u16, timeout: Duration| { + observed_timeouts.borrow_mut().push(timeout); + true + }; + let mut environment = TestEnvironment::default(); + + let warnings = + load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &connector); + + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains(".env.10-timeout")); + assert_eq!( + observed_timeouts.into_inner(), + vec![Duration::from_millis(5000), Duration::from_millis(500)] + ); + assert_eq!( + environment, + test_environment([("DEFAULT_TIMEOUT", "loaded"), ("MAXIMUM_TIMEOUT", "loaded"),]) + ); + Ok(()) +} + +#[test] +fn invalid_from_sources_fail_closed_without_connecting() -> anyhow::Result<()> { + let codex_home = tempfile::tempdir()?; + write_overlay( + codex_home.path(), + ".env.10-missing-source", + r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY"} +SHOULD_NOT_LOAD=1 +"#, + )?; + write_overlay( + codex_home.path(), + ".env.20-invalid-source", + r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY"} +HTTPS_PROXY="not a url" +"#, + )?; + let connector_called = Cell::new(false); + let connector = |_: &str, _: u16, _: Duration| { + connector_called.set(true); + true + }; + let mut environment = TestEnvironment::default(); + + let warnings = + load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &connector); + + assert!(!connector_called.get()); + assert_eq!(warnings.len(), 2); + assert_eq!(environment, TestEnvironment::default()); + Ok(()) +} + +#[test] +fn directives_inside_multiline_values_are_not_scanned() -> anyhow::Result<()> { + let codex_home = tempfile::tempdir()?; + write_overlay( + codex_home.path(), + ".env.multiline", + r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080} +# codex-env-unset: ["HTTP_PROXY"] +MESSAGE='before +# codex-env-unset: ["ALL_PROXY"] +# codex-env-if: {"not":{"type":"tcp_connect","host":"other.example.com","port":8080}} +after' +"#, + )?; + let mut environment = test_environment([ + ("ALL_PROXY", "http://proxy.example.com:8080"), + ("HTTP_PROXY", "http://proxy.example.com:8080"), + ]); + + let warnings = + load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &|_, _, _| true); + + assert_eq!(warnings, Vec::::new()); + assert_eq!( + environment, + test_environment([ + ("ALL_PROXY", "http://proxy.example.com:8080"), + ( + "MESSAGE", + "before\n# codex-env-unset: [\"ALL_PROXY\"]\n# codex-env-if: {\"not\":{\"type\":\"tcp_connect\",\"host\":\"other.example.com\",\"port\":8080}}\nafter", + ), + ]) + ); + Ok(()) +} + +#[test] +fn endpoint_parser_supports_urls_bare_authorities_and_known_default_ports() { + assert_eq!( + [ + "http://user:password@proxy.example.com:8080", + "proxy.example.com:8080", + "https://proxy.example.com", + "http://[::1]:8080", + ] + .map(parse_endpoint), + [ + Some(("proxy.example.com".to_string(), 8080)), + Some(("proxy.example.com".to_string(), 8080)), + Some(("proxy.example.com".to_string(), 443)), + Some(("::1".to_string(), 8080)), + ] + ); +} diff --git a/codex-rs/arg0/src/conditional_dotenv/mod.rs b/codex-rs/arg0/src/conditional_dotenv/mod.rs new file mode 100644 index 0000000000..c3ed40179e --- /dev/null +++ b/codex-rs/arg0/src/conditional_dotenv/mod.rs @@ -0,0 +1,465 @@ +//! Loads TCP-gated dotenv overlays from `CODEX_HOME` during single-threaded startup. +//! +//! Example `~/.codex/.env.corporate-proxy`: +//! +//! ```dotenv +//! # codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500} +//! HTTPS_PROXY=http://proxy.example.com:8080 +//! HTTP_PROXY=http://proxy.example.com:8080 +//! ALL_PROXY=http://proxy.example.com:8080 +//! NO_PROXY=localhost,127.0.0.1,.example.com +//! ``` +//! +//! A TCP condition can be inverted with `{"not":{"type":"tcp_connect",...}}`, which allows a +//! second overlay to unset proxy variables when the endpoint is unreachable. + +use crate::is_reserved_env_var; +use serde::Deserialize; +use std::ffi::OsString; +use std::net::ToSocketAddrs; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Stdio; +use std::time::Duration; +use std::time::Instant; +use url::Url; + +pub(crate) const TCP_CONNECT_HELPER_ARG1: &str = "--codex-run-as-tcp-connect-probe"; + +const CONDITIONAL_DOTENV_PREFIX: &str = ".env."; +const CONDITION_DIRECTIVE_PREFIX: &str = "# codex-env-if:"; +const UNSET_DIRECTIVE_PREFIX: &str = "# codex-env-unset:"; +const DEFAULT_TCP_CONNECT_TIMEOUT_MS: u64 = 500; +const MAX_TCP_CONNECT_TIMEOUT_MS: u64 = 5_000; +const TCP_CONNECT_HELPER_POLL_INTERVAL: Duration = Duration::from_millis(10); +const IGNORED_CONDITIONAL_DOTENV_SUFFIXES: &[&str] = &[ + "bak", "back", "backup", "bkp", "old", "orig", "original", "save", "saved", "disable", + "disabled", "inactive", "off", "tmp", "temp", "swp", "swo", "example", "sample", "template", + "dist", +]; + +type TcpConnector<'a> = dyn Fn(&str, u16, Duration) -> bool + 'a; + +pub(crate) fn load(codex_home: &Path) { + let mut environment = ProcessEnvironment; + + for warning in + load_conditional_dotenv_overlays(codex_home, &mut environment, &system_tcp_connect) + { + eprintln!("WARNING: skipped conditional dotenv overlay: {warning}"); + } +} + +/// Runs an OS-resolved TCP probe in the child process used by [`system_tcp_connect`]. +/// +/// This exits before dotenv loading, so its resolver and connection tasks cannot race with the +/// parent's environment mutation. +pub(crate) fn run_tcp_connect_helper(mut args: impl Iterator) -> ! { + let host = args.next().and_then(|value| value.into_string().ok()); + let port = args + .next() + .and_then(|value| value.into_string().ok()) + .and_then(|value| value.parse::().ok()); + let timeout = args + .next() + .and_then(|value| value.into_string().ok()) + .and_then(|value| value.parse::().ok()) + .and_then(|timeout_ms| tcp_connect_timeout(Some(timeout_ms)).ok()); + let connected = match (host, port, timeout) { + (Some(host), Some(port), Some(timeout)) => direct_tcp_connect(&host, port, timeout), + _ => false, + }; + let exit_code = if connected { 0 } else { 1 }; + std::process::exit(exit_code); +} + +fn load_conditional_dotenv_overlays( + codex_home: &Path, + environment: &mut dyn StartupEnvironment, + tcp_connect: &TcpConnector<'_>, +) -> Vec { + let paths = match conditional_dotenv_paths(codex_home) { + Ok(paths) => paths, + Err(err) => { + return vec![format!( + "could not discover overlays in {}: {err}", + codex_home.display() + )]; + } + }; + let mut warnings = Vec::new(); + + for path in paths { + let overlay = match parse_conditional_dotenv(&path) { + Ok(Some(overlay)) => overlay, + Ok(None) => continue, + Err(err) => { + warnings.push(format!("{}: {err}", path.display())); + continue; + } + }; + match evaluate_condition(&overlay.condition, &overlay.entries, tcp_connect) { + Ok(true) => apply_overlay(overlay, environment), + Ok(false) => {} + Err(err) => warnings.push(format!("{}: {err}", path.display())), + } + } + + warnings +} + +fn conditional_dotenv_paths(codex_home: &Path) -> std::io::Result> { + let entries = match std::fs::read_dir(codex_home) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(err) => return Err(err), + }; + let mut paths = Vec::new(); + + for entry in entries { + let entry = entry?; + let file_name = entry.file_name(); + let Some(file_name) = file_name.to_str() else { + continue; + }; + let is_ignored = file_name.ends_with('~') + || file_name.rsplit_once('.').is_some_and(|(_, suffix)| { + IGNORED_CONDITIONAL_DOTENV_SUFFIXES + .iter() + .any(|ignored| suffix.eq_ignore_ascii_case(ignored)) + }); + if file_name.starts_with(CONDITIONAL_DOTENV_PREFIX) + && file_name.len() > CONDITIONAL_DOTENV_PREFIX.len() + && !is_ignored + && entry.path().is_file() + { + paths.push(entry.path()); + } + } + + paths.sort(); + Ok(paths) +} + +fn parse_conditional_dotenv(path: &Path) -> Result, String> { + let contents = + std::fs::read_to_string(path).map_err(|err| format!("could not read file: {err}"))?; + let contents = contents.strip_prefix('\u{feff}').unwrap_or(&contents); + let mut lines = contents + .lines() + .map(|line| line.trim_start_matches('\u{feff}').trim()); + let Some(first_line) = lines.find(|line| !line.is_empty()) else { + return Ok(None); + }; + let Some(condition_json) = first_line.strip_prefix(CONDITION_DIRECTIVE_PREFIX) else { + return Ok(None); + }; + let condition_json = condition_json.trim(); + if condition_json.is_empty() { + return Err("condition directive is empty".to_string()); + } + let condition = serde_json::from_str(condition_json) + .map_err(|err| format!("condition directive is invalid: {err}"))?; + + let mut unset = None; + for line in lines { + if !line.is_empty() && !line.starts_with('#') { + break; + } + if line.starts_with(CONDITION_DIRECTIVE_PREFIX) { + return Err("exactly one condition directive is required".to_string()); + } + if let Some(unset_json) = line.strip_prefix(UNSET_DIRECTIVE_PREFIX) { + if unset.is_some() { + return Err("multiple unset directives are not supported".to_string()); + } + let keys: Vec = serde_json::from_str(unset_json.trim()) + .map_err(|err| format!("unset directive is invalid: {err}"))?; + if let Some(key) = keys.iter().find(|key| !is_valid_env_var_name(key)) { + return Err(format!( + "unset directive contains invalid environment variable name `{key}`" + )); + } + unset = Some(keys); + } + } + + let entries = dotenvy::from_read_iter(contents.as_bytes()) + .collect::, _>>() + .map_err(|_| "file contains an invalid dotenv assignment".to_string())?; + if entries.iter().any(|(_, value)| value.contains('\0')) { + return Err("file contains a dotenv value with a NUL byte".to_string()); + } + + Ok(Some(ConditionalDotenv { + condition, + unset: unset.unwrap_or_default(), + entries, + })) +} + +fn evaluate_condition( + condition: &Condition, + overlay_entries: &[(String, String)], + tcp_connect: &TcpConnector<'_>, +) -> Result { + match condition { + Condition::TcpConnect(condition) => { + evaluate_tcp_connect(condition, overlay_entries, tcp_connect) + } + Condition::Not { not } => { + evaluate_tcp_connect(not, overlay_entries, tcp_connect).map(|connected| !connected) + } + } +} + +fn evaluate_tcp_connect( + condition: &TcpConnectCondition, + overlay_entries: &[(String, String)], + tcp_connect: &TcpConnector<'_>, +) -> Result { + match condition { + TcpConnectCondition::TcpConnect { + from, + host, + port, + timeout_ms, + } => { + let target = parse_tcp_connect_target(from.as_deref(), host.as_deref(), *port)?; + let (host, port) = match target { + TcpConnectTarget::FromVariable(from) => { + let value = overlay_entries + .iter() + .rev() + .find_map(|(key, value)| { + (key == from || (cfg!(windows) && key.eq_ignore_ascii_case(from))) + .then_some(value) + }) + .ok_or_else(|| { + format!( + "tcp_connect source variable `{from}` is not defined in the overlay" + ) + })?; + parse_endpoint(value).ok_or_else(|| { + format!( + "tcp_connect source variable `{from}` does not contain a valid endpoint" + ) + })? + } + TcpConnectTarget::Explicit { host, port } => (host.to_string(), port), + }; + let timeout = tcp_connect_timeout(*timeout_ms)?; + Ok((tcp_connect)(&host, port, timeout)) + } + } +} + +fn apply_overlay(overlay: ConditionalDotenv, environment: &mut dyn StartupEnvironment) { + for key in overlay.unset { + if !is_reserved_env_var(&key) { + environment.remove(&key); + } + } + for (key, value) in overlay.entries { + if !is_reserved_env_var(&key) { + environment.set(&key, &value); + } + } +} + +enum TcpConnectTarget<'a> { + FromVariable(&'a str), + Explicit { host: &'a str, port: u16 }, +} + +fn parse_tcp_connect_target<'a>( + from: Option<&'a str>, + host: Option<&'a str>, + port: Option, +) -> Result, String> { + match (from, host, port) { + (Some(from), None, None) => { + if !is_valid_env_var_name(from) { + return Err(format!( + "tcp_connect contains invalid source variable name `{from}`" + )); + } + Ok(TcpConnectTarget::FromVariable(from)) + } + (None, Some(host), Some(port)) => { + if host.is_empty() { + return Err("tcp_connect host must not be empty".to_string()); + } + if port == 0 { + return Err("tcp_connect port must be greater than zero".to_string()); + } + Ok(TcpConnectTarget::Explicit { host, port }) + } + (Some(_), _, _) => Err( + "tcp_connect must specify either `from` or `host` and `port`, but not both".to_string(), + ), + (None, _, _) => { + Err("tcp_connect requires either `from` or both `host` and `port`".to_string()) + } + } +} + +fn tcp_connect_timeout(timeout_ms: Option) -> Result { + let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS); + if !(1..=MAX_TCP_CONNECT_TIMEOUT_MS).contains(&timeout_ms) { + return Err(format!( + "tcp_connect timeout_ms must be between 1 and {MAX_TCP_CONNECT_TIMEOUT_MS}" + )); + } + Ok(Duration::from_millis(timeout_ms)) +} + +fn parse_endpoint(value: &str) -> Option<(String, u16)> { + let value = value.trim(); + let parsed = if value.contains("://") { + Url::parse(value).ok()? + } else { + Url::parse(&format!("http://{value}")).ok()? + }; + let host = match parsed.host()? { + url::Host::Ipv6(address) => address.to_string(), + host => host.to_string(), + }; + let port = parsed.port_or_known_default()?; + (port != 0).then_some((host, port)) +} + +fn system_tcp_connect(host: &str, port: u16, timeout: Duration) -> bool { + // `getaddrinfo` cannot be cancelled portably. Isolate it in a child that can be killed and + // reaped without introducing a resolver thread into the environment-mutating parent. + let Ok(current_exe) = std::env::current_exe() else { + return false; + }; + let Ok(mut child) = Command::new(current_exe) + .arg(TCP_CONNECT_HELPER_ARG1) + .arg(host) + .arg(port.to_string()) + .arg(timeout.as_millis().to_string()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + else { + return false; + }; + + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) => {} + Err(_) => break, + } + + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + break; + } + std::thread::sleep(remaining.min(TCP_CONNECT_HELPER_POLL_INTERVAL)); + } + + let _ = child.kill(); + let _ = child.wait(); + false +} + +fn direct_tcp_connect(host: &str, port: u16, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + let Ok(addresses) = (host, port) + .to_socket_addrs() + .map(Iterator::collect::>) + else { + return false; + }; + if deadline.saturating_duration_since(Instant::now()).is_zero() { + return false; + } + + let Ok(runtime) = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + else { + return false; + }; + runtime.block_on(async move { + tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), async move { + let mut attempts = tokio::task::JoinSet::new(); + for address in addresses { + attempts.spawn(tokio::net::TcpStream::connect(address)); + } + while let Some(result) = attempts.join_next().await { + if matches!(result, Ok(Ok(_))) { + return true; + } + } + false + }) + .await + .unwrap_or(false) + }) +} + +fn is_valid_env_var_name(key: &str) -> bool { + let mut chars = key.chars(); + chars + .next() + .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') + && chars.all(|character| { + character.is_ascii_alphanumeric() || character == '_' || character == '.' + }) +} + +/// Minimal environment interface used to test overlay application without mutating the test +/// process environment. +trait StartupEnvironment { + fn set(&mut self, key: &str, value: &str); + fn remove(&mut self, key: &str); +} + +struct ProcessEnvironment; + +impl StartupEnvironment for ProcessEnvironment { + fn set(&mut self, key: &str, value: &str) { + // Safety: this loader runs from arg0_dispatch before Codex creates any threads. + unsafe { std::env::set_var(key, value) }; + } + + fn remove(&mut self, key: &str) { + // Safety: this loader runs from arg0_dispatch before Codex creates any threads. + unsafe { std::env::remove_var(key) }; + } +} + +struct ConditionalDotenv { + condition: Condition, + unset: Vec, + entries: Vec<(String, String)>, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged, deny_unknown_fields)] +enum Condition { + TcpConnect(TcpConnectCondition), + Not { not: TcpConnectCondition }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +enum TcpConnectCondition { + TcpConnect { + from: Option, + host: Option, + port: Option, + timeout_ms: Option, + }, +} + +#[cfg(test)] +#[path = "conditional_dotenv_tests.rs"] +mod tests; diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 1c28f81d8c..3833449822 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -15,6 +15,8 @@ use codex_windows_sandbox::CODEX_WINDOWS_SANDBOX_ARG1; use std::os::unix::fs::symlink; use tempfile::TempDir; +mod conditional_dotenv; + const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; #[cfg(unix)] @@ -98,6 +100,9 @@ pub fn arg0_dispatch() -> Option { } let argv1 = args.next().unwrap_or_default(); + if argv1 == conditional_dotenv::TCP_CONNECT_HELPER_ARG1 { + conditional_dotenv::run_tcp_connect_helper(args); + } if argv1 == CODEX_FS_HELPER_ARG1 { codex_exec_server::run_fs_helper_main(); } @@ -191,7 +196,7 @@ fn prepare_path_env_var_with_aliases( /// `codex-linux-sandbox` we *directly* execute /// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: /// -/// 1. Load `.env` values from `~/.codex/.env` before creating any threads. +/// 1. Load `.env` and matching conditional dotenv values from `~/.codex` before creating threads. /// 2. Spawn a main runtime thread with a controlled stack size. /// 3. Construct a Tokio multi-thread runtime. /// 4. Capture the current executable path and derive the @@ -285,16 +290,18 @@ fn build_runtime() -> anyhow::Result { const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_"; -/// Load env vars from ~/.codex/.env. +/// Load base and conditional env vars from the startup Codex home. /// /// Security: Do not allow `.env` files to create or modify any variables /// with names starting with `CODEX_`. fn load_dotenv() { - if let Ok(codex_home) = find_codex_home() - && let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) - { + let Ok(codex_home) = find_codex_home() else { + return; + }; + if let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) { set_filtered(iter); } + conditional_dotenv::load(codex_home.as_path()); } /// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys. @@ -303,7 +310,7 @@ where I: IntoIterator>, { for (key, value) in iter.into_iter().flatten() { - if !key.to_ascii_uppercase().starts_with(ILLEGAL_ENV_VAR_PREFIX) { + if !is_reserved_env_var(&key) { // It is safe to call set_var() because our process is // single-threaded at this point in its execution. unsafe { std::env::set_var(&key, &value) }; @@ -311,6 +318,11 @@ where } } +fn is_reserved_env_var(key: &str) -> bool { + key.get(..ILLEGAL_ENV_VAR_PREFIX.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(ILLEGAL_ENV_VAR_PREFIX)) +} + /// Creates a temporary directory with either: /// /// - UNIX: `apply_patch` symlink to the current executable From 84fe70c30e88a1828a510804341202bab629d507 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 6 Jul 2026 11:20:32 -0700 Subject: [PATCH 09/76] elicitations: Move to shared ElicitationService (#30627) ## Why Code-mode tool results could return to the model while an MCP elicitation was still waiting for user input. This differed from parallel tool calling and could let the model continue before the user resolved the request. We need one session-level view of outstanding elicitations so tool runtimes can consistently hold results until every pending elicitation is resolved. ## What changed - Added a counted, session-owned ElicitationService with RAII registrations. - Registered both core-originated and server-originated MCP elicitations with the service. - Migrated out-of-band elicitation tracking and unified exec timeout pausing to the shared service. - Made code-mode functions.exec and functions.wait capture their runtime result normally, then hold it before returning while an elicitation is outstanding. - Kept terminate: true immediate; only its result is held. - Preserved model-visible wall time across the elicitation hold. - Kept the behavior session-scoped, with concurrent elicitations holding the pause until all registrations are released. --- .../src/protocol/v2/thread.rs | 4 +- codex-rs/codex-mcp/src/connection_manager.rs | 3 + .../codex-mcp/src/connection_manager_tests.rs | 24 +++++ codex-rs/codex-mcp/src/elicitation.rs | 33 ++++++ codex-rs/codex-mcp/src/lib.rs | 1 + codex-rs/codex-mcp/src/mcp/mod.rs | 2 + codex-rs/core/src/codex_thread.rs | 47 ++++---- codex-rs/core/src/connectors.rs | 1 + codex-rs/core/src/elicitation.rs | 100 ++++++++++++++++++ codex-rs/core/src/elicitation_tests.rs | 19 ++++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 1 + codex-rs/core/src/session/mcp.rs | 7 ++ codex-rs/core/src/session/mod.rs | 8 +- codex-rs/core/src/session/session.rs | 7 +- codex-rs/core/src/session/tests.rs | 4 +- codex-rs/core/src/state/service.rs | 2 + .../src/tools/code_mode/execute_handler.rs | 1 + .../core/src/tools/code_mode/wait_handler.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 7 +- .../core/src/unified_exec/process_manager.rs | 8 +- codex-rs/core/tests/suite/code_mode.rs | 70 ++++++++++++ 22 files changed, 302 insertions(+), 49 deletions(-) create mode 100644 codex-rs/core/src/elicitation.rs create mode 100644 codex-rs/core/src/elicitation_tests.rs 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 7cd03930d8..367a4a64c8 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -685,7 +685,7 @@ pub struct ThreadIncrementElicitationParams { #[ts(export_to = "v2/")] pub struct ThreadIncrementElicitationResponse { /// Current out-of-band elicitation count after the increment. - pub count: u64, + pub count: i64, /// Whether timeout accounting is paused after applying the increment. pub paused: bool, } @@ -705,7 +705,7 @@ pub struct ThreadDecrementElicitationParams { #[ts(export_to = "v2/")] pub struct ThreadDecrementElicitationResponse { /// Current out-of-band elicitation count after the decrement. - pub count: u64, + pub count: i64, /// Whether timeout accounting remains paused after applying the decrement. pub paused: bool, } diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index c76e3c3c08..2143e3b577 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -142,6 +142,7 @@ impl McpConnectionManager { tool_plugin_provenance: ToolPluginProvenance, auth: Option<&CodexAuth>, elicitation_reviewer: Option, + elicitation_lifecycle: Option, elicitation_router: ElicitationRequestRouter, ) -> Self { let mut required_servers = mcp_servers @@ -157,6 +158,7 @@ impl McpConnectionManager { approval_policy.value(), initial_permission_profile, elicitation_reviewer, + elicitation_lifecycle, elicitation_router, ); let tool_plugin_provenance = Arc::new(tool_plugin_provenance); @@ -360,6 +362,7 @@ impl McpConnectionManager { approval_policy.value(), permission_profile.clone(), /*reviewer*/ None, + /*lifecycle*/ None, ElicitationRequestRouter::default(), ), startup_cancellation_token: CancellationToken::new(), diff --git a/codex-rs/codex-mcp/src/connection_manager_tests.rs b/codex-rs/codex-mcp/src/connection_manager_tests.rs index 1f61ab700e..41fc19a594 100644 --- a/codex-rs/codex-mcp/src/connection_manager_tests.rs +++ b/codex-rs/codex-mcp/src/connection_manager_tests.rs @@ -2,6 +2,7 @@ use super::*; use crate::codex_apps_cache::CodexAppsToolsCache; use crate::codex_apps_cache::CodexAppsToolsCacheContext; use crate::declared_openai_file_input_param_names; +use crate::elicitation::ElicitationLifecycle; use crate::elicitation::ElicitationRequestManager; use crate::elicitation::ElicitationRequestRouter; use crate::elicitation::elicitation_is_rejected_by_policy; @@ -323,6 +324,7 @@ async fn disabled_permissions_auto_accept_elicitation_with_empty_form_schema() { AskForApproval::Never, PermissionProfile::Disabled, /*reviewer*/ None, + /*lifecycle*/ None, ElicitationRequestRouter::default(), ); let (tx_event, _rx_event) = async_channel::bounded(1); @@ -359,6 +361,7 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel AskForApproval::Never, PermissionProfile::Disabled, /*reviewer*/ None, + /*lifecycle*/ None, ElicitationRequestRouter::default(), ); let (tx_event, _rx_event) = async_channel::bounded(1); @@ -395,17 +398,35 @@ async fn disabled_permissions_do_not_auto_accept_elicitation_with_requested_fiel #[tokio::test] async fn shared_elicitation_router_targets_the_exact_pending_request() { + struct Registration(Arc); + + impl Drop for Registration { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } + } + let router = ElicitationRequestRouter::default(); + let outstanding = Arc::new(AtomicUsize::new(0)); + let lifecycle = ElicitationLifecycle::new({ + let outstanding = outstanding.clone(); + move || { + outstanding.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Registration(outstanding.clone()) + } + }); let manager_a = ElicitationRequestManager::new( AskForApproval::OnRequest, PermissionProfile::default(), /*reviewer*/ None, + Some(lifecycle.clone()), router.clone(), ); let manager_b = ElicitationRequestManager::new( AskForApproval::OnRequest, PermissionProfile::default(), /*reviewer*/ None, + Some(lifecycle), router, ); let (tx_event, rx_event) = async_channel::bounded(2); @@ -435,6 +456,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() { else { panic!("expected elicitation request"); }; + assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 2); let ( codex_protocol::mcp::RequestId::String(request_a_id), codex_protocol::mcp::RequestId::String(request_b_id), @@ -485,6 +507,7 @@ async fn shared_elicitation_router_targets_the_exact_pending_request() { .expect("request B response"), response_b ); + assert_eq!(outstanding.load(std::sync::atomic::Ordering::SeqCst), 0); } #[test] @@ -1539,6 +1562,7 @@ async fn no_local_runtime_fails_local_stdio_but_keeps_local_http_server() { ToolPluginProvenance::default(), /*auth*/ None, /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/codex-mcp/src/elicitation.rs b/codex-rs/codex-mcp/src/elicitation.rs index e4fcf49d22..b6bb7fa791 100644 --- a/codex-rs/codex-mcp/src/elicitation.rs +++ b/codex-rs/codex-mcp/src/elicitation.rs @@ -52,6 +52,33 @@ pub trait ElicitationReviewer: Send + Sync { pub type ElicitationReviewerHandle = Arc; +/// Holds an owner-provided registration while an MCP elicitation is waiting for a response. +#[derive(Clone)] +pub struct ElicitationLifecycle { + register: Arc Box + Send + Sync>, +} + +impl ElicitationLifecycle { + pub fn new(register: impl Fn() -> T + Send + Sync + 'static) -> Self + where + T: Send + Sync + 'static, + { + Self { + register: Arc::new(move || Box::new(register())), + } + } + + fn start(&self) -> ActiveElicitation { + ActiveElicitation { + _registration: (self.register)(), + } + } +} + +struct ActiveElicitation { + _registration: Box, +} + /// Routes model-visible elicitation response tokens to their exact pending responders. /// /// One router is shared by every MCP runtime created for a thread. The public response token is @@ -86,6 +113,7 @@ pub(crate) struct ElicitationRequestManager { pub(crate) permission_profile: Arc>, auto_deny: Arc>, reviewer: Option, + lifecycle: Option, } impl ElicitationRequestManager { @@ -93,6 +121,7 @@ impl ElicitationRequestManager { approval_policy: AskForApproval, permission_profile: PermissionProfile, reviewer: Option, + lifecycle: Option, router: ElicitationRequestRouter, ) -> Self { Self { @@ -101,6 +130,7 @@ impl ElicitationRequestManager { permission_profile: Arc::new(StdMutex::new(permission_profile)), auto_deny: Arc::new(StdMutex::new(false)), reviewer, + lifecycle, } } @@ -140,6 +170,7 @@ impl ElicitationRequestManager { let permission_profile = self.permission_profile.clone(); let auto_deny = self.auto_deny.clone(); let reviewer = self.reviewer.clone(); + let lifecycle = self.lifecycle.clone(); Box::new(move |id, elicitation| { let router = router.clone(); let tx_event = tx_event.clone(); @@ -148,6 +179,7 @@ impl ElicitationRequestManager { let permission_profile = permission_profile.clone(); let auto_deny = auto_deny.clone(); let reviewer = reviewer.clone(); + let lifecycle = lifecycle.clone(); async move { let auto_deny = auto_deny .lock() @@ -249,6 +281,7 @@ impl ElicitationRequestManager { }, }; let (tx, rx) = oneshot::channel(); + let _active_elicitation = lifecycle.as_ref().map(ElicitationLifecycle::start); { let mut lock = router.requests.lock().await; lock.insert((server_name.clone(), routed_request_id), tx); diff --git a/codex-rs/codex-mcp/src/lib.rs b/codex-rs/codex-mcp/src/lib.rs index eda7ac2399..77128d7970 100644 --- a/codex-rs/codex-mcp/src/lib.rs +++ b/codex-rs/codex-mcp/src/lib.rs @@ -1,5 +1,6 @@ pub use connection_manager::McpConnectionManager; pub use connection_manager::tool_is_model_visible; +pub use elicitation::ElicitationLifecycle; pub use elicitation::ElicitationRequestRouter; pub use elicitation::ElicitationReviewRequest; pub use elicitation::ElicitationReviewer; diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index ffb797f26e..87ea25dd9f 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -339,6 +339,7 @@ pub async fn read_mcp_resource( tool_plugin_provenance(config), auth, /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, crate::elicitation::ElicitationRequestRouter::default(), ) .await; @@ -416,6 +417,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail( tool_plugin_provenance, auth, /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, crate::elicitation::ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index 4a562092f1..c80268e2f9 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -1,5 +1,6 @@ use crate::agent::AgentStatus; use crate::config::ConstraintResult; +use crate::elicitation::ElicitationRegistration; use crate::session::Codex; use crate::session::SessionSettingsUpdate; use crate::session::SteerInputError; @@ -162,7 +163,13 @@ pub struct CodexThread { pub(crate) session_source: SessionSource, session_configured: SessionConfiguredEvent, rollout_path: Option, - out_of_band_elicitation_count: Mutex, + out_of_band_elicitations: Mutex, +} + +#[derive(Default)] +struct OutOfBandElicitations { + count: i64, + registration: Option, } #[derive(Debug, Eq, PartialEq)] @@ -187,7 +194,7 @@ impl CodexThread { session_source, session_configured, rollout_path, - out_of_band_elicitation_count: Mutex::new(0), + out_of_band_elicitations: Mutex::new(OutOfBandElicitations::default()), } } @@ -655,38 +662,30 @@ impl CodexThread { self.codex.enabled(feature) } - pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult { - let mut guard = self.out_of_band_elicitation_count.lock().await; - let was_zero = *guard == 0; - *guard = guard.checked_add(1).ok_or_else(|| { + pub async fn increment_out_of_band_elicitation_count(&self) -> CodexResult { + let mut elicitations = self.out_of_band_elicitations.lock().await; + let incremented = elicitations.count.checked_add(1).ok_or_else(|| { CodexErr::Fatal("out-of-band elicitation count overflowed".to_string()) })?; - - if was_zero { - self.codex - .session - .set_out_of_band_elicitation_pause_state(/*paused*/ true); + if elicitations.count == 0 { + elicitations.registration = Some(self.codex.session.services.elicitations.register()); } - - Ok(*guard) + elicitations.count = incremented; + Ok(incremented) } - pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult { - let mut guard = self.out_of_band_elicitation_count.lock().await; - if *guard == 0 { + pub async fn decrement_out_of_band_elicitation_count(&self) -> CodexResult { + let mut elicitations = self.out_of_band_elicitations.lock().await; + if elicitations.count == 0 { return Err(CodexErr::InvalidRequest( "out-of-band elicitation count is already zero".to_string(), )); } - *guard -= 1; - let now_zero = *guard == 0; - if now_zero { - self.codex - .session - .set_out_of_band_elicitation_pause_state(/*paused*/ false); + elicitations.count -= 1; + if elicitations.count == 0 { + elicitations.registration = None; } - - Ok(*guard) + Ok(elicitations.count) } } diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index b45cc841a1..805d56b648 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -283,6 +283,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_mcp_manager( ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, codex_mcp::ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/core/src/elicitation.rs b/codex-rs/core/src/elicitation.rs new file mode 100644 index 0000000000..b29207af46 --- /dev/null +++ b/codex-rs/core/src/elicitation.rs @@ -0,0 +1,100 @@ +use std::sync::Arc; +use std::sync::Mutex; + +use tokio::sync::watch; + +/// Coordinates user elicitations that pause tool-result delivery for a session. +/// +/// Registrations are counted so concurrent elicitations keep the session paused until all of them +/// finish. Consumers can subscribe to pause timeout progress or wait before returning an already +/// captured result. +#[derive(Clone)] +pub(crate) struct ElicitationService { + inner: Arc, +} + +struct Inner { + state: Mutex, + paused: watch::Sender, +} + +#[derive(Default)] +struct State { + outstanding: i64, +} + +pub(crate) struct ElicitationRegistration { + service: ElicitationService, +} + +impl ElicitationService { + pub(crate) fn new() -> Self { + let (paused, _paused_rx) = watch::channel(false); + Self { + inner: Arc::new(Inner { + state: Mutex::new(State::default()), + paused, + }), + } + } + + pub(crate) fn register(&self) -> ElicitationRegistration { + self.increment(); + ElicitationRegistration { + service: self.clone(), + } + } + + fn increment(&self) { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let was_clear = state.outstanding == 0; + assert_ne!( + state.outstanding, + i64::MAX, + "outstanding elicitation count overflowed" + ); + state.outstanding += 1; + if was_clear { + self.inner.paused.send_replace(true); + } + } + + pub(crate) fn subscribe(&self) -> watch::Receiver { + self.inner.paused.subscribe() + } + + pub(crate) async fn wait_until_clear(&self) { + let mut paused = self.subscribe(); + let _ = paused.wait_for(|paused| !*paused).await; + } + + fn decrement(&self) { + let mut state = self + .inner + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + state.outstanding > 0, + "elicitation registration count underflowed" + ); + state.outstanding -= 1; + if state.outstanding == 0 { + self.inner.paused.send_replace(false); + } + } +} + +impl Drop for ElicitationRegistration { + fn drop(&mut self) { + self.service.decrement(); + } +} + +#[cfg(test)] +#[path = "elicitation_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/elicitation_tests.rs b/codex-rs/core/src/elicitation_tests.rs new file mode 100644 index 0000000000..eefe1f5394 --- /dev/null +++ b/codex-rs/core/src/elicitation_tests.rs @@ -0,0 +1,19 @@ +use super::*; + +#[tokio::test] +async fn wait_until_clear_waits_for_every_registration() { + let service = ElicitationService::new(); + let first = service.register(); + let second = service.register(); + let waiting = tokio::spawn({ + let service = service.clone(); + async move { service.wait_until_clear().await } + }); + + drop(first); + tokio::task::yield_now().await; + assert!(!waiting.is_finished()); + + drop(second); + waiting.await.expect("elicitation waiter should complete"); +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index df8753a386..91ccbd6e63 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -40,6 +40,7 @@ pub mod connectors; pub mod context; mod context_manager; mod current_time; +mod elicitation; mod environment_selection; pub mod exec; pub mod exec_env; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 6b642b1cd0..f68d32ff4e 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1439,6 +1439,7 @@ async fn host_owned_codex_apps_manager( codex_mcp::ToolPluginProvenance::default(), auth.as_ref(), /*elicitation_reviewer*/ None, + /*elicitation_lifecycle*/ None, codex_mcp::ElicitationRequestRouter::default(), ) .await; diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 4cfb0a36de..3f5e92eca7 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -195,6 +195,11 @@ impl Session { Arc::new(GuardianMcpElicitationReviewer::new(self)) } + pub(crate) fn mcp_elicitation_lifecycle(&self) -> codex_mcp::ElicitationLifecycle { + let elicitations = self.services.elicitations.clone(); + codex_mcp::ElicitationLifecycle::new(move || elicitations.register()) + } + #[expect( clippy::await_holding_invalid_type, reason = "active turn checks and turn state updates must remain atomic" @@ -222,6 +227,7 @@ impl Session { }; } + let _elicitation = self.services.elicitations.register(); let (tx_response, rx_response) = oneshot::channel(); let prev_entry = { let mut active = self.active_turn.lock().await; @@ -377,6 +383,7 @@ impl Session { tool_plugin_provenance, auth.as_ref(), elicitation_reviewer, + Some(self.mcp_elicitation_lifecycle()), current_runtime.manager().elicitation_router(), ) .await; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 560dcf5985..ec3fa12f39 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1141,12 +1141,8 @@ impl Session { state.session_configuration.codex_home().clone() } - pub(crate) fn subscribe_out_of_band_elicitation_pause_state(&self) -> watch::Receiver { - self.out_of_band_elicitation_paused.subscribe() - } - - pub(crate) fn set_out_of_band_elicitation_pause_state(&self, paused: bool) { - self.out_of_band_elicitation_paused.send_replace(paused); + pub(crate) fn subscribe_elicitation_pause_state(&self) -> watch::Receiver { + self.services.elicitations.subscribe() } pub(crate) fn get_tx_event(&self) -> Sender { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 48a9ee447e..2261eaa495 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -30,7 +30,6 @@ pub(crate) struct Session { pub(crate) installation_id: String, pub(super) tx_event: Sender, pub(super) agent_status: watch::Sender, - pub(super) out_of_band_elicitation_paused: watch::Sender, pub(super) state: Mutex, /// Serializes rebuild/apply cycles for the running proxy; each cycle /// rebuilds from the current SessionState while holding this lock. @@ -1057,6 +1056,7 @@ impl Session { unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + elicitations: crate::elicitation::ElicitationService::new(), shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client, @@ -1125,15 +1125,11 @@ impl Session { tool_search_handler_cache: Default::default(), turn_environments: Arc::clone(&turn_environments), }; - let (out_of_band_elicitation_paused, _out_of_band_elicitation_paused_rx) = - watch::channel(false); - let sess = Arc::new(Session { thread_id, installation_id, tx_event: tx_event.clone(), agent_status, - out_of_band_elicitation_paused, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), @@ -1219,6 +1215,7 @@ impl Session { tool_plugin_provenance, auth, Some(sess.mcp_elicitation_reviewer()), + Some(sess.mcp_elicitation_lifecycle()), codex_mcp::ElicitationRequestRouter::default(), ) .instrument(info_span!( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9ae29a0917..582a58f2ab 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5384,6 +5384,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + elicitations: crate::elicitation::ElicitationService::new(), shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( @@ -5494,7 +5495,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, - out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), @@ -7510,6 +7510,7 @@ where unified_exec_manager: UnifiedExecProcessManager::new( config.background_terminal_max_timeout, ), + elicitations: crate::elicitation::ElicitationService::new(), shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( @@ -7620,7 +7621,6 @@ where installation_id: "11111111-1111-4111-8111-111111111111".to_string(), tx_event, agent_status: agent_status_tx, - out_of_band_elicitation_paused: watch::channel(false).0, state: Mutex::new(state), managed_network_proxy_refresh_lock: Semaphore::new(/*permits*/ 1), features: config.features.clone(), diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index c4b67a629b..43f411e31b 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -10,6 +10,7 @@ use crate::client::ModelClient; use crate::config::NetworkProxyAuditMetadata; use crate::config::StartedNetworkProxy; use crate::current_time::TimeProvider; +use crate::elicitation::ElicitationService; use crate::environment_selection::ThreadEnvironments; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; @@ -55,6 +56,7 @@ pub(crate) struct SessionServices { pub(crate) mcp_projection_lock: Mutex<()>, pub(crate) mcp_startup_cancellation_token: Mutex, pub(crate) unified_exec_manager: UnifiedExecProcessManager, + pub(crate) elicitations: ElicitationService, #[cfg_attr(not(unix), allow(dead_code))] pub(crate) shell_zsh_path: Option, #[cfg_attr(not(unix), allow(dead_code))] diff --git a/codex-rs/core/src/tools/code_mode/execute_handler.rs b/codex-rs/core/src/tools/code_mode/execute_handler.rs index 2e46c1bd6b..8374ae1ca4 100644 --- a/codex-rs/core/src/tools/code_mode/execute_handler.rs +++ b/codex-rs/core/src/tools/code_mode/execute_handler.rs @@ -85,6 +85,7 @@ impl CodeModeExecuteHandler { .code_mode_service .finish_cell_dispatch(&cell_id); } + exec.session.services.elicitations.wait_until_clear().await; handle_runtime_response(&exec, response, args.max_output_tokens, started_at) .await .map_err(FunctionCallError::RespondToModel) diff --git a/codex-rs/core/src/tools/code_mode/wait_handler.rs b/codex-rs/core/src/tools/code_mode/wait_handler.rs index 953391a573..c3c867f3ef 100644 --- a/codex-rs/core/src/tools/code_mode/wait_handler.rs +++ b/codex-rs/core/src/tools/code_mode/wait_handler.rs @@ -120,6 +120,7 @@ impl CodeModeWaitHandler { .code_mode_service .finish_cell_dispatch(runtime_cell_id); } + exec.session.services.elicitations.wait_until_clear().await; handle_runtime_response(&exec, wait_response.into(), args.max_tokens, started_at) .await .map(boxed_tool_output) diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index 54ee788e3b..81403c8e52 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -160,7 +160,7 @@ async fn exec_command_with_tty( &output_closed, &output_closed_notify, &cancellation_token, - Some(session.subscribe_out_of_band_elicitation_pause_state()), + Some(session.subscribe_elicitation_pause_state()), deadline, ) .await; @@ -515,12 +515,11 @@ async fn unified_exec_pause_blocks_yield_timeout() -> anyhow::Result<()> { skip_if_sandbox!(Ok(())); let (session, turn) = test_session_and_turn().await; - session.set_out_of_band_elicitation_pause_state(/*paused*/ true); + let elicitation = session.services.elicitations.register(); - let paused_session = Arc::clone(&session); tokio::spawn(async move { tokio::time::sleep(Duration::from_secs(2)).await; - paused_session.set_out_of_band_elicitation_pause_state(/*paused*/ false); + drop(elicitation); }); let started = tokio::time::Instant::now(); diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index ef22918cd2..a895708f79 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -493,11 +493,7 @@ impl UnifiedExecProcessManager { &output_closed, &output_closed_notify, &cancellation_token, - Some( - context - .session - .subscribe_out_of_band_elicitation_pause_state(), - ), + Some(context.session.subscribe_elicitation_pause_state()), deadline, ) .await; @@ -839,7 +835,7 @@ impl UnifiedExecProcessManager { let pause_state = entry .session .upgrade() - .map(|session| session.subscribe_out_of_band_elicitation_pause_state()); + .map(|session| session.subscribe_elicitation_pause_state()); let session = entry.session.upgrade(); Ok(PreparedProcessHandles { diff --git a/codex-rs/core/tests/suite/code_mode.rs b/codex-rs/core/tests/suite/code_mode.rs index 26c041d8f7..9293b506ef 100644 --- a/codex-rs/core/tests/suite/code_mode.rs +++ b/codex-rs/core/tests/suite/code_mode.rs @@ -521,6 +521,76 @@ text(JSON.stringify(await tools.exec_command({ cmd: "printf code_mode_exec_marke Ok(()) } +#[tokio::test] +async fn code_mode_exec_holds_captured_result_during_elicitation() -> Result<()> { + let server = responses::start_mock_server().await; + let mut builder = test_codex() + .with_model("test-gpt-5.1-codex") + .with_config(|config| { + let _ = config.features.enable(Feature::CodeMode); + }); + let test = builder.build(&server).await?; + + let first_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_custom_tool_call("call-1", "exec", "text('captured');"), + ev_completed("resp-1"), + ]), + ) + .await; + let second_mock = responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-1", "done"), + ev_completed("resp-2"), + ]), + ) + .await; + + assert_eq!( + test.codex.increment_out_of_band_elicitation_count().await?, + 1 + ); + assert_eq!( + test.codex.increment_out_of_band_elicitation_count().await?, + 2 + ); + let release_elicitation = async { + tokio::time::timeout(Duration::from_secs(5), async { + while first_mock.requests().is_empty() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("initial response request should arrive"); + tokio::time::sleep(Duration::from_millis(250)).await; + assert!( + second_mock.requests().is_empty(), + "captured exec result should not return during an elicitation" + ); + assert_eq!( + test.codex.decrement_out_of_band_elicitation_count().await?, + 1 + ); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + second_mock.requests().is_empty(), + "captured exec result should wait for every elicitation" + ); + assert_eq!( + test.codex.decrement_out_of_band_elicitation_count().await?, + 0 + ); + Ok::<(), anyhow::Error>(()) + }; + + tokio::try_join!(test.submit_turn("run code mode"), release_elicitation)?; + second_mock.single_request(); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn code_mode_only_restricts_prompt_tools() -> Result<()> { skip_if_no_network!(Ok(())); From a86d525e4d28b299d2522cbef844675203e86084 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 11:44:08 -0700 Subject: [PATCH 10/76] core: trace executor skill discovery (#30318) ## Why Make it easier to measure the performance of different parts of skill loading. ## What - Add spans for step-context capture, world-state construction, executor catalog snapshot/root loading, and environment skill loading. - Record the discovered environment skill count. - Trace outbound exec-server requests with client kind and RPC method fields. - Update trace propagation tests to assert that requests keep the parent trace id while creating their own child span. --- .../core-skills/src/loader/environment.rs | 7 +++++++ codex-rs/core/src/session/mod.rs | 1 + codex-rs/core/src/session/world_state.rs | 1 + codex-rs/exec-server/src/client.rs | 11 +++++++++- codex-rs/exec-server/src/rpc.rs | 21 ++++++++++++++++++- codex-rs/ext/skills/src/state.rs | 7 +++++++ 6 files changed, 46 insertions(+), 2 deletions(-) diff --git a/codex-rs/core-skills/src/loader/environment.rs b/codex-rs/core-skills/src/loader/environment.rs index 049b500585..3d50bafe1f 100644 --- a/codex-rs/core-skills/src/loader/environment.rs +++ b/codex-rs/core-skills/src/loader/environment.rs @@ -143,6 +143,12 @@ pub struct EnvironmentSkillLoadOutcome { } /// Discovers skills without converting environment-owned paths to host paths. +#[tracing::instrument( + name = "skills.environment.load", + level = "info", + skip_all, + fields(skill_count = tracing::field::Empty) +)] pub async fn load_environment_skills_from_root( file_system: &dyn ExecutorFileSystem, root: &PathUri, @@ -235,6 +241,7 @@ pub async fn load_environment_skills_from_root( warnings: vec![format!("failed to walk skills root {root}: {error:#}")], }, }; + tracing::Span::current().record("skill_count", discovery.skills.len()); outcome.warnings.extend(discovery.warnings); if discovery.skills.is_empty() { return outcome; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ec3fa12f39..d4cfd9e05d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2829,6 +2829,7 @@ impl Session { /// This may refresh filesystem-derived state. Normal turns should call it only from /// `run_turn` and pass the result down; standalone request or history boundaries may capture /// their own step. + #[tracing::instrument(name = "step_context.capture", level = "info", skip_all)] pub(crate) async fn capture_step_context( self: &Arc, turn_context: Arc, diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index a78116d457..89684e9e50 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -7,6 +7,7 @@ use crate::context::world_state::WorldState; use codex_extension_api::WorldStateContributionInput; impl Session { + #[tracing::instrument(name = "world_state.build", level = "info", skip_all)] pub(crate) async fn build_world_state_for_step( &self, step_context: &StepContext, diff --git a/codex-rs/exec-server/src/client.rs b/codex-rs/exec-server/src/client.rs index 82412a58e6..5590fd3b5e 100644 --- a/codex-rs/exec-server/src/client.rs +++ b/codex-rs/exec-server/src/client.rs @@ -1394,7 +1394,16 @@ mod tests { assert_eq!(session.process_id(), &process_id); let trace = server.await.expect("server task").expect("trace context"); - assert_eq!(trace, expected_trace); + let expected_traceparent = expected_trace + .traceparent + .as_deref() + .expect("parent traceparent"); + let traceparent = trace.traceparent.as_deref().expect("request traceparent"); + let expected_parts = expected_traceparent.split('-').collect::>(); + let parts = traceparent.split('-').collect::>(); + assert_eq!(parts[1], expected_parts[1]); + assert_ne!(parts[2], expected_parts[2]); + assert_eq!(trace.tracestate, expected_trace.tracestate); } async fn accept_websocket(listener: &TcpListener) -> WebSocketStream { diff --git a/codex-rs/exec-server/src/rpc.rs b/codex-rs/exec-server/src/rpc.rs index 46a0bd14e8..e762fe8b66 100644 --- a/codex-rs/exec-server/src/rpc.rs +++ b/codex-rs/exec-server/src/rpc.rs @@ -342,6 +342,16 @@ impl RpcClient { drain_pending(&self.pending).await; } + #[tracing::instrument( + name = "codex.exec_server.request", + level = "info", + skip_all, + fields( + otel.kind = "client", + otel.name = method, + method, + ) + )] pub(crate) async fn call(&self, method: &str, params: &P) -> Result where P: Serialize, @@ -803,6 +813,15 @@ mod tests { .expect("RPC response"); assert_eq!(response, serde_json::json!({})); let trace = server.await.expect("server task").expect("trace context"); - assert_eq!(trace, expected_trace); + let expected_traceparent = expected_trace + .traceparent + .as_deref() + .expect("parent traceparent"); + let traceparent = trace.traceparent.as_deref().expect("request traceparent"); + let expected_parts = expected_traceparent.split('-').collect::>(); + let parts = traceparent.split('-').collect::>(); + assert_eq!(parts[1], expected_parts[1]); + assert_ne!(parts[2], expected_parts[2]); + assert_eq!(trace.tracestate, expected_trace.tracestate); } } diff --git a/codex-rs/ext/skills/src/state.rs b/codex-rs/ext/skills/src/state.rs index 2f1a84c46b..2094f34496 100644 --- a/codex-rs/ext/skills/src/state.rs +++ b/codex-rs/ext/skills/src/state.rs @@ -66,6 +66,12 @@ impl SkillsThreadState { /// Environment availability only controls whether the root is projected into the current /// step; it never invalidates the cache. There is intentionally no filesystem watcher or /// content-based invalidation because selected environment roots are treated as stable. + #[tracing::instrument( + name = "skills.executor.catalog_snapshot", + level = "info", + skip_all, + fields(root_count = query.executor_roots.len()) + )] pub(crate) async fn executor_catalog_snapshot( &self, providers: &SkillProviders, @@ -157,6 +163,7 @@ impl SkillsThreadState { next_cache } + #[tracing::instrument(name = "skills.executor.catalog_root", level = "info", skip_all)] async fn executor_root_catalog( &self, providers: &SkillProviders, From 2e20d2ef4445145f2c156021b864d59e0a04cb64 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Mon, 6 Jul 2026 12:17:16 -0700 Subject: [PATCH 11/76] Revert "Conditional codex_home dotenv" (#31276) ## Summary - Revert the conditional `CODEX_HOME` dotenv behavior introduced in #29959. - Restore the previous startup and configuration behavior --- codex-rs/Cargo.lock | 3 - codex-rs/arg0/Cargo.toml | 5 +- .../conditional_dotenv_tests.rs | 281 ----------- codex-rs/arg0/src/conditional_dotenv/mod.rs | 465 ------------------ codex-rs/arg0/src/lib.rs | 24 +- 5 files changed, 7 insertions(+), 771 deletions(-) delete mode 100644 codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs delete mode 100644 codex-rs/arg0/src/conditional_dotenv/mod.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e590d17497..6ac51d403f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2227,11 +2227,8 @@ dependencies = [ "codex-windows-sandbox", "dotenvy", "pretty_assertions", - "serde", - "serde_json", "tempfile", "tokio", - "url", ] [[package]] diff --git a/codex-rs/arg0/Cargo.toml b/codex-rs/arg0/Cargo.toml index 400e3fbb9c..bb45db4552 100644 --- a/codex-rs/arg0/Cargo.toml +++ b/codex-rs/arg0/Cargo.toml @@ -23,11 +23,8 @@ codex-shell-escalation = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-home-dir = { workspace = true } dotenvy = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["net", "rt-multi-thread", "time"] } -url = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread"] } [target.'cfg(windows)'.dependencies] codex-windows-sandbox = { workspace = true } diff --git a/codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs b/codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs deleted file mode 100644 index f4b5dfb1e6..0000000000 --- a/codex-rs/arg0/src/conditional_dotenv/conditional_dotenv_tests.rs +++ /dev/null @@ -1,281 +0,0 @@ -use super::*; -use pretty_assertions::assert_eq; -use std::cell::Cell; -use std::cell::RefCell; -use std::collections::BTreeMap; -use std::fs; -use std::time::Duration; - -#[derive(Debug, Default, PartialEq, Eq)] -struct TestEnvironment(BTreeMap); - -impl StartupEnvironment for TestEnvironment { - fn set(&mut self, key: &str, value: &str) { - self.0.insert(key.to_string(), value.to_string()); - } - - fn remove(&mut self, key: &str) { - self.0.remove(key); - } -} - -fn write_overlay(codex_home: &Path, name: &str, contents: &str) -> std::io::Result<()> { - fs::write(codex_home.join(name), contents) -} - -fn test_environment(values: [(&str, &str); N]) -> TestEnvironment { - TestEnvironment(BTreeMap::from( - values.map(|(key, value)| (key.to_string(), value.to_string())), - )) -} - -#[test] -fn proxy_overlays_set_or_unset_environment_for_network() -> anyhow::Result<()> { - let codex_home = tempfile::tempdir()?; - write_overlay( - codex_home.path(), - ".env.10-proxy-on", - r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500} -HTTPS_PROXY=http://user:password@proxy.example.com:8080 -HTTP_PROXY=http://proxy.example.com:8080 -ALL_PROXY=http://proxy.example.com:8080 -NO_PROXY=localhost,127.0.0.1,.example.com -"#, - )?; - write_overlay( - codex_home.path(), - ".env.20-proxy-off", - r#"# codex-env-if: {"not":{"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":500}} -# codex-env-unset: ["HTTPS_PROXY","HTTP_PROXY","ALL_PROXY","NO_PROXY"] -"#, - )?; - - let on_network_calls = Cell::new(0); - let on_network_connector = |host: &str, port: u16, timeout: Duration| { - on_network_calls.set(on_network_calls.get() + 1); - assert_eq!(host, "proxy.example.com"); - assert_eq!(port, 8080); - assert_eq!(timeout, Duration::from_millis(500)); - true - }; - let mut on_network_environment = test_environment([ - ("HTTPS_PROXY", "http://stale.example.com:8080"), - ("UNRELATED", "preserved"), - ]); - - let warnings = load_conditional_dotenv_overlays( - codex_home.path(), - &mut on_network_environment, - &on_network_connector, - ); - - assert_eq!(on_network_calls.get(), 2); - assert_eq!(warnings, Vec::::new()); - assert_eq!( - on_network_environment, - test_environment([ - ("ALL_PROXY", "http://proxy.example.com:8080"), - ("HTTPS_PROXY", "http://user:password@proxy.example.com:8080"), - ("HTTP_PROXY", "http://proxy.example.com:8080"), - ("NO_PROXY", "localhost,127.0.0.1,.example.com"), - ("UNRELATED", "preserved"), - ]) - ); - - let off_network_calls = Cell::new(0); - let off_network_connector = |host: &str, port: u16, timeout: Duration| { - off_network_calls.set(off_network_calls.get() + 1); - assert_eq!(host, "proxy.example.com"); - assert_eq!(port, 8080); - assert_eq!(timeout, Duration::from_millis(500)); - false - }; - let mut off_network_environment = test_environment([ - ("ALL_PROXY", "http://inherited.example.com:8080"), - ("HTTPS_PROXY", "http://inherited.example.com:8080"), - ("HTTP_PROXY", "http://inherited.example.com:8080"), - ("NO_PROXY", "localhost"), - ("UNRELATED", "preserved"), - ]); - - let warnings = load_conditional_dotenv_overlays( - codex_home.path(), - &mut off_network_environment, - &off_network_connector, - ); - - assert_eq!(off_network_calls.get(), 2); - assert_eq!(warnings, Vec::::new()); - assert_eq!( - off_network_environment, - test_environment([("UNRELATED", "preserved")]) - ); - Ok(()) -} - -#[test] -fn overlay_filters_reserved_names_for_set_and_unset() -> anyhow::Result<()> { - let codex_home = tempfile::tempdir()?; - write_overlay( - codex_home.path(), - ".env.proxy", - r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080} -# codex-env-unset: ["ALL_PROXY","codex_internal"] -HTTP_PROXY=http://proxy.example.com:8080 -codex_internal=changed -"#, - )?; - let mut environment = test_environment([ - ("ALL_PROXY", "http://old.example.com:8080"), - ("codex_internal", "preserved"), - ]); - - let warnings = - load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &|_, _, _| true); - - assert_eq!(warnings, Vec::::new()); - assert_eq!( - environment, - test_environment([ - ("HTTP_PROXY", "http://proxy.example.com:8080"), - ("codex_internal", "preserved"), - ]) - ); - Ok(()) -} - -#[test] -fn invalid_timeout_fails_closed_without_blocking_valid_overlays() -> anyhow::Result<()> { - let codex_home = tempfile::tempdir()?; - write_overlay(codex_home.path(), ".env.example", "SHOULD_NOT_LOAD=1\n")?; - write_overlay( - codex_home.path(), - ".env.10-timeout", - r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":5001} -SHOULD_NOT_LOAD=2 -"#, - )?; - write_overlay( - codex_home.path(), - ".env.20-maximum-timeout", - r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":5000} -MAXIMUM_TIMEOUT=loaded -"#, - )?; - write_overlay( - codex_home.path(), - ".env.30-default-timeout", - r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080} -DEFAULT_TIMEOUT=loaded -"#, - )?; - let observed_timeouts = RefCell::new(Vec::new()); - let connector = |_: &str, _: u16, timeout: Duration| { - observed_timeouts.borrow_mut().push(timeout); - true - }; - let mut environment = TestEnvironment::default(); - - let warnings = - load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &connector); - - assert_eq!(warnings.len(), 1); - assert!(warnings[0].contains(".env.10-timeout")); - assert_eq!( - observed_timeouts.into_inner(), - vec![Duration::from_millis(5000), Duration::from_millis(500)] - ); - assert_eq!( - environment, - test_environment([("DEFAULT_TIMEOUT", "loaded"), ("MAXIMUM_TIMEOUT", "loaded"),]) - ); - Ok(()) -} - -#[test] -fn invalid_from_sources_fail_closed_without_connecting() -> anyhow::Result<()> { - let codex_home = tempfile::tempdir()?; - write_overlay( - codex_home.path(), - ".env.10-missing-source", - r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY"} -SHOULD_NOT_LOAD=1 -"#, - )?; - write_overlay( - codex_home.path(), - ".env.20-invalid-source", - r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY"} -HTTPS_PROXY="not a url" -"#, - )?; - let connector_called = Cell::new(false); - let connector = |_: &str, _: u16, _: Duration| { - connector_called.set(true); - true - }; - let mut environment = TestEnvironment::default(); - - let warnings = - load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &connector); - - assert!(!connector_called.get()); - assert_eq!(warnings.len(), 2); - assert_eq!(environment, TestEnvironment::default()); - Ok(()) -} - -#[test] -fn directives_inside_multiline_values_are_not_scanned() -> anyhow::Result<()> { - let codex_home = tempfile::tempdir()?; - write_overlay( - codex_home.path(), - ".env.multiline", - r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080} -# codex-env-unset: ["HTTP_PROXY"] -MESSAGE='before -# codex-env-unset: ["ALL_PROXY"] -# codex-env-if: {"not":{"type":"tcp_connect","host":"other.example.com","port":8080}} -after' -"#, - )?; - let mut environment = test_environment([ - ("ALL_PROXY", "http://proxy.example.com:8080"), - ("HTTP_PROXY", "http://proxy.example.com:8080"), - ]); - - let warnings = - load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &|_, _, _| true); - - assert_eq!(warnings, Vec::::new()); - assert_eq!( - environment, - test_environment([ - ("ALL_PROXY", "http://proxy.example.com:8080"), - ( - "MESSAGE", - "before\n# codex-env-unset: [\"ALL_PROXY\"]\n# codex-env-if: {\"not\":{\"type\":\"tcp_connect\",\"host\":\"other.example.com\",\"port\":8080}}\nafter", - ), - ]) - ); - Ok(()) -} - -#[test] -fn endpoint_parser_supports_urls_bare_authorities_and_known_default_ports() { - assert_eq!( - [ - "http://user:password@proxy.example.com:8080", - "proxy.example.com:8080", - "https://proxy.example.com", - "http://[::1]:8080", - ] - .map(parse_endpoint), - [ - Some(("proxy.example.com".to_string(), 8080)), - Some(("proxy.example.com".to_string(), 8080)), - Some(("proxy.example.com".to_string(), 443)), - Some(("::1".to_string(), 8080)), - ] - ); -} diff --git a/codex-rs/arg0/src/conditional_dotenv/mod.rs b/codex-rs/arg0/src/conditional_dotenv/mod.rs deleted file mode 100644 index c3ed40179e..0000000000 --- a/codex-rs/arg0/src/conditional_dotenv/mod.rs +++ /dev/null @@ -1,465 +0,0 @@ -//! Loads TCP-gated dotenv overlays from `CODEX_HOME` during single-threaded startup. -//! -//! Example `~/.codex/.env.corporate-proxy`: -//! -//! ```dotenv -//! # codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500} -//! HTTPS_PROXY=http://proxy.example.com:8080 -//! HTTP_PROXY=http://proxy.example.com:8080 -//! ALL_PROXY=http://proxy.example.com:8080 -//! NO_PROXY=localhost,127.0.0.1,.example.com -//! ``` -//! -//! A TCP condition can be inverted with `{"not":{"type":"tcp_connect",...}}`, which allows a -//! second overlay to unset proxy variables when the endpoint is unreachable. - -use crate::is_reserved_env_var; -use serde::Deserialize; -use std::ffi::OsString; -use std::net::ToSocketAddrs; -use std::path::Path; -use std::path::PathBuf; -use std::process::Command; -use std::process::Stdio; -use std::time::Duration; -use std::time::Instant; -use url::Url; - -pub(crate) const TCP_CONNECT_HELPER_ARG1: &str = "--codex-run-as-tcp-connect-probe"; - -const CONDITIONAL_DOTENV_PREFIX: &str = ".env."; -const CONDITION_DIRECTIVE_PREFIX: &str = "# codex-env-if:"; -const UNSET_DIRECTIVE_PREFIX: &str = "# codex-env-unset:"; -const DEFAULT_TCP_CONNECT_TIMEOUT_MS: u64 = 500; -const MAX_TCP_CONNECT_TIMEOUT_MS: u64 = 5_000; -const TCP_CONNECT_HELPER_POLL_INTERVAL: Duration = Duration::from_millis(10); -const IGNORED_CONDITIONAL_DOTENV_SUFFIXES: &[&str] = &[ - "bak", "back", "backup", "bkp", "old", "orig", "original", "save", "saved", "disable", - "disabled", "inactive", "off", "tmp", "temp", "swp", "swo", "example", "sample", "template", - "dist", -]; - -type TcpConnector<'a> = dyn Fn(&str, u16, Duration) -> bool + 'a; - -pub(crate) fn load(codex_home: &Path) { - let mut environment = ProcessEnvironment; - - for warning in - load_conditional_dotenv_overlays(codex_home, &mut environment, &system_tcp_connect) - { - eprintln!("WARNING: skipped conditional dotenv overlay: {warning}"); - } -} - -/// Runs an OS-resolved TCP probe in the child process used by [`system_tcp_connect`]. -/// -/// This exits before dotenv loading, so its resolver and connection tasks cannot race with the -/// parent's environment mutation. -pub(crate) fn run_tcp_connect_helper(mut args: impl Iterator) -> ! { - let host = args.next().and_then(|value| value.into_string().ok()); - let port = args - .next() - .and_then(|value| value.into_string().ok()) - .and_then(|value| value.parse::().ok()); - let timeout = args - .next() - .and_then(|value| value.into_string().ok()) - .and_then(|value| value.parse::().ok()) - .and_then(|timeout_ms| tcp_connect_timeout(Some(timeout_ms)).ok()); - let connected = match (host, port, timeout) { - (Some(host), Some(port), Some(timeout)) => direct_tcp_connect(&host, port, timeout), - _ => false, - }; - let exit_code = if connected { 0 } else { 1 }; - std::process::exit(exit_code); -} - -fn load_conditional_dotenv_overlays( - codex_home: &Path, - environment: &mut dyn StartupEnvironment, - tcp_connect: &TcpConnector<'_>, -) -> Vec { - let paths = match conditional_dotenv_paths(codex_home) { - Ok(paths) => paths, - Err(err) => { - return vec![format!( - "could not discover overlays in {}: {err}", - codex_home.display() - )]; - } - }; - let mut warnings = Vec::new(); - - for path in paths { - let overlay = match parse_conditional_dotenv(&path) { - Ok(Some(overlay)) => overlay, - Ok(None) => continue, - Err(err) => { - warnings.push(format!("{}: {err}", path.display())); - continue; - } - }; - match evaluate_condition(&overlay.condition, &overlay.entries, tcp_connect) { - Ok(true) => apply_overlay(overlay, environment), - Ok(false) => {} - Err(err) => warnings.push(format!("{}: {err}", path.display())), - } - } - - warnings -} - -fn conditional_dotenv_paths(codex_home: &Path) -> std::io::Result> { - let entries = match std::fs::read_dir(codex_home) { - Ok(entries) => entries, - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(err) => return Err(err), - }; - let mut paths = Vec::new(); - - for entry in entries { - let entry = entry?; - let file_name = entry.file_name(); - let Some(file_name) = file_name.to_str() else { - continue; - }; - let is_ignored = file_name.ends_with('~') - || file_name.rsplit_once('.').is_some_and(|(_, suffix)| { - IGNORED_CONDITIONAL_DOTENV_SUFFIXES - .iter() - .any(|ignored| suffix.eq_ignore_ascii_case(ignored)) - }); - if file_name.starts_with(CONDITIONAL_DOTENV_PREFIX) - && file_name.len() > CONDITIONAL_DOTENV_PREFIX.len() - && !is_ignored - && entry.path().is_file() - { - paths.push(entry.path()); - } - } - - paths.sort(); - Ok(paths) -} - -fn parse_conditional_dotenv(path: &Path) -> Result, String> { - let contents = - std::fs::read_to_string(path).map_err(|err| format!("could not read file: {err}"))?; - let contents = contents.strip_prefix('\u{feff}').unwrap_or(&contents); - let mut lines = contents - .lines() - .map(|line| line.trim_start_matches('\u{feff}').trim()); - let Some(first_line) = lines.find(|line| !line.is_empty()) else { - return Ok(None); - }; - let Some(condition_json) = first_line.strip_prefix(CONDITION_DIRECTIVE_PREFIX) else { - return Ok(None); - }; - let condition_json = condition_json.trim(); - if condition_json.is_empty() { - return Err("condition directive is empty".to_string()); - } - let condition = serde_json::from_str(condition_json) - .map_err(|err| format!("condition directive is invalid: {err}"))?; - - let mut unset = None; - for line in lines { - if !line.is_empty() && !line.starts_with('#') { - break; - } - if line.starts_with(CONDITION_DIRECTIVE_PREFIX) { - return Err("exactly one condition directive is required".to_string()); - } - if let Some(unset_json) = line.strip_prefix(UNSET_DIRECTIVE_PREFIX) { - if unset.is_some() { - return Err("multiple unset directives are not supported".to_string()); - } - let keys: Vec = serde_json::from_str(unset_json.trim()) - .map_err(|err| format!("unset directive is invalid: {err}"))?; - if let Some(key) = keys.iter().find(|key| !is_valid_env_var_name(key)) { - return Err(format!( - "unset directive contains invalid environment variable name `{key}`" - )); - } - unset = Some(keys); - } - } - - let entries = dotenvy::from_read_iter(contents.as_bytes()) - .collect::, _>>() - .map_err(|_| "file contains an invalid dotenv assignment".to_string())?; - if entries.iter().any(|(_, value)| value.contains('\0')) { - return Err("file contains a dotenv value with a NUL byte".to_string()); - } - - Ok(Some(ConditionalDotenv { - condition, - unset: unset.unwrap_or_default(), - entries, - })) -} - -fn evaluate_condition( - condition: &Condition, - overlay_entries: &[(String, String)], - tcp_connect: &TcpConnector<'_>, -) -> Result { - match condition { - Condition::TcpConnect(condition) => { - evaluate_tcp_connect(condition, overlay_entries, tcp_connect) - } - Condition::Not { not } => { - evaluate_tcp_connect(not, overlay_entries, tcp_connect).map(|connected| !connected) - } - } -} - -fn evaluate_tcp_connect( - condition: &TcpConnectCondition, - overlay_entries: &[(String, String)], - tcp_connect: &TcpConnector<'_>, -) -> Result { - match condition { - TcpConnectCondition::TcpConnect { - from, - host, - port, - timeout_ms, - } => { - let target = parse_tcp_connect_target(from.as_deref(), host.as_deref(), *port)?; - let (host, port) = match target { - TcpConnectTarget::FromVariable(from) => { - let value = overlay_entries - .iter() - .rev() - .find_map(|(key, value)| { - (key == from || (cfg!(windows) && key.eq_ignore_ascii_case(from))) - .then_some(value) - }) - .ok_or_else(|| { - format!( - "tcp_connect source variable `{from}` is not defined in the overlay" - ) - })?; - parse_endpoint(value).ok_or_else(|| { - format!( - "tcp_connect source variable `{from}` does not contain a valid endpoint" - ) - })? - } - TcpConnectTarget::Explicit { host, port } => (host.to_string(), port), - }; - let timeout = tcp_connect_timeout(*timeout_ms)?; - Ok((tcp_connect)(&host, port, timeout)) - } - } -} - -fn apply_overlay(overlay: ConditionalDotenv, environment: &mut dyn StartupEnvironment) { - for key in overlay.unset { - if !is_reserved_env_var(&key) { - environment.remove(&key); - } - } - for (key, value) in overlay.entries { - if !is_reserved_env_var(&key) { - environment.set(&key, &value); - } - } -} - -enum TcpConnectTarget<'a> { - FromVariable(&'a str), - Explicit { host: &'a str, port: u16 }, -} - -fn parse_tcp_connect_target<'a>( - from: Option<&'a str>, - host: Option<&'a str>, - port: Option, -) -> Result, String> { - match (from, host, port) { - (Some(from), None, None) => { - if !is_valid_env_var_name(from) { - return Err(format!( - "tcp_connect contains invalid source variable name `{from}`" - )); - } - Ok(TcpConnectTarget::FromVariable(from)) - } - (None, Some(host), Some(port)) => { - if host.is_empty() { - return Err("tcp_connect host must not be empty".to_string()); - } - if port == 0 { - return Err("tcp_connect port must be greater than zero".to_string()); - } - Ok(TcpConnectTarget::Explicit { host, port }) - } - (Some(_), _, _) => Err( - "tcp_connect must specify either `from` or `host` and `port`, but not both".to_string(), - ), - (None, _, _) => { - Err("tcp_connect requires either `from` or both `host` and `port`".to_string()) - } - } -} - -fn tcp_connect_timeout(timeout_ms: Option) -> Result { - let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS); - if !(1..=MAX_TCP_CONNECT_TIMEOUT_MS).contains(&timeout_ms) { - return Err(format!( - "tcp_connect timeout_ms must be between 1 and {MAX_TCP_CONNECT_TIMEOUT_MS}" - )); - } - Ok(Duration::from_millis(timeout_ms)) -} - -fn parse_endpoint(value: &str) -> Option<(String, u16)> { - let value = value.trim(); - let parsed = if value.contains("://") { - Url::parse(value).ok()? - } else { - Url::parse(&format!("http://{value}")).ok()? - }; - let host = match parsed.host()? { - url::Host::Ipv6(address) => address.to_string(), - host => host.to_string(), - }; - let port = parsed.port_or_known_default()?; - (port != 0).then_some((host, port)) -} - -fn system_tcp_connect(host: &str, port: u16, timeout: Duration) -> bool { - // `getaddrinfo` cannot be cancelled portably. Isolate it in a child that can be killed and - // reaped without introducing a resolver thread into the environment-mutating parent. - let Ok(current_exe) = std::env::current_exe() else { - return false; - }; - let Ok(mut child) = Command::new(current_exe) - .arg(TCP_CONNECT_HELPER_ARG1) - .arg(host) - .arg(port.to_string()) - .arg(timeout.as_millis().to_string()) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn() - else { - return false; - }; - - let deadline = Instant::now() + timeout; - loop { - match child.try_wait() { - Ok(Some(status)) => return status.success(), - Ok(None) => {} - Err(_) => break, - } - - let remaining = deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - break; - } - std::thread::sleep(remaining.min(TCP_CONNECT_HELPER_POLL_INTERVAL)); - } - - let _ = child.kill(); - let _ = child.wait(); - false -} - -fn direct_tcp_connect(host: &str, port: u16, timeout: Duration) -> bool { - let deadline = Instant::now() + timeout; - let Ok(addresses) = (host, port) - .to_socket_addrs() - .map(Iterator::collect::>) - else { - return false; - }; - if deadline.saturating_duration_since(Instant::now()).is_zero() { - return false; - } - - let Ok(runtime) = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - else { - return false; - }; - runtime.block_on(async move { - tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), async move { - let mut attempts = tokio::task::JoinSet::new(); - for address in addresses { - attempts.spawn(tokio::net::TcpStream::connect(address)); - } - while let Some(result) = attempts.join_next().await { - if matches!(result, Ok(Ok(_))) { - return true; - } - } - false - }) - .await - .unwrap_or(false) - }) -} - -fn is_valid_env_var_name(key: &str) -> bool { - let mut chars = key.chars(); - chars - .next() - .is_some_and(|first| first.is_ascii_alphabetic() || first == '_') - && chars.all(|character| { - character.is_ascii_alphanumeric() || character == '_' || character == '.' - }) -} - -/// Minimal environment interface used to test overlay application without mutating the test -/// process environment. -trait StartupEnvironment { - fn set(&mut self, key: &str, value: &str); - fn remove(&mut self, key: &str); -} - -struct ProcessEnvironment; - -impl StartupEnvironment for ProcessEnvironment { - fn set(&mut self, key: &str, value: &str) { - // Safety: this loader runs from arg0_dispatch before Codex creates any threads. - unsafe { std::env::set_var(key, value) }; - } - - fn remove(&mut self, key: &str) { - // Safety: this loader runs from arg0_dispatch before Codex creates any threads. - unsafe { std::env::remove_var(key) }; - } -} - -struct ConditionalDotenv { - condition: Condition, - unset: Vec, - entries: Vec<(String, String)>, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged, deny_unknown_fields)] -enum Condition { - TcpConnect(TcpConnectCondition), - Not { not: TcpConnectCondition }, -} - -#[derive(Debug, Deserialize)] -#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] -enum TcpConnectCondition { - TcpConnect { - from: Option, - host: Option, - port: Option, - timeout_ms: Option, - }, -} - -#[cfg(test)] -#[path = "conditional_dotenv_tests.rs"] -mod tests; diff --git a/codex-rs/arg0/src/lib.rs b/codex-rs/arg0/src/lib.rs index 3833449822..1c28f81d8c 100644 --- a/codex-rs/arg0/src/lib.rs +++ b/codex-rs/arg0/src/lib.rs @@ -15,8 +15,6 @@ use codex_windows_sandbox::CODEX_WINDOWS_SANDBOX_ARG1; use std::os::unix::fs::symlink; use tempfile::TempDir; -mod conditional_dotenv; - const APPLY_PATCH_ARG0: &str = "apply_patch"; const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch"; #[cfg(unix)] @@ -100,9 +98,6 @@ pub fn arg0_dispatch() -> Option { } let argv1 = args.next().unwrap_or_default(); - if argv1 == conditional_dotenv::TCP_CONNECT_HELPER_ARG1 { - conditional_dotenv::run_tcp_connect_helper(args); - } if argv1 == CODEX_FS_HELPER_ARG1 { codex_exec_server::run_fs_helper_main(); } @@ -196,7 +191,7 @@ fn prepare_path_env_var_with_aliases( /// `codex-linux-sandbox` we *directly* execute /// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we: /// -/// 1. Load `.env` and matching conditional dotenv values from `~/.codex` before creating threads. +/// 1. Load `.env` values from `~/.codex/.env` before creating any threads. /// 2. Spawn a main runtime thread with a controlled stack size. /// 3. Construct a Tokio multi-thread runtime. /// 4. Capture the current executable path and derive the @@ -290,18 +285,16 @@ fn build_runtime() -> anyhow::Result { const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_"; -/// Load base and conditional env vars from the startup Codex home. +/// Load env vars from ~/.codex/.env. /// /// Security: Do not allow `.env` files to create or modify any variables /// with names starting with `CODEX_`. fn load_dotenv() { - let Ok(codex_home) = find_codex_home() else { - return; - }; - if let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) { + if let Ok(codex_home) = find_codex_home() + && let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) + { set_filtered(iter); } - conditional_dotenv::load(codex_home.as_path()); } /// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys. @@ -310,7 +303,7 @@ where I: IntoIterator>, { for (key, value) in iter.into_iter().flatten() { - if !is_reserved_env_var(&key) { + if !key.to_ascii_uppercase().starts_with(ILLEGAL_ENV_VAR_PREFIX) { // It is safe to call set_var() because our process is // single-threaded at this point in its execution. unsafe { std::env::set_var(&key, &value) }; @@ -318,11 +311,6 @@ where } } -fn is_reserved_env_var(key: &str) -> bool { - key.get(..ILLEGAL_ENV_VAR_PREFIX.len()) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case(ILLEGAL_ENV_VAR_PREFIX)) -} - /// Creates a temporary directory with either: /// /// - UNIX: `apply_patch` symlink to the current executable From 7affe3e3e476e002b7361ffbf644f8b40bad7a8f Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 6 Jul 2026 12:22:40 -0700 Subject: [PATCH 12/76] refactor(protocol): isolate legacy item fanout (#30956) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Stack 1. [#30956](https://github.com/openai/codex/pull/30956) — isolate legacy item fanout ← **this PR** 2. [#30283](https://github.com/openai/codex/pull/30283) — emit canonical `TurnItem` lifecycle 3. [#30188](https://github.com/openai/codex/pull/30188) — persist canonical items for paginated threads ## Description Move legacy `EventMsg` projection code out of the canonical item and protocol schema modules into `protocol/src/legacy_events.rs`. This is a behavior-neutral extraction. It keeps the existing `HasLegacyEvent` API and the existing legacy projections unchanged, while giving compatibility fanout a single home. ## Why Canonical `TurnItem` types and wire event schemas should not own the implementation details for legacy compatibility projections. Isolating that code makes the boundary explicit and keeps follow-up canonical lifecycle work easier to review. --- codex-rs/protocol/src/items.rs | 189 ---------------- codex-rs/protocol/src/legacy_events.rs | 289 +++++++++++++++++++++++++ codex-rs/protocol/src/lib.rs | 1 + codex-rs/protocol/src/protocol.rs | 74 +------ 4 files changed, 292 insertions(+), 261 deletions(-) create mode 100644 codex-rs/protocol/src/legacy_events.rs diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index c319573446..820c3b774b 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -10,26 +10,12 @@ use crate::models::ResponseItem; use crate::models::WebSearchAction; use crate::openai_models::ReasoningEffort as ReasoningEffortConfig; use crate::parse_command::ParsedCommand; -use crate::protocol::AgentMessageEvent; -use crate::protocol::AgentReasoningEvent; -use crate::protocol::AgentReasoningRawContentEvent; use crate::protocol::AgentStatus; use crate::protocol::CollabAgentRef; -use crate::protocol::ContextCompactedEvent; -use crate::protocol::EventMsg; use crate::protocol::ExecCommandSource; use crate::protocol::FileChange; -use crate::protocol::ImageGenerationEndEvent; -use crate::protocol::McpInvocation; -use crate::protocol::McpToolCallBeginEvent; -use crate::protocol::McpToolCallEndEvent; -use crate::protocol::PatchApplyBeginEvent; -use crate::protocol::PatchApplyEndEvent; use crate::protocol::PatchApplyStatus; use crate::protocol::SubAgentActivityKind; -use crate::protocol::UserMessageEvent; -use crate::protocol::ViewImageToolCallEvent; -use crate::protocol::WebSearchEndEvent; use crate::user_input::ByteRange; use crate::user_input::TextElement; use crate::user_input::UserInput; @@ -387,10 +373,6 @@ impl ContextCompactionItem { id: uuid::Uuid::new_v4().to_string(), } } - - pub fn as_legacy_event(&self) -> EventMsg { - EventMsg::ContextCompacted(ContextCompactedEvent {}) - } } impl Default for ContextCompactionItem { @@ -408,20 +390,6 @@ impl UserMessageItem { } } - pub fn as_legacy_event(&self) -> EventMsg { - // Legacy user-message events flatten only text inputs into `message` and - // rebase text element ranges onto that concatenated text. - EventMsg::UserMessage(UserMessageEvent { - client_id: self.client_id.clone(), - message: self.message(), - images: Some(self.image_urls()), - image_details: self.image_details(), - local_images: self.local_image_paths(), - local_image_details: self.local_image_details(), - text_elements: self.text_elements(), - }) - } - pub fn message(&self) -> String { self.content .iter() @@ -609,134 +577,6 @@ impl AgentMessageItem { memory_citation: None, } } - - pub fn as_legacy_events(&self) -> Vec { - self.content - .iter() - .map(|c| match c { - AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent { - message: text.clone(), - phase: self.phase.clone(), - memory_citation: self.memory_citation.clone(), - }), - }) - .collect() - } -} - -impl ReasoningItem { - pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - let mut events = Vec::new(); - for summary in &self.summary_text { - events.push(EventMsg::AgentReasoning(AgentReasoningEvent { - text: summary.clone(), - })); - } - - if show_raw_agent_reasoning { - for entry in &self.raw_content { - events.push(EventMsg::AgentReasoningRawContent( - AgentReasoningRawContentEvent { - text: entry.clone(), - }, - )); - } - } - - events - } -} - -impl WebSearchItem { - pub fn as_legacy_event(&self) -> EventMsg { - EventMsg::WebSearchEnd(WebSearchEndEvent { - call_id: self.id.clone(), - query: self.query.clone(), - action: self.action.clone(), - }) - } -} - -impl ImageGenerationItem { - pub fn as_legacy_event(&self) -> EventMsg { - EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { - call_id: self.id.clone(), - status: self.status.clone(), - revised_prompt: self.revised_prompt.clone(), - result: self.result.clone(), - saved_path: self.saved_path.clone(), - }) - } -} - -impl FileChangeItem { - pub fn as_legacy_begin_event(&self, turn_id: String) -> EventMsg { - EventMsg::PatchApplyBegin(PatchApplyBeginEvent { - call_id: self.id.clone(), - turn_id, - auto_approved: self.auto_approved.unwrap_or(false), - changes: self.changes.clone(), - }) - } - - pub fn as_legacy_end_event(&self, turn_id: String) -> Option { - let status = self.status.clone()?; - Some(EventMsg::PatchApplyEnd(PatchApplyEndEvent { - call_id: self.id.clone(), - turn_id, - stdout: self.stdout.clone().unwrap_or_default(), - stderr: self.stderr.clone().unwrap_or_default(), - success: status == PatchApplyStatus::Completed, - changes: self.changes.clone(), - status, - })) - } -} - -impl McpToolCallItem { - pub fn as_legacy_begin_event(&self) -> EventMsg { - EventMsg::McpToolCallBegin(McpToolCallBeginEvent { - call_id: self.id.clone(), - invocation: McpInvocation { - server: self.server.clone(), - tool: self.tool.clone(), - arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), - }, - connector_id: self.connector_id.clone(), - mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), - link_id: self.link_id.clone(), - app_name: self.app_name.clone(), - template_id: self.template_id.clone(), - action_name: self.action_name.clone(), - plugin_id: self.plugin_id.clone(), - }) - } - - pub fn as_legacy_end_event(&self) -> Option { - let result = match (&self.result, &self.error) { - (Some(result), _) => Ok(result.clone()), - (None, Some(error)) => Err(error.message.clone()), - (None, None) => return None, - }; - - Some(EventMsg::McpToolCallEnd(McpToolCallEndEvent { - call_id: self.id.clone(), - invocation: McpInvocation { - server: self.server.clone(), - tool: self.tool.clone(), - arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), - }, - mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), - connector_id: self.connector_id.clone(), - link_id: self.link_id.clone(), - app_name: self.app_name.clone(), - template_id: self.template_id.clone(), - action_name: self.action_name.clone(), - plugin_id: self.plugin_id.clone(), - duration: self.duration?, - result, - })) - } } impl TurnItem { @@ -760,35 +600,6 @@ impl TurnItem { TurnItem::ContextCompaction(item) => item.id.clone(), } } - - pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - match self { - TurnItem::UserMessage(item) => vec![item.as_legacy_event()], - TurnItem::HookPrompt(_) => Vec::new(), - TurnItem::AgentMessage(item) => item.as_legacy_events(), - TurnItem::Plan(_) => Vec::new(), - TurnItem::CommandExecution(_) - | TurnItem::DynamicToolCall(_) - | TurnItem::CollabAgentToolCall(_) => Vec::new(), - TurnItem::SubAgentActivity(_) => Vec::new(), - TurnItem::WebSearch(item) => vec![item.as_legacy_event()], - TurnItem::ImageView(item) => { - vec![EventMsg::ViewImageToolCall(ViewImageToolCallEvent { - call_id: item.id.clone(), - path: item.path.clone(), - })] - } - TurnItem::Sleep(_) => Vec::new(), - TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()], - TurnItem::FileChange(item) => item - .as_legacy_end_event(String::new()) - .into_iter() - .collect(), - TurnItem::McpToolCall(item) => item.as_legacy_end_event().into_iter().collect(), - TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning), - TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()], - } - } } #[cfg(test)] diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs new file mode 100644 index 0000000000..f4a511d367 --- /dev/null +++ b/codex-rs/protocol/src/legacy_events.rs @@ -0,0 +1,289 @@ +use crate::items::AgentMessageContent; +use crate::items::AgentMessageItem; +use crate::items::ContextCompactionItem; +use crate::items::FileChangeItem; +use crate::items::ImageGenerationItem; +use crate::items::McpToolCallItem; +use crate::items::ReasoningItem; +use crate::items::TurnItem; +use crate::items::UserMessageItem; +use crate::items::WebSearchItem; +use crate::protocol::AgentMessageContentDeltaEvent; +use crate::protocol::AgentMessageEvent; +use crate::protocol::AgentReasoningEvent; +use crate::protocol::AgentReasoningRawContentEvent; +use crate::protocol::ContextCompactedEvent; +use crate::protocol::EventMsg; +use crate::protocol::ImageGenerationBeginEvent; +use crate::protocol::ImageGenerationEndEvent; +use crate::protocol::ItemCompletedEvent; +use crate::protocol::ItemStartedEvent; +use crate::protocol::McpInvocation; +use crate::protocol::McpToolCallBeginEvent; +use crate::protocol::McpToolCallEndEvent; +use crate::protocol::PatchApplyBeginEvent; +use crate::protocol::PatchApplyEndEvent; +use crate::protocol::PatchApplyStatus; +use crate::protocol::ReasoningContentDeltaEvent; +use crate::protocol::ReasoningRawContentDeltaEvent; +use crate::protocol::UserMessageEvent; +use crate::protocol::ViewImageToolCallEvent; +use crate::protocol::WebSearchBeginEvent; +use crate::protocol::WebSearchEndEvent; + +/// Converts canonical item lifecycle events back into the legacy raw event stream used by +/// compatibility consumers that have not migrated to `TurnItem`. +pub trait HasLegacyEvent { + fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec; +} + +impl ContextCompactionItem { + pub fn as_legacy_event(&self) -> EventMsg { + EventMsg::ContextCompacted(ContextCompactedEvent {}) + } +} + +impl UserMessageItem { + pub fn as_legacy_event(&self) -> EventMsg { + // Legacy user-message events flatten only text inputs into `message` and + // rebase text element ranges onto that concatenated text. + EventMsg::UserMessage(UserMessageEvent { + client_id: self.client_id.clone(), + message: self.message(), + images: Some(self.image_urls()), + image_details: self.image_details(), + local_images: self.local_image_paths(), + local_image_details: self.local_image_details(), + text_elements: self.text_elements(), + }) + } +} + +impl AgentMessageItem { + pub fn as_legacy_events(&self) -> Vec { + self.content + .iter() + .map(|c| match c { + AgentMessageContent::Text { text } => EventMsg::AgentMessage(AgentMessageEvent { + message: text.clone(), + phase: self.phase.clone(), + memory_citation: self.memory_citation.clone(), + }), + }) + .collect() + } +} + +impl ReasoningItem { + pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + let mut events = Vec::new(); + for summary in &self.summary_text { + events.push(EventMsg::AgentReasoning(AgentReasoningEvent { + text: summary.clone(), + })); + } + + if show_raw_agent_reasoning { + for entry in &self.raw_content { + events.push(EventMsg::AgentReasoningRawContent( + AgentReasoningRawContentEvent { + text: entry.clone(), + }, + )); + } + } + + events + } +} + +impl WebSearchItem { + pub fn as_legacy_event(&self) -> EventMsg { + EventMsg::WebSearchEnd(WebSearchEndEvent { + call_id: self.id.clone(), + query: self.query.clone(), + action: self.action.clone(), + }) + } +} + +impl ImageGenerationItem { + pub fn as_legacy_event(&self) -> EventMsg { + EventMsg::ImageGenerationEnd(ImageGenerationEndEvent { + call_id: self.id.clone(), + status: self.status.clone(), + revised_prompt: self.revised_prompt.clone(), + result: self.result.clone(), + saved_path: self.saved_path.clone(), + }) + } +} + +impl FileChangeItem { + pub fn as_legacy_begin_event(&self, turn_id: String) -> EventMsg { + EventMsg::PatchApplyBegin(PatchApplyBeginEvent { + call_id: self.id.clone(), + turn_id, + auto_approved: self.auto_approved.unwrap_or(false), + changes: self.changes.clone(), + }) + } + + pub fn as_legacy_end_event(&self, turn_id: String) -> Option { + let status = self.status.clone()?; + Some(EventMsg::PatchApplyEnd(PatchApplyEndEvent { + call_id: self.id.clone(), + turn_id, + stdout: self.stdout.clone().unwrap_or_default(), + stderr: self.stderr.clone().unwrap_or_default(), + success: status == PatchApplyStatus::Completed, + changes: self.changes.clone(), + status, + })) + } +} + +impl McpToolCallItem { + pub fn as_legacy_begin_event(&self) -> EventMsg { + EventMsg::McpToolCallBegin(McpToolCallBeginEvent { + call_id: self.id.clone(), + invocation: McpInvocation { + server: self.server.clone(), + tool: self.tool.clone(), + arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), + }, + connector_id: self.connector_id.clone(), + mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), + link_id: self.link_id.clone(), + app_name: self.app_name.clone(), + template_id: self.template_id.clone(), + action_name: self.action_name.clone(), + plugin_id: self.plugin_id.clone(), + }) + } + + pub fn as_legacy_end_event(&self) -> Option { + let result = match (&self.result, &self.error) { + (Some(result), _) => Ok(result.clone()), + (None, Some(error)) => Err(error.message.clone()), + (None, None) => return None, + }; + + Some(EventMsg::McpToolCallEnd(McpToolCallEndEvent { + call_id: self.id.clone(), + invocation: McpInvocation { + server: self.server.clone(), + tool: self.tool.clone(), + arguments: (!self.arguments.is_null()).then(|| self.arguments.clone()), + }, + mcp_app_resource_uri: self.mcp_app_resource_uri.clone(), + connector_id: self.connector_id.clone(), + link_id: self.link_id.clone(), + app_name: self.app_name.clone(), + template_id: self.template_id.clone(), + action_name: self.action_name.clone(), + plugin_id: self.plugin_id.clone(), + duration: self.duration?, + result, + })) + } +} + +impl TurnItem { + pub fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + match self { + TurnItem::UserMessage(item) => vec![item.as_legacy_event()], + TurnItem::HookPrompt(_) => Vec::new(), + TurnItem::AgentMessage(item) => item.as_legacy_events(), + TurnItem::Plan(_) => Vec::new(), + TurnItem::CommandExecution(_) + | TurnItem::DynamicToolCall(_) + | TurnItem::CollabAgentToolCall(_) => Vec::new(), + TurnItem::SubAgentActivity(_) => Vec::new(), + TurnItem::WebSearch(item) => vec![item.as_legacy_event()], + TurnItem::ImageView(item) => { + vec![EventMsg::ViewImageToolCall(ViewImageToolCallEvent { + call_id: item.id.clone(), + path: item.path.clone(), + })] + } + TurnItem::Sleep(_) => Vec::new(), + TurnItem::ImageGeneration(item) => vec![item.as_legacy_event()], + TurnItem::FileChange(item) => item + .as_legacy_end_event(String::new()) + .into_iter() + .collect(), + TurnItem::McpToolCall(item) => item.as_legacy_end_event().into_iter().collect(), + TurnItem::Reasoning(item) => item.as_legacy_events(show_raw_agent_reasoning), + TurnItem::ContextCompaction(item) => vec![item.as_legacy_event()], + } + } +} + +impl HasLegacyEvent for ItemStartedEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + match &self.item { + TurnItem::WebSearch(item) => vec![EventMsg::WebSearchBegin(WebSearchBeginEvent { + call_id: item.id.clone(), + })], + TurnItem::ImageView(_) => Vec::new(), + TurnItem::ImageGeneration(item) => { + vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { + call_id: item.id.clone(), + })] + } + TurnItem::FileChange(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())], + TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event()], + _ => Vec::new(), + } + } +} + +impl HasLegacyEvent for ItemCompletedEvent { + fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + match &self.item { + TurnItem::FileChange(item) => item + .as_legacy_end_event(self.turn_id.clone()) + .into_iter() + .collect(), + _ => self.item.as_legacy_events(show_raw_agent_reasoning), + } + } +} + +impl HasLegacyEvent for AgentMessageContentDeltaEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + Vec::new() + } +} + +impl HasLegacyEvent for ReasoningContentDeltaEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + Vec::new() + } +} + +impl HasLegacyEvent for ReasoningRawContentDeltaEvent { + fn as_legacy_events(&self, _: bool) -> Vec { + Vec::new() + } +} + +impl HasLegacyEvent for EventMsg { + fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { + match self { + EventMsg::ItemStarted(event) => event.as_legacy_events(show_raw_agent_reasoning), + EventMsg::ItemCompleted(event) => event.as_legacy_events(show_raw_agent_reasoning), + EventMsg::AgentMessageContentDelta(event) => { + event.as_legacy_events(show_raw_agent_reasoning) + } + EventMsg::ReasoningContentDelta(event) => { + event.as_legacy_events(show_raw_agent_reasoning) + } + EventMsg::ReasoningRawContentDelta(event) => { + event.as_legacy_events(show_raw_agent_reasoning) + } + _ => Vec::new(), + } + } +} diff --git a/codex-rs/protocol/src/lib.rs b/codex-rs/protocol/src/lib.rs index c50e95d37f..636c64ed7b 100644 --- a/codex-rs/protocol/src/lib.rs +++ b/codex-rs/protocol/src/lib.rs @@ -16,6 +16,7 @@ pub mod dynamic_tools; pub mod error; pub mod exec_output; pub mod items; +mod legacy_events; pub mod mcp; pub mod mcp_approval_meta; pub mod memory_citation; diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 0096915efb..2c97af10aa 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -84,6 +84,7 @@ pub use crate::approvals::NetworkApprovalContext; pub use crate::approvals::NetworkApprovalProtocol; pub use crate::approvals::NetworkPolicyAmendment; pub use crate::approvals::NetworkPolicyRuleAction; +pub use crate::legacy_events::HasLegacyEvent; pub use crate::permissions::FileSystemAccessMode; pub use crate::permissions::FileSystemPath; pub use crate::permissions::FileSystemSandboxEntry; @@ -1794,25 +1795,6 @@ pub struct ItemStartedEvent { pub started_at_ms: i64, } -impl HasLegacyEvent for ItemStartedEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - match &self.item { - TurnItem::WebSearch(item) => vec![EventMsg::WebSearchBegin(WebSearchBeginEvent { - call_id: item.id.clone(), - })], - TurnItem::ImageView(_) => Vec::new(), - TurnItem::ImageGeneration(item) => { - vec![EventMsg::ImageGenerationBegin(ImageGenerationBeginEvent { - call_id: item.id.clone(), - })] - } - TurnItem::FileChange(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())], - TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event()], - _ => Vec::new(), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ItemCompletedEvent { pub thread_id: ThreadId, @@ -1829,22 +1811,6 @@ const fn default_item_completed_at_ms() -> i64 { 0 } -pub trait HasLegacyEvent { - fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec; -} - -impl HasLegacyEvent for ItemCompletedEvent { - fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - match &self.item { - TurnItem::FileChange(item) => item - .as_legacy_end_event(self.turn_id.clone()) - .into_iter() - .collect(), - _ => self.item.as_legacy_events(show_raw_agent_reasoning), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct AgentMessageContentDeltaEvent { pub thread_id: String, @@ -1853,12 +1819,6 @@ pub struct AgentMessageContentDeltaEvent { pub delta: String, } -impl HasLegacyEvent for AgentMessageContentDeltaEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - Vec::new() - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct PlanDeltaEvent { pub thread_id: String, @@ -1878,12 +1838,6 @@ pub struct ReasoningContentDeltaEvent { pub summary_index: i64, } -impl HasLegacyEvent for ReasoningContentDeltaEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - Vec::new() - } -} - #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema)] pub struct ReasoningRawContentDeltaEvent { pub thread_id: String, @@ -1895,31 +1849,6 @@ pub struct ReasoningRawContentDeltaEvent { pub content_index: i64, } -impl HasLegacyEvent for ReasoningRawContentDeltaEvent { - fn as_legacy_events(&self, _: bool) -> Vec { - Vec::new() - } -} - -impl HasLegacyEvent for EventMsg { - fn as_legacy_events(&self, show_raw_agent_reasoning: bool) -> Vec { - match self { - EventMsg::ItemStarted(event) => event.as_legacy_events(show_raw_agent_reasoning), - EventMsg::ItemCompleted(event) => event.as_legacy_events(show_raw_agent_reasoning), - EventMsg::AgentMessageContentDelta(event) => { - event.as_legacy_events(show_raw_agent_reasoning) - } - EventMsg::ReasoningContentDelta(event) => { - event.as_legacy_events(show_raw_agent_reasoning) - } - EventMsg::ReasoningRawContentDelta(event) => { - event.as_legacy_events(show_raw_agent_reasoning) - } - _ => Vec::new(), - } - } -} - #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ExitedReviewModeEvent { pub review_output: Option, @@ -5386,6 +5315,7 @@ mod tests { let event = serde_json::from_value::(value).unwrap(); assert_eq!(event.completed_at_ms, 0); } + #[test] fn rollback_failed_error_does_not_affect_turn_status() { let event = ErrorEvent { From 58ec5283156c3fce6421d38684afef0a656129d4 Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 6 Jul 2026 12:54:06 -0700 Subject: [PATCH 13/76] [app-server] Include reset-credit details in rate limits (#30395) ## Why Supported clients currently receive only a reset-credit count from `account/rateLimits/read`. The redemption UI and other app-server clients need each available credit's expiry and ID so they can explain what will expire and consume the credit a user selected. This information belongs on the existing rate-limit read surface rather than a second app-server list RPC that clients would need to coordinate. ## What changed - extend `rateLimitResetCredits` on `account/rateLimits/read` with nullable `credits` detail rows - fetch usage and reset-credit details concurrently; if the detail request fails, times out, or cannot be parsed, preserve the usage response and return `credits: null` - expose each credit's ID, reset type, status, grant time, expiry time, title, and description - add an optional nullable `creditId` to `account/rateLimitResetCredit/consume`; omitting it preserves the existing automatic-selection behavior - forward a selected credit ID to the Codex backend and update the app-server documentation and generated schemas The TUI consumer is stacked in #30488. ## Validation - `just test -p codex-app-server-protocol` (251 passed) - `just test -p codex-backend-client` (16 passed) - `just test -p codex-app-server rate_limit` (18 passed) Part of #29618. --- .../schema/json/ClientRequest.json | 7 + .../codex_app_server_protocol.schemas.json | 81 ++++++++++ .../codex_app_server_protocol.v2.schemas.json | 81 ++++++++++ ...sumeAccountRateLimitResetCreditParams.json | 7 + .../json/v2/GetAccountRateLimitsResponse.json | 74 +++++++++ ...onsumeAccountRateLimitResetCreditParams.ts | 7 +- .../typescript/v2/RateLimitResetCredit.ts | 27 ++++ .../v2/RateLimitResetCreditStatus.ts | 5 + .../v2/RateLimitResetCreditsSummary.ts | 11 +- .../typescript/v2/RateLimitResetType.ts | 5 + .../schema/typescript/v2/index.ts | 3 + .../src/protocol/v2/account.rs | 50 ++++++ codex-rs/app-server/README.md | 39 ++++- codex-rs/app-server/src/request_processors.rs | 5 + .../request_processors/account_processor.rs | 23 ++- .../account_processor/rate_limit_resets.rs | 110 ++++++++++++- .../suite/v2/rate_limit_reset_credits.rs | 61 +++++++ .../app-server/tests/suite/v2/rate_limits.rs | 129 ++++++++++++++- .../src/client/rate_limit_resets.rs | 44 ++++- .../src/client/rate_limit_resets_tests.rs | 77 +++++++++ codex-rs/backend-client/src/lib.rs | 2 + codex-rs/backend-client/src/types.rs | 17 ++ codex-rs/tui/src/app/background_requests.rs | 5 +- codex-rs/tui/src/chatwidget/tests/usage.rs | 150 ++++++++++++++---- 24 files changed, 967 insertions(+), 53 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 7f7ead662b..2f39f821b6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -556,6 +556,13 @@ }, "ConsumeAccountRateLimitResetCreditParams": { "properties": { + "creditId": { + "description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.", + "type": [ + "string", + "null" + ] + }, "idempotencyKey": { "description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.", "type": "string" 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 53139c82d8..0eedef903a 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 @@ -8688,6 +8688,13 @@ "ConsumeAccountRateLimitResetCreditParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "creditId": { + "description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.", + "type": [ + "string", + "null" + ] + }, "idempotencyKey": { "description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.", "type": "string" @@ -14533,11 +14540,78 @@ ], "type": "string" }, + "RateLimitResetCredit": { + "properties": { + "description": { + "description": "Backend-provided display description for this credit, or `null` when unavailable.", + "type": [ + "string", + "null" + ] + }, + "expiresAt": { + "description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "grantedAt": { + "description": "Unix timestamp in seconds when the credit was granted.", + "format": "int64", + "type": "integer" + }, + "id": { + "description": "Opaque backend identifier for this reset credit.", + "type": "string" + }, + "resetType": { + "$ref": "#/definitions/v2/RateLimitResetType" + }, + "status": { + "$ref": "#/definitions/v2/RateLimitResetCreditStatus" + }, + "title": { + "description": "Backend-provided display title for this credit, or `null` when unavailable.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "grantedAt", + "id", + "resetType", + "status" + ], + "type": "object" + }, + "RateLimitResetCreditStatus": { + "enum": [ + "available", + "redeeming", + "redeemed", + "unknown" + ], + "type": "string" + }, "RateLimitResetCreditsSummary": { "properties": { "availableCount": { "format": "int64", "type": "integer" + }, + "credits": { + "description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.", + "items": { + "$ref": "#/definitions/v2/RateLimitResetCredit" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -14545,6 +14619,13 @@ ], "type": "object" }, + "RateLimitResetType": { + "enum": [ + "codexRateLimits", + "unknown" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { 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 961dd14f3e..b428f25258 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 @@ -4928,6 +4928,13 @@ "ConsumeAccountRateLimitResetCreditParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "creditId": { + "description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.", + "type": [ + "string", + "null" + ] + }, "idempotencyKey": { "description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.", "type": "string" @@ -10937,11 +10944,78 @@ ], "type": "string" }, + "RateLimitResetCredit": { + "properties": { + "description": { + "description": "Backend-provided display description for this credit, or `null` when unavailable.", + "type": [ + "string", + "null" + ] + }, + "expiresAt": { + "description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "grantedAt": { + "description": "Unix timestamp in seconds when the credit was granted.", + "format": "int64", + "type": "integer" + }, + "id": { + "description": "Opaque backend identifier for this reset credit.", + "type": "string" + }, + "resetType": { + "$ref": "#/definitions/RateLimitResetType" + }, + "status": { + "$ref": "#/definitions/RateLimitResetCreditStatus" + }, + "title": { + "description": "Backend-provided display title for this credit, or `null` when unavailable.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "grantedAt", + "id", + "resetType", + "status" + ], + "type": "object" + }, + "RateLimitResetCreditStatus": { + "enum": [ + "available", + "redeeming", + "redeemed", + "unknown" + ], + "type": "string" + }, "RateLimitResetCreditsSummary": { "properties": { "availableCount": { "format": "int64", "type": "integer" + }, + "credits": { + "description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.", + "items": { + "$ref": "#/definitions/RateLimitResetCredit" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -10949,6 +11023,13 @@ ], "type": "object" }, + "RateLimitResetType": { + "enum": [ + "codexRateLimits", + "unknown" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json b/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json index c9bf023a63..3d9d2c1e98 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConsumeAccountRateLimitResetCreditParams.json @@ -1,6 +1,13 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "creditId": { + "description": "Opaque reset-credit identifier to redeem. When omitted, the backend selects the next available credit.", + "type": [ + "string", + "null" + ] + }, "idempotencyKey": { "description": "Identifies one logical reset attempt. A UUID is recommended; reuse the same value when retrying that attempt.", "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json index 11b9712510..a2883a1293 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountRateLimitsResponse.json @@ -49,11 +49,78 @@ ], "type": "string" }, + "RateLimitResetCredit": { + "properties": { + "description": { + "description": "Backend-provided display description for this credit, or `null` when unavailable.", + "type": [ + "string", + "null" + ] + }, + "expiresAt": { + "description": "Unix timestamp in seconds when the credit expires, or `null` if it does not expire.", + "format": "int64", + "type": [ + "integer", + "null" + ] + }, + "grantedAt": { + "description": "Unix timestamp in seconds when the credit was granted.", + "format": "int64", + "type": "integer" + }, + "id": { + "description": "Opaque backend identifier for this reset credit.", + "type": "string" + }, + "resetType": { + "$ref": "#/definitions/RateLimitResetType" + }, + "status": { + "$ref": "#/definitions/RateLimitResetCreditStatus" + }, + "title": { + "description": "Backend-provided display title for this credit, or `null` when unavailable.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "grantedAt", + "id", + "resetType", + "status" + ], + "type": "object" + }, + "RateLimitResetCreditStatus": { + "enum": [ + "available", + "redeeming", + "redeemed", + "unknown" + ], + "type": "string" + }, "RateLimitResetCreditsSummary": { "properties": { "availableCount": { "format": "int64", "type": "integer" + }, + "credits": { + "description": "Detail rows for available reset credits, when the backend provides them.\n\n`null` means only `availableCount` is known, while an empty array means details were fetched and no available credits were returned. The backend may cap this list, so its length can be less than `availableCount`.", + "items": { + "$ref": "#/definitions/RateLimitResetCredit" + }, + "type": [ + "array", + "null" + ] } }, "required": [ @@ -61,6 +128,13 @@ ], "type": "object" }, + "RateLimitResetType": { + "enum": [ + "codexRateLimits", + "unknown" + ], + "type": "string" + }, "RateLimitSnapshot": { "properties": { "credits": { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts index c3cef64f36..f1c5bf351e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConsumeAccountRateLimitResetCreditParams.ts @@ -7,4 +7,9 @@ export type ConsumeAccountRateLimitResetCreditParams = { * Identifies one logical reset attempt. A UUID is recommended; reuse the same value when * retrying that attempt. */ -idempotencyKey: string, }; +idempotencyKey: string, +/** + * Opaque reset-credit identifier to redeem. When omitted, the backend selects the next + * available credit. + */ +creditId?: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts new file mode 100644 index 0000000000..514c1558a2 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCredit.ts @@ -0,0 +1,27 @@ +// 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 { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus"; +import type { RateLimitResetType } from "./RateLimitResetType"; + +export type RateLimitResetCredit = { +/** + * Opaque backend identifier for this reset credit. + */ +id: string, resetType: RateLimitResetType, status: RateLimitResetCreditStatus, +/** + * Unix timestamp in seconds when the credit was granted. + */ +grantedAt: number, +/** + * Unix timestamp in seconds when the credit expires, or `null` if it does not expire. + */ +expiresAt: number | null, +/** + * Backend-provided display title for this credit, or `null` when unavailable. + */ +title: string | null, +/** + * Backend-provided display description for this credit, or `null` when unavailable. + */ +description: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.ts new file mode 100644 index 0000000000..fa15861b3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditStatus.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 RateLimitResetCreditStatus = "available" | "redeeming" | "redeemed" | "unknown"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts index e42dd8a703..46a8eee2e7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetCreditsSummary.ts @@ -1,5 +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 { RateLimitResetCredit } from "./RateLimitResetCredit"; -export type RateLimitResetCreditsSummary = { availableCount: bigint, }; +export type RateLimitResetCreditsSummary = { availableCount: bigint, +/** + * Detail rows for available reset credits, when the backend provides them. + * + * `null` means only `availableCount` is known, while an empty array means details were fetched + * and no available credits were returned. The backend may cap this list, so its length can be + * less than `availableCount`. + */ +credits: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.ts new file mode 100644 index 0000000000..718145bf47 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RateLimitResetType.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 RateLimitResetType = "codexRateLimits" | "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 c405082c4c..c335f144ec 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -335,7 +335,10 @@ export type { ProcessOutputDeltaNotification } from "./ProcessOutputDeltaNotific export type { ProcessOutputStream } from "./ProcessOutputStream"; export type { ProcessTerminalSize } from "./ProcessTerminalSize"; export type { RateLimitReachedType } from "./RateLimitReachedType"; +export type { RateLimitResetCredit } from "./RateLimitResetCredit"; +export type { RateLimitResetCreditStatus } from "./RateLimitResetCreditStatus"; export type { RateLimitResetCreditsSummary } from "./RateLimitResetCreditsSummary"; +export type { RateLimitResetType } from "./RateLimitResetType"; export type { RateLimitSnapshot } from "./RateLimitSnapshot"; export type { RateLimitWindow } from "./RateLimitWindow"; export type { RawResponseItemCompletedNotification } from "./RawResponseItemCompletedNotification"; 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 635d001bba..788c8f7aaf 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -284,6 +284,52 @@ pub struct GetAccountRateLimitsResponse { #[ts(export_to = "v2/")] pub struct RateLimitResetCreditsSummary { pub available_count: i64, + /// Detail rows for available reset credits, when the backend provides them. + /// + /// `null` means only `availableCount` is known, while an empty array means details were fetched + /// and no available credits were returned. The backend may cap this list, so its length can be + /// less than `availableCount`. + pub credits: Option>, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RateLimitResetCredit { + /// Opaque backend identifier for this reset credit. + pub id: String, + pub reset_type: RateLimitResetType, + pub status: RateLimitResetCreditStatus, + /// Unix timestamp in seconds when the credit was granted. + #[ts(type = "number")] + pub granted_at: i64, + /// Unix timestamp in seconds when the credit expires, or `null` if it does not expire. + #[ts(type = "number | null")] + pub expires_at: Option, + /// Backend-provided display title for this credit, or `null` when unavailable. + pub title: Option, + /// Backend-provided display description for this credit, or `null` when unavailable. + pub description: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/", rename_all = "camelCase")] +pub enum RateLimitResetType { + CodexRateLimits, + #[serde(other)] + Unknown, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/", rename_all = "camelCase")] +pub enum RateLimitResetCreditStatus { + Available, + Redeeming, + Redeemed, + #[serde(other)] + Unknown, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] @@ -293,6 +339,10 @@ pub struct ConsumeAccountRateLimitResetCreditParams { /// Identifies one logical reset attempt. A UUID is recommended; reuse the same value when /// retrying that attempt. pub idempotency_key: String, + /// Opaque reset-credit identifier to redeem. When omitted, the backend selects the next + /// available credit. + #[ts(optional = nullable)] + pub credit_id: Option, } #[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 1dfa6b4164..a7986753ee 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1929,8 +1929,8 @@ Codex supports these authentication modes. The current mode is surfaced in `acco - `account/login/cancel` — cancel a pending managed ChatGPT login by `loginId`. - `account/logout` — sign out; triggers `account/updated`. - `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, `personalAccessToken`, or `null`) and includes the current ChatGPT `planType` when available. -- `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/rateLimits/read` — fetch ChatGPT rate limits, an optional effective monthly credit limit, and the earned rate-limit resets currently available, including expiry details when provided by the backend. Rate-limit updates arrive via `account/rateLimits/updated` (notify); reset-credit data is snapshot-only. +- `account/rateLimitResetCredit/consume` — consume one earned reset using a caller-provided idempotency key, optionally selecting a reset-credit ID returned by `account/rateLimits/read`. - `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. @@ -2032,7 +2032,30 @@ Field notes: ```json { "method": "account/rateLimits/read", "id": 7 } -{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null }, "rateLimitResetCredits": { "availableCount": 2 } } } +{ + "id": 7, + "result": { + "rateLimits": { + "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, + "secondary": null, + "rateLimitReachedType": null + }, + "rateLimitResetCredits": { + "availableCount": 2, + "credits": [ + { + "id": "RateLimitResetCredit_1", + "resetType": "codexRateLimits", + "status": "available", + "grantedAt": 1781654400, + "expiresAt": 1784246400, + "title": "Full reset (Weekly + 5 hr)", + "description": "Ready to redeem" + } + ] + } + } +} { "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } } ``` @@ -2043,23 +2066,27 @@ Field notes: - `resetsAt` is a Unix timestamp (seconds) for the next reset. - `rateLimitReachedType` identifies the backend-classified limit state when one has been reached. - `individualLimit` describes the effective monthly credit limit when available. In an `account/rateLimits/read` response, `null` means no monthly limit is available. In a sparse `account/rateLimits/updated` notification, nullable account metadata may be unavailable and does not clear a previously observed value. -- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. Refetch `account/rateLimits/read` after consuming a reset. +- `rateLimitResetCredits` contains the available earned-reset count when the backend provides it; otherwise it is `null`. +- `rateLimitResetCredits.credits` is `null` when only the count is available. An empty array means details were fetched and no available credits were returned. +- The backend may cap `rateLimitResetCredits.credits`, so `availableCount` is the authoritative total and can be greater than the number of detail rows. +- Refetch `account/rateLimits/read` after consuming a reset. ### 8) Earned rate-limit resets (ChatGPT) ```json -{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868" } } +{ "method": "account/rateLimitResetCredit/consume", "id": 8, "params": { "idempotencyKey": "8ae96ff3-3425-4f4c-8772-b6fd61502868", "creditId": "RateLimitResetCredit_1" } } { "id": 8, "result": { "outcome": "reset" } } ``` Field notes: - `idempotencyKey` must be non-empty. A UUID is recommended for each logical redemption attempt; reuse the same value when retrying that attempt. +- `creditId` is optional. When provided, it must be a non-empty opaque ID returned by `account/rateLimits/read`; when omitted, the backend selects the next available credit. - `reset` means a credit was consumed. - `alreadyRedeemed` means the same redemption completed previously. Treat it as an idempotent success and refresh account limits. - `nothingToReset` means there is no eligible rate-limit window to reset. - `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. +- Refetch `account/rateLimits/read` after consuming a reset instead of inferring updated state from this response. ### 9) Workspace messages (ChatGPT) diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 587325c246..f03351468f 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -156,7 +156,10 @@ use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::PluginUninstallResponse; +use codex_app_server_protocol::RateLimitResetCredit; +use codex_app_server_protocol::RateLimitResetCreditStatus; use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitResetType; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery; use codex_app_server_protocol::ReviewStartParams; @@ -298,6 +301,8 @@ 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::RateLimitResetCreditDetails as BackendRateLimitResetCreditDetails; +use codex_backend_client::RateLimitResetCreditsDetails as BackendRateLimitResetCreditsDetails; use codex_backend_client::RequestError as BackendRequestError; use codex_backend_client::TokenUsageProfile; use codex_chatgpt::connectors; 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 4691e22204..9592563bec 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -891,9 +891,11 @@ impl AccountRequestProcessor { 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 response = client - .get_rate_limits_with_reset_credits() - .await + let (response, detailed_rate_limit_reset_credits) = tokio::join!( + client.get_rate_limits_with_reset_credits(), + Self::detailed_rate_limit_reset_credits(&client), + ); + let response = response .map_err(|err| internal_error(format!("failed to fetch codex rate limits: {err}")))?; if response.rate_limits.is_empty() { return Err(internal_error( @@ -920,6 +922,15 @@ impl AccountRequestProcessor { .cloned() .unwrap_or_else(|| response.rate_limits[0].clone()); + let rate_limit_reset_credits = detailed_rate_limit_reset_credits.or_else(|| { + response + .rate_limit_reset_credits + .map(|summary| RateLimitResetCreditsSummary { + available_count: summary.available_count, + credits: None, + }) + }); + Ok(GetAccountRateLimitsResponse { rate_limits: rate_limits.into(), rate_limits_by_limit_id: Some( @@ -928,11 +939,7 @@ impl AccountRequestProcessor { .map(|(limit_id, snapshot)| (limit_id, snapshot.into())) .collect(), ), - rate_limit_reset_credits: response.rate_limit_reset_credits.map(|summary| { - RateLimitResetCreditsSummary { - available_count: summary.available_count, - } - }), + rate_limit_reset_credits, }) } diff --git a/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs b/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs index c8a397d3a7..919d30d770 100644 --- a/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs +++ b/codex-rs/app-server/src/request_processors/account_processor/rate_limit_resets.rs @@ -1,11 +1,47 @@ use super::*; const RATE_LIMIT_RESET_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); +const RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 5); #[cfg(debug_assertions)] const RATE_LIMIT_RESET_REQUEST_TIMEOUT_ENV_VAR: &str = "CODEX_TEST_RATE_LIMIT_RESET_REQUEST_TIMEOUT_MS"; impl AccountRequestProcessor { + pub(super) async fn detailed_rate_limit_reset_credits( + client: &BackendClient, + ) -> Option { + let details = match tokio::time::timeout( + RATE_LIMIT_RESET_DETAILS_REQUEST_TIMEOUT, + client.list_rate_limit_reset_credits(), + ) + .await + { + Ok(Ok(details)) => details, + Ok(Err(err)) => { + tracing::warn!( + "failed to fetch rate limit reset credit details; falling back to the usage response: {err}" + ); + return None; + } + Err(_) => { + tracing::warn!( + "rate limit reset credit detail request timed out; falling back to the usage response" + ); + return None; + } + }; + + match rate_limit_reset_credits_from_backend(details) { + Ok(summary) => Some(summary), + Err(err) => { + tracing::warn!( + "failed to parse rate limit reset credit details; falling back to the usage response: {err}" + ); + None + } + } + } + pub(crate) async fn consume_account_rate_limit_reset_credit( &self, params: ConsumeAccountRateLimitResetCreditParams, @@ -13,6 +49,9 @@ impl AccountRequestProcessor { if params.idempotency_key.is_empty() { return Err(invalid_request("idempotencyKey must not be empty")); } + if params.credit_id.as_deref().is_some_and(str::is_empty) { + return Err(invalid_request("creditId must not be empty")); + } let client = self.rate_limit_reset_backend_client().await?; let request_timeout = RATE_LIMIT_RESET_REQUEST_TIMEOUT; @@ -22,10 +61,20 @@ impl AccountRequestProcessor { .and_then(|value| value.parse::().ok()) .map(Duration::from_millis) .unwrap_or(request_timeout); - let response = tokio::time::timeout( - request_timeout, - client.consume_rate_limit_reset_credit(¶ms.idempotency_key), - ) + let response = tokio::time::timeout(request_timeout, async { + match params.credit_id.as_deref() { + Some(credit_id) => { + client + .consume_rate_limit_reset_credit_by_id(¶ms.idempotency_key, credit_id) + .await + } + None => { + client + .consume_rate_limit_reset_credit(¶ms.idempotency_key) + .await + } + } + }) .await .map_err(|_| internal_error("rate limit reset consume timed out"))? .map_err(|err| internal_error(format!("failed to consume rate limit reset: {err}")))?; @@ -64,3 +113,56 @@ impl AccountRequestProcessor { .map_err(|err| internal_error(format!("failed to construct backend client: {err}"))) } } + +fn rate_limit_reset_credits_from_backend( + details: BackendRateLimitResetCreditsDetails, +) -> Result { + let credits = details + .credits + .into_iter() + .map(rate_limit_reset_credit_from_backend) + .collect::, _>>()?; + Ok(RateLimitResetCreditsSummary { + available_count: details.available_count, + credits: Some(credits), + }) +} + +fn rate_limit_reset_credit_from_backend( + credit: BackendRateLimitResetCreditDetails, +) -> Result { + let reset_type = match credit.reset_type.as_str() { + "codex_rate_limits" => RateLimitResetType::CodexRateLimits, + _ => RateLimitResetType::Unknown, + }; + let status = match credit.status.as_str() { + "available" => RateLimitResetCreditStatus::Available, + "redeeming" => RateLimitResetCreditStatus::Redeeming, + "redeemed" => RateLimitResetCreditStatus::Redeemed, + _ => RateLimitResetCreditStatus::Unknown, + }; + let granted_at = rate_limit_reset_credit_timestamp(&credit.granted_at) + .map_err(|err| format!("invalid granted_at for credit `{}`: {err}", credit.id))?; + let expires_at = credit + .expires_at + .as_deref() + .map(rate_limit_reset_credit_timestamp) + .transpose() + .map_err(|err| format!("invalid expires_at for credit `{}`: {err}", credit.id))?; + + Ok(RateLimitResetCredit { + id: credit.id, + reset_type, + status, + granted_at, + expires_at, + title: credit.title, + description: credit.description, + }) +} + +fn rate_limit_reset_credit_timestamp(timestamp: &str) -> Result { + DateTime::parse_from_rfc3339(timestamp) + .map(|timestamp| timestamp.timestamp()) + .map_err(|err| format!("failed to parse timestamp `{timestamp}`: {err}")) +} 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 86ea09504c..fbb4e6e940 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 @@ -44,6 +44,7 @@ async fn consume_rate_limit_reset_credit_requires_chatgpt_auth() -> Result<()> { .send_consume_account_rate_limit_reset_credit_request( ConsumeAccountRateLimitResetCreditParams { idempotency_key: "request-1".to_string(), + credit_id: None, }, ) .await?; @@ -120,6 +121,44 @@ async fn consume_account_rate_limit_reset_credit_maps_backend_outcomes() -> Resu Ok(()) } +#[tokio::test] +async fn consume_account_rate_limit_reset_credit_forwards_selected_credit_id() -> Result<()> { + let (codex_home, server) = chatgpt_test_context().await?; + Mock::given(method("POST")) + .and(path("/api/codex/rate-limit-reset-credits/consume")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(body_json(json!({ + "redeem_request_id": "request-selected", + "credit_id": "credit-123", + }))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "code": "reset", "windows_reset": 2 })), + ) + .expect(1) + .mount(&server) + .await; + + let mut mcp = initialized_app_server(codex_home.path()).await?; + let request_id = mcp + .send_consume_account_rate_limit_reset_credit_request( + ConsumeAccountRateLimitResetCreditParams { + idempotency_key: "request-selected".to_string(), + credit_id: Some("credit-123".to_string()), + }, + ) + .await?; + + assert_eq!( + read_response::(&mut mcp, request_id).await?, + ConsumeAccountRateLimitResetCreditResponse { + outcome: ConsumeAccountRateLimitResetCreditOutcome::Reset, + } + ); + Ok(()) +} + #[tokio::test] async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() -> Result<()> { let (codex_home, _server) = chatgpt_test_context().await?; @@ -129,6 +168,7 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() .send_consume_account_rate_limit_reset_credit_request( ConsumeAccountRateLimitResetCreditParams { idempotency_key: String::new(), + credit_id: None, }, ) .await?; @@ -139,6 +179,26 @@ async fn consume_account_rate_limit_reset_credit_rejects_empty_idempotency_key() Ok(()) } +#[tokio::test] +async fn consume_account_rate_limit_reset_credit_rejects_empty_credit_id() -> Result<()> { + let (codex_home, _server) = chatgpt_test_context().await?; + let mut mcp = initialized_app_server(codex_home.path()).await?; + + let request_id = mcp + .send_consume_account_rate_limit_reset_credit_request( + ConsumeAccountRateLimitResetCreditParams { + idempotency_key: "request-1".to_string(), + credit_id: Some(String::new()), + }, + ) + .await?; + let error = read_error_response(&mut mcp, request_id).await?; + + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!(error.error.message, "creditId must not be empty"); + Ok(()) +} + #[tokio::test] async fn consume_account_rate_limit_reset_credit_surfaces_backend_failure() -> Result<()> { let (codex_home, server) = chatgpt_test_context().await?; @@ -244,6 +304,7 @@ async fn send_consume_reset_credit(mcp: &mut TestAppServer, idempotency_key: &st mcp.send_consume_account_rate_limit_reset_credit_request( ConsumeAccountRateLimitResetCreditParams { idempotency_key: idempotency_key.to_string(), + credit_id: None, }, ) .await diff --git a/codex-rs/app-server/tests/suite/v2/rate_limits.rs b/codex-rs/app-server/tests/suite/v2/rate_limits.rs index 58c31c453b..423385cea9 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limits.rs @@ -10,7 +10,10 @@ use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RateLimitReachedType; +use codex_app_server_protocol::RateLimitResetCredit; +use codex_app_server_protocol::RateLimitResetCreditStatus; use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitResetType; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::RequestId; @@ -109,6 +112,16 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { let secondary_reset_timestamp = chrono::DateTime::parse_from_rfc3339("2025-01-01T01:00:00Z") .expect("parse secondary reset timestamp") .timestamp(); + let reset_credit_granted_at = chrono::DateTime::parse_from_rfc3339("2026-06-17T00:00:00Z") + .expect("parse reset credit grant timestamp") + .timestamp(); + let reset_credit_expires_at = chrono::DateTime::parse_from_rfc3339("2026-07-17T00:00:00Z") + .expect("parse reset credit expiry timestamp") + .timestamp(); + let second_reset_credit_granted_at = + chrono::DateTime::parse_from_rfc3339("2026-06-18T00:00:00Z") + .expect("parse second reset credit grant timestamp") + .timestamp(); let response_body = json!({ "plan_type": "pro", "rate_limit": { @@ -170,6 +183,35 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { .expect(1) .mount(&server) .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "credits": [ + { + "id": "credit-1", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-17T00:00:00Z", + "expires_at": "2026-07-17T00:00:00Z", + "title": "Full reset (Weekly + 5 hr)", + "description": "Ready to redeem" + }, + { + "id": "credit-2", + "reset_type": "future_reset_type", + "status": "future_status", + "granted_at": "2026-06-18T00:00:00Z", + "expires_at": null + } + ], + "available_count": 2, + "total_earned_count": 4 + }))) + .expect(1) + .mount(&server) + .await; let mut mcp = TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; @@ -260,13 +302,98 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> { .into_iter() .collect(), ), - rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { available_count: 3 }), + rate_limit_reset_credits: Some(RateLimitResetCreditsSummary { + available_count: 2, + credits: Some(vec![ + RateLimitResetCredit { + id: "credit-1".to_string(), + reset_type: RateLimitResetType::CodexRateLimits, + status: RateLimitResetCreditStatus::Available, + granted_at: reset_credit_granted_at, + expires_at: Some(reset_credit_expires_at), + title: Some("Full reset (Weekly + 5 hr)".to_string()), + description: Some("Ready to redeem".to_string()), + }, + RateLimitResetCredit { + id: "credit-2".to_string(), + reset_type: RateLimitResetType::Unknown, + status: RateLimitResetCreditStatus::Unknown, + granted_at: second_reset_credit_granted_at, + expires_at: None, + title: None, + description: None, + }, + ]), + }), }; assert_eq!(received, expected); Ok(()) } +#[tokio::test] +async fn get_account_rate_limits_preserves_count_when_reset_credit_details_fail() -> Result<()> { + let codex_home = TempDir::new()?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let server = MockServer::start().await; + write_chatgpt_base_url(codex_home.path(), &server.uri())?; + + Mock::given(method("GET")) + .and(path("/api/codex/usage")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "plan_type": "pro", + "rate_limit": { + "allowed": true, + "limit_reached": false, + "primary_window": { + "used_percent": 42, + "limit_window_seconds": 3600, + "reset_after_seconds": 120, + "reset_at": 1735689720 + } + }, + "rate_limit_reset_credits": { "available_count": 3 } + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/codex/rate-limit-reset-credits")) + .respond_with(ResponseTemplate::new(500).set_body_string("boom")) + .expect(1) + .mount(&server) + .await; + + 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_rate_limits_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountRateLimitsResponse = to_response(response)?; + + assert_eq!( + received.rate_limit_reset_credits, + Some(RateLimitResetCreditsSummary { + available_count: 3, + credits: None, + }) + ); + + Ok(()) +} + #[tokio::test] async fn send_add_credits_nudge_email_requires_auth() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/backend-client/src/client/rate_limit_resets.rs b/codex-rs/backend-client/src/client/rate_limit_resets.rs index b22a4ec91a..bed56ba648 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets.rs @@ -3,6 +3,7 @@ use super::Client; use super::PathStyle; use crate::types::ConsumeRateLimitResetCreditResponse; +use crate::types::RateLimitResetCreditsDetails; use crate::types::RateLimitStatusWithResetCredits; use crate::types::RateLimitsWithResetCredits; use anyhow::Result; @@ -13,6 +14,8 @@ use serde::Serialize; #[derive(Serialize)] struct ConsumeRateLimitResetCreditRequest<'a> { redeem_request_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + credit_id: Option<&'a str>, } impl Client { @@ -31,9 +34,34 @@ impl Client { self.decode_json(&url, &ct, &body) } + pub async fn list_rate_limit_reset_credits(&self) -> Result { + let url = self.rate_limit_reset_credits_url(); + let req = self.http.get(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "GET", &url).await?; + self.decode_json(&url, &ct, &body) + } + pub async fn consume_rate_limit_reset_credit( &self, redeem_request_id: &str, + ) -> Result { + self.consume_rate_limit_reset_credit_request(redeem_request_id, /*credit_id*/ None) + .await + } + + pub async fn consume_rate_limit_reset_credit_by_id( + &self, + redeem_request_id: &str, + credit_id: &str, + ) -> Result { + self.consume_rate_limit_reset_credit_request(redeem_request_id, Some(credit_id)) + .await + } + + async fn consume_rate_limit_reset_credit_request( + &self, + redeem_request_id: &str, + credit_id: Option<&str>, ) -> Result { let url = self.consume_rate_limit_reset_credit_url(); let req = self @@ -41,7 +69,10 @@ impl Client { .post(&url) .headers(self.headers()) .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .json(&ConsumeRateLimitResetCreditRequest { redeem_request_id }); + .json(&ConsumeRateLimitResetCreditRequest { + redeem_request_id, + credit_id, + }); let (body, ct) = self.exec_request(req, "POST", &url).await?; self.decode_json(&url, &ct, &body) } @@ -53,6 +84,17 @@ impl Client { } } + fn rate_limit_reset_credits_url(&self) -> String { + match self.path_style { + PathStyle::CodexApi => { + format!("{}/api/codex/rate-limit-reset-credits", self.base_url) + } + PathStyle::ChatGptApi => { + format!("{}/wham/rate-limit-reset-credits", self.base_url) + } + } + } + fn consume_rate_limit_reset_credit_url(&self) -> String { match self.path_style { PathStyle::CodexApi => { diff --git a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs index 89313dbe5d..f706be5da9 100644 --- a/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs +++ b/codex-rs/backend-client/src/client/rate_limit_resets_tests.rs @@ -1,5 +1,7 @@ use super::*; use crate::types::ConsumeRateLimitResetCreditCode; +use crate::types::RateLimitResetCreditDetails; +use crate::types::RateLimitResetCreditsDetails; use crate::types::RateLimitResetCreditsSummary; use pretty_assertions::assert_eq; @@ -9,6 +11,10 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { test_client("https://example.test", PathStyle::CodexApi).rate_limit_status_url(), "https://example.test/api/codex/usage" ); + assert_eq!( + test_client("https://example.test", PathStyle::CodexApi).rate_limit_reset_credits_url(), + "https://example.test/api/codex/rate-limit-reset-credits" + ); assert_eq!( test_client("https://example.test", PathStyle::CodexApi) .consume_rate_limit_reset_credit_url(), @@ -19,6 +25,11 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { .rate_limit_status_url(), "https://chatgpt.com/backend-api/wham/usage" ); + assert_eq!( + test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi) + .rate_limit_reset_credits_url(), + "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits" + ); assert_eq!( test_client("https://chatgpt.com/backend-api", PathStyle::ChatGptApi) .consume_rate_limit_reset_credit_url(), @@ -28,10 +39,22 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { assert_eq!( serde_json::to_value(ConsumeRateLimitResetCreditRequest { redeem_request_id: "redeem-123", + credit_id: None, }) .unwrap(), serde_json::json!({ "redeem_request_id": "redeem-123" }) ); + assert_eq!( + serde_json::to_value(ConsumeRateLimitResetCreditRequest { + redeem_request_id: "redeem-456", + credit_id: Some("credit-123"), + }) + .unwrap(), + serde_json::json!({ + "redeem_request_id": "redeem-456", + "credit_id": "credit-123", + }) + ); let status: RateLimitStatusWithResetCredits = serde_json::from_value(serde_json::json!({ "plan_type": "plus", @@ -43,6 +66,60 @@ fn rate_limit_reset_contract_uses_expected_paths_and_payloads() { Some(RateLimitResetCreditsSummary { available_count: 3 }) ); + let details: RateLimitResetCreditsDetails = serde_json::from_value(serde_json::json!({ + "credits": [ + { + "id": "credit-1", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-17T00:00:00Z", + "expires_at": "2026-07-17T00:00:00Z", + "redeem_started_at": null, + "redeemed_at": null, + "profile_image_url": "https://example.test/avatar.png", + "profile_user_id": "@friend", + "title": "Full reset (Weekly + 5 hr)", + "description": "Ready to redeem" + }, + { + "id": "credit-2", + "reset_type": "codex_rate_limits", + "status": "available", + "granted_at": "2026-06-18T00:00:00Z", + "expires_at": null + } + ], + "available_count": 2, + "total_earned_count": 4 + })) + .unwrap(); + assert_eq!( + details, + RateLimitResetCreditsDetails { + credits: vec![ + RateLimitResetCreditDetails { + id: "credit-1".to_string(), + reset_type: "codex_rate_limits".to_string(), + status: "available".to_string(), + granted_at: "2026-06-17T00:00:00Z".to_string(), + expires_at: Some("2026-07-17T00:00:00Z".to_string()), + title: Some("Full reset (Weekly + 5 hr)".to_string()), + description: Some("Ready to redeem".to_string()), + }, + RateLimitResetCreditDetails { + id: "credit-2".to_string(), + reset_type: "codex_rate_limits".to_string(), + status: "available".to_string(), + granted_at: "2026-06-18T00:00:00Z".to_string(), + expires_at: None, + title: None, + description: None, + }, + ], + available_count: 2, + } + ); + let response: ConsumeRateLimitResetCreditResponse = serde_json::from_value(serde_json::json!({ "code": "reset", "credit": { "id": "ignored-by-cli" }, diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 6cbeaf30b2..06f2cd442a 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -18,6 +18,8 @@ pub use types::DeliveredConfigToml; pub use types::DeliveredRequirementsToml; pub use types::DeliveredTomlFragment; pub use types::PaginatedListTaskListItem; +pub use types::RateLimitResetCreditDetails; +pub use types::RateLimitResetCreditsDetails; pub use types::RateLimitResetCreditsSummary; pub use types::RateLimitsWithResetCredits; pub use types::TaskListItem; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 18ab323986..7fb4346a92 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -23,6 +23,23 @@ pub struct RateLimitResetCreditsSummary { pub available_count: i64, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct RateLimitResetCreditsDetails { + pub credits: Vec, + pub available_count: i64, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +pub struct RateLimitResetCreditDetails { + pub id: String, + pub reset_type: String, + pub status: String, + pub granted_at: String, + pub expires_at: Option, + pub title: Option, + pub description: Option, +} + #[derive(Clone, Debug, PartialEq)] pub struct RateLimitsWithResetCredits { pub rate_limits: Vec, diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 9391f4b3e3..9162b2c868 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -798,7 +798,10 @@ pub(super) async fn consume_rate_limit_reset_credit_request( request_handle .request_typed(ClientRequest::ConsumeAccountRateLimitResetCredit { request_id, - params: ConsumeAccountRateLimitResetCreditParams { idempotency_key }, + params: ConsumeAccountRateLimitResetCreditParams { + idempotency_key, + credit_id: None, + }, }) .await .wrap_err("account/rateLimitResetCredit/consume failed in TUI") diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index c05b4a62ff..22908b1112 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -14,7 +14,10 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -35,7 +38,10 @@ async fn usage_command_disables_reset_after_cached_zero_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -63,7 +69,10 @@ async fn usage_menu_refresh_enables_newly_available_reset() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -76,7 +85,10 @@ async fn usage_menu_refresh_enables_newly_available_reset() { chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None, + }), ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -92,7 +104,10 @@ async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -122,7 +137,10 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); assert_matches!( @@ -139,7 +157,10 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, vec![snapshot(/*percent*/ 92.0)], - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None, + }), ); assert_eq!(chat.available_rate_limit_reset_credits, None); @@ -185,7 +206,10 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); chat.dispatch_command(SlashCommand::Usage); @@ -206,7 +230,10 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); record_popup(&chat, &mut states); @@ -215,7 +242,10 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 0 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); record_popup(&chat, &mut states); @@ -270,7 +300,10 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); record_popup(&chat, &mut states); @@ -288,7 +321,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( free_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); states.push(format!( "Free:\n{}", @@ -301,7 +337,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( go_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); @@ -317,7 +356,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( business_request_id, vec![monthly_business_snapshot], - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); states.push(format!( "Business with monthly window:\n{}", @@ -337,7 +379,10 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -353,7 +398,10 @@ async fn rate_limit_reset_confirmation_can_use_reset() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); @@ -393,7 +441,10 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 1 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(!finish_reset_consume_outcome( @@ -438,7 +489,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); dismiss_popup(&mut chat); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -469,7 +523,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); assert!( render_bottom_popup(&chat, /*width*/ 80) @@ -485,7 +542,10 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(finish_reset_consume_outcome( @@ -522,7 +582,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); } @@ -536,7 +599,10 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); chat.clear_pending_rate_limit_reset_hint(); @@ -559,7 +625,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -588,7 +657,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 1, + credits: None + }), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -631,7 +703,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); let rendered = lines_to_single_string( &chat @@ -654,7 +729,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.usage_history_insertion_blocked()); @@ -685,7 +763,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -698,7 +779,10 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -716,7 +800,10 @@ 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 }), + Ok(RateLimitResetCreditsSummary { + available_count: 0, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -731,7 +818,10 @@ async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { available_count: 2 }), + Ok(RateLimitResetCreditsSummary { + available_count: 2, + credits: None + }), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); From aa94ea139744f0a9dd76343805cc96649d9f2658 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Mon, 6 Jul 2026 13:23:01 -0700 Subject: [PATCH 14/76] chore(approvals) consolidate guardian calls for shell tools (#31267) ## Summary As part of our effort to start simplifying approvals code, this PR extracts Guardian approvals logic from shell tool calls, and replaces it with the ApprovalAction abstraction instead. This way, tools don't need to know about Guardian at all. ## Testing - [x] Adds integration test --- codex-rs/core/src/session/tests.rs | 16 +++ codex-rs/core/src/tools/orchestrator.rs | 22 +++- .../core/src/tools/runtimes/apply_patch.rs | 30 ++--- .../src/tools/runtimes/apply_patch_tests.rs | 2 +- codex-rs/core/src/tools/runtimes/shell.rs | 42 +++---- .../core/src/tools/runtimes/unified_exec.rs | 44 ++++--- codex-rs/core/src/tools/sandboxing.rs | 4 + codex-rs/core/tests/suite/guardian_review.rs | 116 ++++++++++++++++++ 8 files changed, 210 insertions(+), 66 deletions(-) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 582a58f2ab..6cc223cd7d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1039,6 +1039,22 @@ async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> an ) -> futures::future::BoxFuture<'a, ReviewDecision> { Box::pin(async { ReviewDecision::Approved }) } + + fn approval_action( + &self, + _req: &(), + ctx: &crate::tools::sandboxing::ApprovalCtx<'_>, + ) -> std::io::Result { + Ok(crate::tools::sandboxing::ApprovalAction::Shell { + id: ctx.call_id.to_string(), + command: Vec::new(), + #[allow(deprecated)] + cwd: ctx.turn.cwd.clone(), + sandbox_permissions: crate::sandboxing::SandboxPermissions::UseDefault, + additional_permissions: None, + justification: None, + }) + } } impl crate::tools::sandboxing::Sandboxable for ProbeToolRuntime { diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index aaa0dfe9d1..da3e4acddd 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -9,6 +9,7 @@ caching). use crate::guardian::guardian_rejection_message; use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; +use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::hook_runtime::run_permission_request_hooks; use crate::network_policy_decision::network_approval_context_from_payload; @@ -563,7 +564,26 @@ impl ToolOrchestrator { } else { ToolDecisionSource::User }; - let decision = tool.start_approval_async(req, approval_ctx).await; + let decision = if let Some(review_id) = approval_ctx.guardian_review_id.clone() { + match tool.approval_action(req, &approval_ctx) { + Ok(action) => { + review_approval_request( + approval_ctx.session, + approval_ctx.turn, + review_id, + action, + approval_ctx.retry_reason.clone(), + ) + .await + } + Err(err) => { + tracing::error!(%err, "failed to build guardian approval action"); + ReviewDecision::Abort + } + } + } else { + tool.start_approval_async(req, approval_ctx).await + }; let tool_name = flat_tool_name(&tool_ctx.tool_name); otel.tool_decision( tool_name.as_ref(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 840a0dd01e..b839b05c44 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -4,11 +4,10 @@ //! selected turn environment filesystem for both local and remote turns, with //! sandboxing enforced by the explicit filesystem sandbox context. use crate::exec::is_likely_sandbox_denied; -use crate::guardian::GuardianApprovalRequest; -use crate::guardian::review_approval_request; use crate::session::turn_context::TurnEnvironment; use crate::tools::hook_names::HookToolName; use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::PermissionRequestPayload; @@ -77,7 +76,7 @@ impl ApplyPatchRuntime { fn build_guardian_review_request( req: &ApplyPatchRequest, call_id: &str, - ) -> std::io::Result { + ) -> std::io::Result { // TODO(anp): Remove this conversion once the guardian API supports PathUri. let cwd = req.action.cwd.to_abs_path()?; let files = req @@ -85,7 +84,7 @@ impl ApplyPatchRuntime { .iter() .map(PathUri::to_abs_path) .collect::>>()?; - Ok(GuardianApprovalRequest::ApplyPatch { + Ok(ApprovalAction::ApplyPatch { id: call_id.to_string(), cwd, files, @@ -152,22 +151,7 @@ impl Approvable for ApplyPatchRuntime { let retry_reason = ctx.retry_reason.clone(); let approval_keys = self.approval_keys(req); let changes = req.changes.clone(); - let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - if let Some(review_id) = guardian_review_id { - let action = match ApplyPatchRuntime::build_guardian_review_request( - req, - ctx.call_id, - ) { - Ok(action) => action, - Err(err) => { - tracing::error!(cwd = %req.action.cwd, %err, "guardian apply_patch cwd is not host-native"); - return ReviewDecision::Abort; - } - }; - return review_approval_request(session, turn, review_id, action, retry_reason) - .await; - } if req.permissions_preapproved && retry_reason.is_none() { return ReviewDecision::Approved; } @@ -201,6 +185,14 @@ impl Approvable for ApplyPatchRuntime { }) } + fn approval_action( + &self, + req: &ApplyPatchRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + ApplyPatchRuntime::build_guardian_review_request(req, ctx.call_id) + } + fn wants_no_sandbox_approval(&self, policy: AskForApproval) -> bool { match policy { AskForApproval::Never => false, 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 f1c6f43aa3..a37cd45c13 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -80,7 +80,7 @@ async fn guardian_review_request_includes_patch_context() { assert_eq!( guardian_request, - GuardianApprovalRequest::ApplyPatch { + ApprovalAction::ApplyPatch { id: "call-1".to_string(), cwd: expected_cwd, files: vec![path], diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 0d386103db..3ff50f3f51 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -10,9 +10,7 @@ pub(crate) mod zsh_fork_backend; use crate::command_canonicalization::canonicalize_command_for_approval; use crate::exec::ExecCapturePolicy; -use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianNetworkAccessTrigger; -use crate::guardian::review_approval_request; use crate::sandboxing::ExecOptions; use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; @@ -29,6 +27,7 @@ use crate::tools::runtimes::disable_powershell_profile_for_elevated_windows_sand use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::PermissionRequestPayload; @@ -145,30 +144,14 @@ impl Approvable for ShellRuntime { let command = req.command.clone(); let cwd = req.cwd.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); - let retry_reason = ctx.retry_reason.clone(); - let reason = retry_reason.clone().or_else(|| req.justification.clone()); + let reason = ctx + .retry_reason + .clone() + .or_else(|| req.justification.clone()); let session = ctx.session; let turn = ctx.turn; let call_id = ctx.call_id.to_string(); - let guardian_review_id = ctx.guardian_review_id.clone(); Box::pin(async move { - if let Some(review_id) = guardian_review_id { - return review_approval_request( - session, - turn, - review_id, - GuardianApprovalRequest::Shell { - id: call_id, - command, - cwd: cwd.clone(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), - justification: req.justification.clone(), - }, - retry_reason, - ) - .await; - } with_cached_approval(&session.services, "shell", keys, move || async move { let available_decisions = None; session @@ -193,6 +176,21 @@ impl Approvable for ShellRuntime { }) } + fn approval_action( + &self, + req: &ShellRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + Ok(ApprovalAction::Shell { + id: ctx.call_id.to_string(), + command: req.command.clone(), + cwd: req.cwd.clone(), + sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), + justification: req.justification.clone(), + }) + } + fn exec_approval_requirement(&self, req: &ShellRequest) -> Option { Some(req.exec_approval_requirement.clone()) } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5df42cc979..5bb30645e3 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -7,9 +7,7 @@ the process manager to spawn PTYs once an ExecRequest is prepared. use crate::command_canonicalization::canonicalize_command_for_approval; use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; -use crate::guardian::GuardianApprovalRequest; use crate::guardian::GuardianNetworkAccessTrigger; -use crate::guardian::review_approval_request; use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecServerEnvConfig; use crate::sandboxing::SandboxPermissions; @@ -26,6 +24,7 @@ use crate::tools::runtimes::exec_env_for_sandbox_permissions; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::shell::zsh_fork_backend; use crate::tools::sandboxing::Approvable; +use crate::tools::sandboxing::ApprovalAction; use crate::tools::sandboxing::ApprovalCtx; use crate::tools::sandboxing::ExecApprovalRequirement; use crate::tools::sandboxing::PermissionRequestPayload; @@ -179,9 +178,10 @@ impl Approvable for UnifiedExecRuntime<'_> { let call_id = ctx.call_id.to_string(); let command = req.command.clone(); let environment_id = Some(req.turn_environment.environment_id.clone()); - let retry_reason = ctx.retry_reason.clone(); - let reason = retry_reason.clone().or_else(|| req.justification.clone()); - let guardian_review_id = ctx.guardian_review_id.clone(); + let reason = ctx + .retry_reason + .clone() + .or_else(|| req.justification.clone()); Box::pin(async move { let native_cwd = match req.cwd.to_abs_path() { Ok(c) => c, @@ -192,24 +192,6 @@ impl Approvable for UnifiedExecRuntime<'_> { return ReviewDecision::Abort; } }; - if let Some(review_id) = guardian_review_id { - return review_approval_request( - session, - turn, - review_id, - GuardianApprovalRequest::ExecCommand { - id: call_id, - command, - cwd: native_cwd.clone(), - sandbox_permissions: req.sandbox_permissions, - additional_permissions: req.additional_permissions.clone(), - justification: req.justification.clone(), - tty: req.tty, - }, - retry_reason, - ) - .await; - } with_cached_approval(&session.services, "unified_exec", keys, || async move { let available_decisions = None; session @@ -234,6 +216,22 @@ impl Approvable for UnifiedExecRuntime<'_> { }) } + fn approval_action( + &self, + req: &UnifiedExecRequest, + ctx: &ApprovalCtx<'_>, + ) -> std::io::Result { + Ok(ApprovalAction::ExecCommand { + id: ctx.call_id.to_string(), + command: req.command.clone(), + cwd: req.cwd.to_abs_path()?, + sandbox_permissions: req.sandbox_permissions, + additional_permissions: req.additional_permissions.clone(), + justification: req.justification.clone(), + tty: req.tty, + }) + } + fn exec_approval_requirement( &self, req: &UnifiedExecRequest, diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 8e9e274f24..4c351eeb94 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -133,6 +133,8 @@ pub(crate) struct ApprovalCtx<'a> { pub network_approval_context: Option, } +pub(crate) type ApprovalAction = crate::guardian::GuardianApprovalRequest; + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct PermissionRequestPayload { pub tool_name: HookToolName, @@ -368,6 +370,8 @@ pub(crate) trait Approvable { req: &'a Req, ctx: ApprovalCtx<'a>, ) -> BoxFuture<'a, ReviewDecision>; + + fn approval_action(&self, req: &Req, ctx: &ApprovalCtx<'_>) -> std::io::Result; } pub(crate) trait Sandboxable { diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index c1da262033..383954b816 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -20,6 +20,7 @@ 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::skip_if_wine_exec; use core_test_support::test_codex::local_selections; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; @@ -110,6 +111,121 @@ async fn guardian_session_prewarms_and_is_reused_for_first_review() -> Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn guardian_denial_rejects_tool_call_with_rationale() -> Result<()> { + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + skip_if_wine_exec!( + Ok(()), + "Guardian approval actions require host-native paths" + ); + + let server = start_mock_server().await; + let approval_policy = AskForApproval::OnRequest; + let sandbox_policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let sandbox_policy_for_config = sandbox_policy.clone(); + + let mut builder = test_codex().with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(approval_policy); + config + .set_legacy_sandbox_policy(sandbox_policy_for_config) + .expect("set sandbox policy"); + }); + let test = builder.build_with_auto_env(&server).await?; + + let output_file = test.cwd.path().join("guardian-denied.txt"); + let command = format!("printf should-not-run > {}", output_file.display()); + let tool_args = json!({ + "cmd": command, + "yield_time_ms": 1_000_u64, + "sandbox_permissions": SandboxPermissions::RequireEscalated, + "justification": "Exercise Guardian denial routing.", + }); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-parent-tool-denied"), + ev_function_call( + "exec-call-denied", + "exec_command", + &serde_json::to_string(&tool_args)?, + ), + ev_completed("resp-parent-tool-denied"), + ]), + sse(vec![ + ev_response_created("resp-guardian-denied"), + ev_assistant_message( + "msg-guardian-denied", + &json!({ + "risk_level": "high", + "user_authorization": "low", + "outcome": "deny", + "rationale": "The requested write has unacceptable test risk.", + }) + .to_string(), + ), + ev_completed("resp-guardian-denied"), + ]), + sse(vec![ + ev_response_created("resp-parent-after-denial"), + ev_assistant_message("msg-parent-after-denial", "denied"), + ev_completed("resp-parent-after-denial"), + ]), + ], + ) + .await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "run a command that Guardian should deny".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { + approval_policy: Some(approval_policy), + approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + sandbox_policy: Some(sandbox_policy), + ..Default::default() + }, + }) + .await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + let requests = responses.requests(); + let guardian_request = requests + .iter() + .find(|request| request.body_contains_text("Exercise Guardian denial routing.")) + .expect("expected Guardian review request"); + assert!(guardian_request.body_contains_text(&command)); + + let tool_output = requests + .iter() + .find_map(|request| request.function_call_output_text("exec-call-denied")) + .expect("expected rejected tool output to be returned to the parent model"); + assert!( + tool_output.contains("The requested write has unacceptable test risk."), + "Guardian rationale missing from rejected tool output: {tool_output}" + ); + assert!( + !output_file.exists(), + "Guardian-denied command unexpectedly executed" + ); + + 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 02868b9019234d9b6fb87cb9c088242387751649 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Mon, 6 Jul 2026 16:33:13 -0400 Subject: [PATCH 15/76] [tui] Truncate hook context in conversation history (#31252) --- ...ext_is_truncated_with_transcript_hint.snap | 10 + .../src/chatwidget/tests/status_and_layout.rs | 35 +++ codex-rs/tui/src/exec_cell/render.rs | 2 +- codex-rs/tui/src/history_cell/hook_cell.rs | 210 ++++++++++++++++-- codex-rs/tui/src/ui_consts.rs | 1 + 5 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__long_hook_context_is_truncated_with_transcript_hint.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__long_hook_context_is_truncated_with_transcript_hint.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__long_hook_context_is_truncated_with_transcript_hint.snap new file mode 100644 index 0000000000..4b88bc9e1b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__long_hook_context_is_truncated_with_transcript_hint.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: history +--- +• SessionStart hook (stopped) + hook context: This hook context is intentionally long enough to wrap across + several terminal rows while keeping the complete value available in the + … +2 lines (ctrl + t to view transcript) + stop: The hook stopped this turn for an important reason. + This second line must remain visible in full. 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 156348d42c..d118191390 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -3888,6 +3888,41 @@ async fn hook_completed_before_reveal_renders_completed_without_running_flash() ); } +#[tokio::test] +async fn long_hook_context_is_truncated_with_transcript_hint_snapshot() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + handle_hook_completed( + &mut chat, + hook_completed_run( + "session-start:0:/tmp/hooks.json", + codex_app_server_protocol::HookEventName::SessionStart, + codex_app_server_protocol::HookRunStatus::Stopped, + vec![ + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Context, + text: "This hook context is intentionally long enough to wrap across several terminal rows while keeping the complete value available in the transcript overlay. The main conversation should stay compact even when a hook injects a large block of instructions for the model." + .to_string(), + }, + codex_app_server_protocol::HookOutputEntry { + kind: codex_app_server_protocol::HookOutputEntryKind::Stop, + text: "The hook stopped this turn for an important reason.\nThis second line must remain visible in full." + .to_string(), + }, + ], + ), + ); + + let history = drain_insert_history(&mut rx) + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::(); + assert_chatwidget_snapshot!( + "long_hook_context_is_truncated_with_transcript_hint", + history + ); +} + #[tokio::test] async fn session_start_hook_events_render_snapshot() { assert_hook_events_snapshot( diff --git a/codex-rs/tui/src/exec_cell/render.rs b/codex-rs/tui/src/exec_cell/render.rs index 882683ad28..bf17277b91 100644 --- a/codex-rs/tui/src/exec_cell/render.rs +++ b/codex-rs/tui/src/exec_cell/render.rs @@ -12,6 +12,7 @@ use crate::motion::activity_indicator; use crate::render::highlight::highlight_bash_to_lines; use crate::render::line_utils::prefix_lines; use crate::render::line_utils::push_owned_lines; +use crate::ui_consts::TRANSCRIPT_HINT; use crate::wrapping::RtOptions; use crate::wrapping::adaptive_wrap_line; use crate::wrapping::adaptive_wrap_lines; @@ -32,7 +33,6 @@ use unicode_width::UnicodeWidthStr; pub(crate) const TOOL_CALL_MAX_LINES: usize = 5; const USER_SHELL_TOOL_CALL_MAX_LINES: usize = 50; const MAX_INTERACTION_PREVIEW_CHARS: usize = 80; -const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript"; pub(crate) struct OutputLinesParams { pub(crate) line_limit: usize, diff --git a/codex-rs/tui/src/history_cell/hook_cell.rs b/codex-rs/tui/src/history_cell/hook_cell.rs index c3214aeb16..51191ea103 100644 --- a/codex-rs/tui/src/history_cell/hook_cell.rs +++ b/codex-rs/tui/src/history_cell/hook_cell.rs @@ -12,11 +12,16 @@ //! 4. Completed runs only persist when they have output or a non-success status. use super::HistoryCell; use super::plain_lines; +use crate::line_truncation::truncate_line_with_ellipsis_if_overflow; use crate::motion::MotionMode; use crate::motion::ReducedMotionIndicator; use crate::motion::activity_indicator; use crate::motion::shimmer_text; +use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; +use crate::ui_consts::TRANSCRIPT_HINT; +use crate::wrapping::RtOptions; +use crate::wrapping::word_wrap_line; use codex_app_server_protocol::HookEventName; use codex_app_server_protocol::HookOutputEntry; use codex_app_server_protocol::HookOutputEntryKind; @@ -50,6 +55,7 @@ const QUIET_HOOK_MIN_VISIBLE: Duration = Duration::from_millis(600); const HOOK_OUTPUT_INDENT: &str = " "; const HOOK_OUTPUT_BODY_INDENT: &str = " "; +const HOOK_CONTEXT_MAX_DISPLAY_ROWS: usize = 3; #[derive(Debug)] struct HookRunCell { @@ -294,11 +300,9 @@ impl HookCell { run.reveal_running_after_delayed_redraw_for_test(now); } } -} -impl HistoryCell for HookCell { - /// Builds viewport lines while coalescing adjacent visible-running hooks. - fn display_lines(&self, _width: u16) -> Vec> { + /// Builds hook lines for either the bounded main viewport or the full transcript overlay. + fn output_lines(&self, width: u16, render_full_context: bool) -> Vec> { let mut lines = Vec::new(); let mut running_group: Option = None; for run in &self.runs { @@ -313,7 +317,12 @@ impl HistoryCell for HookCell { push_running_hook_group(&mut lines, &group, self.animations_enabled); } push_hook_line_separator(&mut lines); - run.push_display_lines(&mut lines, self.animations_enabled); + run.push_display_lines( + &mut lines, + self.animations_enabled, + width, + render_full_context, + ); continue; }; @@ -338,14 +347,21 @@ impl HistoryCell for HookCell { } lines } +} - /// Hook transcript output matches viewport output. +impl HistoryCell for HookCell { + /// Builds viewport lines while coalescing adjacent visible-running hooks. + fn display_lines(&self, width: u16) -> Vec> { + self.output_lines(width, /*render_full_context*/ false) + } + + /// The transcript overlay preserves complete hook context hidden by the viewport preview. fn transcript_lines(&self, width: u16) -> Vec> { - self.display_lines(width) + self.output_lines(width, /*render_full_context*/ true) } fn raw_lines(&self) -> Vec> { - plain_lines(self.display_lines(u16::MAX)) + plain_lines(self.output_lines(u16::MAX, /*render_full_context*/ true)) } /// Produces a coarse cache key for transcript overlays while hook animations are active. @@ -420,7 +436,13 @@ impl HookRunCell { } /// Appends the lines for a single, ungrouped hook run. - fn push_display_lines(&self, lines: &mut Vec>, animations_enabled: bool) { + fn push_display_lines( + &self, + lines: &mut Vec>, + animations_enabled: bool, + width: u16, + render_full_context: bool, + ) { let label = hook_event_label(self.event_name); match &self.state { HookRunState::VisibleRunning { start_time, .. } @@ -446,17 +468,10 @@ impl HookRunCell { .into(), ); for entry in entries { - let prefix = hook_output_prefix(entry.kind); - let mut output_lines = entry.text.split('\n'); - if let Some(first_line) = output_lines.next() { - lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into()); - } - for line in output_lines { - if line.is_empty() { - lines.push("".into()); - } else { - lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into()); - } + if !render_full_context && entry.kind == HookOutputEntryKind::Context { + lines.extend(hook_context_preview_lines(&entry.text, width)); + } else { + push_full_hook_output_entry(lines, entry); } } } @@ -465,6 +480,80 @@ impl HookRunCell { } } +fn push_full_hook_output_entry(lines: &mut Vec>, entry: &HookOutputEntry) { + let prefix = hook_output_prefix(entry.kind); + let mut output_lines = entry.text.split('\n'); + if let Some(first_line) = output_lines.next() { + lines.push(format!("{HOOK_OUTPUT_INDENT}{prefix}{first_line}").into()); + } + for line in output_lines { + if line.is_empty() { + lines.push("".into()); + } else { + lines.push(format!("{HOOK_OUTPUT_BODY_INDENT}{line}").into()); + } + } +} + +fn hook_context_preview_lines(text: &str, width: u16) -> Vec> { + let width = usize::from(width.max(1)); + let mut wrapped = Vec::new(); + let mut source_lines = text.split('\n'); + let first_line = source_lines.next().unwrap_or_default(); + push_wrapped_hook_context_line( + &mut wrapped, + first_line, + width, + Line::from(format!( + "{HOOK_OUTPUT_INDENT}{}", + hook_output_prefix(HookOutputEntryKind::Context) + )), + ); + for line in source_lines { + if line.is_empty() { + wrapped.push("".into()); + } else { + push_wrapped_hook_context_line( + &mut wrapped, + line, + width, + Line::from(HOOK_OUTPUT_BODY_INDENT), + ); + } + } + + if wrapped.len() <= HOOK_CONTEXT_MAX_DISPLAY_ROWS { + return wrapped; + } + + let retained_rows = HOOK_CONTEXT_MAX_DISPLAY_ROWS - 1; + let omitted_rows = wrapped.len() - retained_rows; + wrapped.truncate(retained_rows); + let hint = vec![ + HOOK_OUTPUT_BODY_INDENT.into(), + format!("… +{omitted_rows} lines ({TRANSCRIPT_HINT})").dim(), + ] + .into(); + wrapped.push(truncate_line_with_ellipsis_if_overflow(hint, width)); + wrapped +} + +fn push_wrapped_hook_context_line( + output: &mut Vec>, + text: &str, + width: usize, + initial_indent: Line<'static>, +) { + let line = Line::from(text.to_string()); + let wrapped = word_wrap_line( + &line, + RtOptions::new(width) + .initial_indent(initial_indent) + .subsequent_indent(Line::from(HOOK_OUTPUT_BODY_INDENT)), + ); + push_owned_lines(&wrapped, output); +} + impl HookRunState { /// Creates the hidden initial state for a live hook run. fn pending(start_time: Instant) -> Self { @@ -760,14 +849,13 @@ mod tests { } #[test] - fn completed_hook_multiline_context_preserves_display_and_raw_lines() { + fn completed_hook_short_multiline_context_preserves_display_transcript_and_raw_lines() { let cell = completed_hook_cell( HookEventName::SessionStart, HookRunStatus::Completed, vec![HookOutputEntry { kind: HookOutputEntryKind::Context, - text: "## Working Memory Recall\n\nSource: Codex compaction\nScope: Durable workspace memory" - .to_string(), + text: "## Working Memory Recall\n\nSource: Codex compaction".to_string(), }], ); let expected = vec![ @@ -775,13 +863,87 @@ mod tests { " hook context: ## Working Memory Recall".to_string(), "".to_string(), " Source: Codex compaction".to_string(), - " Scope: Durable workspace memory".to_string(), ]; assert_eq!(line_texts(&cell.display_lines(/*width*/ 80)), expected); + assert_eq!(line_texts(&cell.transcript_lines(/*width*/ 80)), expected); assert_eq!(line_texts(&cell.raw_lines()), expected); } + #[test] + fn completed_hook_long_single_line_context_is_truncated_only_in_display() { + let full_context = format!( + "{}tail-marker", + "context words that should wrap across the terminal width ".repeat(8) + ); + let cell = completed_hook_cell( + HookEventName::SessionStart, + HookRunStatus::Completed, + vec![HookOutputEntry { + kind: HookOutputEntryKind::Context, + text: full_context.clone(), + }], + ); + + let display_lines = cell.display_lines(/*width*/ 80); + let display = line_texts(&display_lines); + assert_eq!(display.len(), 4); + assert_eq!( + Paragraph::new(Text::from(display_lines[1..].to_vec())) + .wrap(Wrap { trim: false }) + .line_count(/*width*/ 80), + HOOK_CONTEXT_MAX_DISPLAY_ROWS + ); + assert!( + display + .iter() + .any(|line| line.contains("ctrl + t to view transcript")), + "expected truncated context to advertise the transcript: {display:?}" + ); + assert!(display.iter().all(|line| !line.contains("tail-marker"))); + + let expected_full = vec![ + "• SessionStart hook (completed)".to_string(), + format!(" hook context: {full_context}"), + ]; + assert_eq!( + line_texts(&cell.transcript_lines(/*width*/ 80)), + expected_full + ); + assert_eq!(line_texts(&cell.raw_lines()), expected_full); + } + + #[test] + fn completed_hook_non_context_entries_are_not_truncated() { + for kind in [ + HookOutputEntryKind::Warning, + HookOutputEntryKind::Stop, + HookOutputEntryKind::Feedback, + HookOutputEntryKind::Error, + ] { + let cell = completed_hook_cell( + HookEventName::UserPromptSubmit, + HookRunStatus::Stopped, + vec![HookOutputEntry { + kind, + text: "first\nsecond\nthird\nfourth\nfifth".to_string(), + }], + ); + + let display = line_texts(&cell.display_lines(/*width*/ 20)); + assert!( + display.iter().any(|line| line == " fifth"), + "expected {kind:?} output to remain complete: {display:?}" + ); + assert!( + display + .iter() + .all(|line| !line.contains("ctrl + t to view transcript")), + "did not expect a transcript hint for {kind:?}: {display:?}" + ); + } + } + #[test] fn completed_hook_multiline_warning_prefixes_first_line_only() { let cell = completed_hook_cell( diff --git a/codex-rs/tui/src/ui_consts.rs b/codex-rs/tui/src/ui_consts.rs index ebbe4ce366..f72dc64263 100644 --- a/codex-rs/tui/src/ui_consts.rs +++ b/codex-rs/tui/src/ui_consts.rs @@ -9,3 +9,4 @@ /// - User history lines account for this many columns (e.g., "▌ ") when wrapping. pub(crate) const LIVE_PREFIX_COLS: u16 = 2; pub(crate) const FOOTER_INDENT_COLS: usize = LIVE_PREFIX_COLS as usize; +pub(crate) const TRANSCRIPT_HINT: &str = "ctrl + t to view transcript"; From c9767411249b50279785b438646140f285536663 Mon Sep 17 00:00:00 2001 From: guinness-oai Date: Mon, 6 Jul 2026 14:12:54 -0700 Subject: [PATCH 16/76] [codex] Flush trailing realtime transcript tail (#29918) ## Why A realtime session can end after more transcript has accumulated than was included in its last handoff. That tail already lives in core's active transcript state, but the stop path aborted the realtime input/fanout tasks before routing it, so the final bit of the conversation could disappear before `thread/realtime/closed`. This behavior is still being evaluated, so clients must opt in per realtime session. Omitted or false leaves shutdown behavior unchanged. ## What changed - Add optional `flushTranscriptTailOnSessionEnd` to `thread/realtime/start`, defaulting to false in app-server. - Expose an idempotent `take_transcript_tail()` from the existing active transcript state using `last_handoff_entry_count`. - When enabled, let shutdown cancel the input owner cleanly and publish at most one final existing `` with the remaining text in ``. - Have the existing fanout drain already-parsed events before routing that final delegation, so a queued handoff wins first and is not duplicated in the tail. - Flush realtime shutdown before ordinary session task abort during core cleanup. ## Validation - `just test -p codex-app-server-protocol` - `just test -p codex-app-server realtime_conversation_stop_emits_closed_notification` - `just test -p codex-core conversation_transport_close_` - `just test -p codex-core conversation_close_routes_only_remaining_transcript_tail_once` - scoped `just fix` for `codex-app-server-protocol`, `codex-app-server`, `codex-protocol`, and `codex-core` - `just fmt` --- .../src/protocol/common.rs | 7 + .../src/protocol/v2/realtime.rs | 4 + .../src/request_processors/turn_processor.rs | 3 + .../tests/suite/v2/experimental_api.rs | 2 + .../tests/suite/v2/realtime_conversation.rs | 9 + .../endpoint/realtime_websocket/methods.rs | 57 +++++ codex-rs/core/src/realtime_conversation.rs | 88 +++++-- codex-rs/core/src/session/handlers.rs | 2 +- codex-rs/core/tests/suite/compact_remote.rs | 1 + .../core/tests/suite/realtime_conversation.rs | 216 ++++++++++++++++-- codex-rs/protocol/src/protocol.rs | 3 + 11 files changed, 357 insertions(+), 35 deletions(-) diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d8db99d4ff..c4b94d8b3f 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -3195,6 +3195,7 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { client_managed_handoffs: Some(true), + flush_transcript_tail_on_session_end: Some(true), codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: Some("silent context".to_string()), @@ -3216,6 +3217,7 @@ mod tests { "params": { "threadId": "thr_123", "clientManagedHandoffs": true, + "flushTranscriptTailOnSessionEnd": true, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, "codexResponseHandoffPrefix": "silent context", @@ -3240,6 +3242,7 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3261,6 +3264,7 @@ mod tests { "params": { "threadId": "thr_123", "clientManagedHandoffs": null, + "flushTranscriptTailOnSessionEnd": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, "codexResponseHandoffPrefix": null, @@ -3280,6 +3284,7 @@ mod tests { request_id: RequestId::Integer(9), params: v2::ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3301,6 +3306,7 @@ mod tests { "params": { "threadId": "thr_123", "clientManagedHandoffs": null, + "flushTranscriptTailOnSessionEnd": null, "codexResponsesAsItems": null, "codexResponseItemPrefix": null, "codexResponseHandoffPrefix": null, @@ -3518,6 +3524,7 @@ mod tests { request_id: RequestId::Integer(1), params: v2::ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs index 4b8b2056ea..b78f390bec 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/realtime.rs @@ -70,6 +70,10 @@ pub struct ThreadRealtimeStartParams { /// them automatically. Defaults to false. #[ts(optional = nullable)] pub client_managed_handoffs: Option, + /// Routes any transcript tail remaining at session end through Codex. Defaults to false. + /// TODO: Remove this rollout knob once transcript-tail flushing is always enabled. + #[ts(optional = nullable)] + pub flush_transcript_tail_on_session_end: Option, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. #[ts(optional = nullable)] pub codex_responses_as_items: Option, 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 bb074e457b..3ff64cd34e 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1002,6 +1002,9 @@ impl TurnRequestProcessor { thread.as_ref(), Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: params.client_managed_handoffs.unwrap_or(false), + flush_transcript_tail_on_session_end: params + .flush_transcript_tail_on_session_end + .unwrap_or(false), codex_responses_as_items: params.codex_responses_as_items.unwrap_or(false), codex_response_item_prefix: params.codex_response_item_prefix, codex_response_handoff_prefix: params.codex_response_handoff_prefix, diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index d9d3469936..80fdb03171 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -82,6 +82,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -198,6 +199,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< let request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs index 26b46c4317..1d8bd90e6d 100644 --- a/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs +++ b/codex-rs/app-server/tests/suite/v2/realtime_conversation.rs @@ -349,6 +349,7 @@ impl RealtimeE2eHarness { .mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs, + flush_transcript_tail_on_session_end: None, thread_id: self.thread_id.clone(), codex_response_item_prefix: codex_responses_as_items .unwrap_or(false) @@ -410,6 +411,7 @@ impl RealtimeE2eHarness { .send_thread_realtime_start_request(ThreadRealtimeStartParams { thread_id: self.thread_id.clone(), client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_response_item_prefix: codex_responses_as_items .unwrap_or(false) .then(|| RESPONSE_ITEM_PREFIX.to_string()), @@ -672,6 +674,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -963,6 +966,7 @@ async fn realtime_start_can_skip_startup_context() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1061,6 +1065,7 @@ async fn realtime_text_output_modality_requests_text_output_and_final_transcript let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1242,6 +1247,7 @@ async fn realtime_conversation_stop_emits_closed_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1346,6 +1352,7 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2701,6 +2708,7 @@ async fn realtime_webrtc_start_surfaces_backend_error() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2767,6 +2775,7 @@ async fn realtime_conversation_requires_feature_flag() -> Result<()> { let start_request_id = mcp .send_thread_realtime_start_request(ThreadRealtimeStartParams { client_managed_handoffs: None, + flush_transcript_tail_on_session_end: None, codex_responses_as_items: None, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 499635579f..0ae443d00a 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -391,6 +391,13 @@ impl RealtimeWebsocketWriter { } impl RealtimeWebsocketEvents { + pub async fn take_transcript_tail(&self) -> Vec { + let mut active_transcript = self.active_transcript.lock().await; + let tail = active_transcript.entries[active_transcript.last_handoff_entry_count..].to_vec(); + active_transcript.last_handoff_entry_count = active_transcript.entries.len(); + tail + } + pub async fn next_event(&self) -> Result, ApiError> { if self.is_closed.load(Ordering::SeqCst) { return Ok(None); @@ -962,6 +969,56 @@ mod tests { ); } + #[tokio::test] + async fn takes_only_transcript_after_last_handoff_once() { + let (_tx_message, rx_message) = async_channel::unbounded(); + let events = RealtimeWebsocketEvents { + rx_message, + active_transcript: Arc::new(Mutex::new(ActiveTranscriptState::default())), + event_parser: RealtimeEventParser::V1, + is_closed: Arc::new(AtomicBool::new(false)), + }; + + assert_eq!(events.take_transcript_tail().await, vec![]); + + let mut covered = RealtimeEvent::InputTranscriptDelta(RealtimeTranscriptDelta { + delta: "already handed off".to_string(), + }); + events.update_active_transcript(&mut covered).await; + let mut handoff = RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "already handed off".to_string(), + active_transcript: vec![], + }); + events.update_active_transcript(&mut handoff).await; + assert_eq!( + handoff, + RealtimeEvent::HandoffRequested(RealtimeHandoffRequested { + handoff_id: "handoff_1".to_string(), + item_id: "item_1".to_string(), + input_transcript: "already handed off".to_string(), + active_transcript: vec![RealtimeTranscriptEntry { + role: "user".to_string(), + text: "already handed off".to_string(), + }], + }) + ); + + let mut tail = RealtimeEvent::OutputTranscriptDelta(RealtimeTranscriptDelta { + delta: "tail".to_string(), + }); + events.update_active_transcript(&mut tail).await; + assert_eq!( + events.take_transcript_tail().await, + vec![RealtimeTranscriptEntry { + role: "assistant".to_string(), + text: "tail".to_string(), + }] + ); + assert_eq!(events.take_transcript_tail().await, vec![]); + } + #[test] fn parse_input_transcript_delta_event() { let payload = json!({ diff --git a/codex-rs/core/src/realtime_conversation.rs b/codex-rs/core/src/realtime_conversation.rs index 0c81133be1..3915c8190d 100644 --- a/codex-rs/core/src/realtime_conversation.rs +++ b/codex-rs/core/src/realtime_conversation.rs @@ -48,6 +48,7 @@ use codex_protocol::protocol::RealtimeConversationSdpEvent; use codex_protocol::protocol::RealtimeConversationStartedEvent; use codex_protocol::protocol::RealtimeHandoffRequested; use codex_protocol::protocol::RealtimeOutputModality; +use codex_protocol::protocol::RealtimeTranscriptEntry; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::RealtimeVoicesList; use http::HeaderMap; @@ -59,6 +60,7 @@ use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; use tokio::sync::Mutex; use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; use tracing::debug; use tracing::error; use tracing::info; @@ -80,6 +82,7 @@ const REALTIME_V2_STEER_ACKNOWLEDGEMENT: &str = "This was sent to steer the previous background agent task."; const REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX: &str = "Conversation already has an active response in progress:"; +const REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION: &str = "The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something."; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum RealtimeConversationEnd { @@ -89,7 +92,7 @@ enum RealtimeConversationEnd { } enum RealtimeFanoutTaskStop { - Abort, + Await, Detach, } @@ -203,6 +206,9 @@ struct RealtimeInputTask { handoff_state: RealtimeHandoffState, session_kind: RealtimeSessionKind, event_parser: RealtimeEventParser, + flush_transcript_tail_on_session_end: bool, + transcript_tail_tx: Sender, + stop_token: CancellationToken, } struct RealtimeInputChannels { @@ -242,12 +248,14 @@ struct ConversationState { input_task: JoinHandle<()>, fanout_task: Option>, realtime_active: Arc, + stop_token: CancellationToken, } struct RealtimeStart { api_provider: ApiProvider, extra_headers: Option, client_managed_handoffs: bool, + flush_transcript_tail_on_session_end: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, codex_response_handoff_prefix: Option, @@ -260,6 +268,7 @@ struct RealtimeStart { struct RealtimeStartOutput { realtime_active: Arc, events_rx: Receiver, + transcript_tail_rx: Receiver, sdp: Option, } @@ -294,7 +303,7 @@ impl RealtimeConversationManager { guard.take() }; if let Some(state) = previous_state { - stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await; + stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await; } self.start_inner(start).await @@ -305,6 +314,7 @@ impl RealtimeConversationManager { api_provider, extra_headers, client_managed_handoffs, + flush_transcript_tail_on_session_end, codex_responses_as_items, codex_response_item_prefix, codex_response_handoff_prefix, @@ -327,8 +337,10 @@ impl RealtimeConversationManager { async_channel::bounded::(HANDOFF_OUT_QUEUE_CAPACITY); let (events_tx, events_rx) = async_channel::bounded::(OUTPUT_EVENTS_QUEUE_CAPACITY); + let (transcript_tail_tx, transcript_tail_rx) = async_channel::bounded::(1); let realtime_active = Arc::new(AtomicBool::new(true)); + let stop_token = CancellationToken::new(); let handoff = RealtimeHandoffState::new( handoff_output_tx, client_managed_handoffs, @@ -364,6 +376,9 @@ impl RealtimeConversationManager { session_kind, event_parser, realtime_active: Arc::clone(&realtime_active), + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token: stop_token.clone(), }); (task, Some(call.sdp)) } else { @@ -385,6 +400,9 @@ impl RealtimeConversationManager { handoff_state: handoff.clone(), session_kind, event_parser, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token: stop_token.clone(), }); (task, None) }; @@ -398,10 +416,12 @@ impl RealtimeConversationManager { input_task: task, fanout_task: None, realtime_active: Arc::clone(&realtime_active), + stop_token, }); Ok(RealtimeStartOutput { realtime_active, events_rx, + transcript_tail_rx, sdp, }) } @@ -652,7 +672,7 @@ impl RealtimeConversationManager { }; if let Some(state) = state { - stop_conversation_state(state, RealtimeFanoutTaskStop::Abort).await; + stop_conversation_state(state, RealtimeFanoutTaskStop::Await).await; } Ok(()) } @@ -663,13 +683,12 @@ async fn stop_conversation_state( fanout_task_stop: RealtimeFanoutTaskStop, ) { state.realtime_active.store(false, Ordering::Relaxed); - state.input_task.abort(); + state.stop_token.cancel(); let _ = state.input_task.await; if let Some(fanout_task) = state.fanout_task.take() { match fanout_task_stop { - RealtimeFanoutTaskStop::Abort => { - fanout_task.abort(); + RealtimeFanoutTaskStop::Await => { let _ = fanout_task.await; } RealtimeFanoutTaskStop::Detach => {} @@ -716,6 +735,7 @@ struct PreparedRealtimeConversationStart { api_provider: ApiProvider, extra_headers: Option, client_managed_handoffs: bool, + flush_transcript_tail_on_session_end: bool, codex_responses_as_items: bool, codex_response_item_prefix: Option, codex_response_handoff_prefix: Option, @@ -800,6 +820,7 @@ async fn prepare_realtime_start( api_provider, extra_headers, client_managed_handoffs: params.client_managed_handoffs, + flush_transcript_tail_on_session_end: params.flush_transcript_tail_on_session_end, codex_responses_as_items: params.codex_responses_as_items, codex_response_item_prefix: params.codex_response_item_prefix, codex_response_handoff_prefix: params.codex_response_handoff_prefix, @@ -966,6 +987,7 @@ async fn handle_start_inner( api_provider, extra_headers, client_managed_handoffs, + flush_transcript_tail_on_session_end, codex_responses_as_items, codex_response_item_prefix, codex_response_handoff_prefix, @@ -984,6 +1006,7 @@ async fn handle_start_inner( api_provider, extra_headers, client_managed_handoffs, + flush_transcript_tail_on_session_end, codex_responses_as_items, codex_response_item_prefix, codex_response_handoff_prefix, @@ -1008,6 +1031,7 @@ async fn handle_start_inner( let RealtimeStartOutput { realtime_active, events_rx, + transcript_tail_rx, sdp, } = start_output; if let Some(sdp) = sdp { @@ -1027,10 +1051,8 @@ async fn handle_start_inner( msg, }; let mut end = RealtimeConversationEnd::TransportClosed; + // Drain already-parsed events so a queued handoff is routed before the final tail. while let Ok(event) = events_rx.recv().await { - if !fanout_realtime_active.load(Ordering::Relaxed) { - break; - } match &event { RealtimeEvent::AudioOut(_) => {} _ => { @@ -1054,9 +1076,6 @@ async fn handle_start_inner( let sess_for_routed_text = Arc::clone(&sess_clone); sess_for_routed_text.route_realtime_text_input(text).await; } - if !fanout_realtime_active.load(Ordering::Relaxed) { - break; - } sess_clone .send_event_raw(ev(EventMsg::RealtimeConversationRealtime( RealtimeConversationRealtimeEvent { @@ -1065,6 +1084,9 @@ async fn handle_start_inner( ))) .await; } + if let Ok(text) = transcript_tail_rx.recv().await { + sess_clone.route_realtime_text_input(text).await; + } if fanout_realtime_active.swap(false, Ordering::Relaxed) { match end { RealtimeConversationEnd::TransportClosed => { @@ -1103,8 +1125,11 @@ pub(crate) async fn handle_audio( } fn realtime_transcript_delta_from_handoff(handoff: &RealtimeHandoffRequested) -> Option { - let active_transcript = handoff - .active_transcript + realtime_transcript_delta(&handoff.active_transcript) +} + +fn realtime_transcript_delta(active_transcript: &[RealtimeTranscriptEntry]) -> Option { + let active_transcript = active_transcript .iter() .map(|entry| format!("{role}: {text}", role = entry.role, text = entry.text)) .collect::>() @@ -1254,6 +1279,9 @@ struct RealtimeWebrtcSidebandInputTask { session_kind: RealtimeSessionKind, event_parser: RealtimeEventParser, realtime_active: Arc, + flush_transcript_tail_on_session_end: bool, + transcript_tail_tx: Sender, + stop_token: CancellationToken, } fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> JoinHandle<()> { @@ -1268,6 +1296,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J session_kind, event_parser, realtime_active, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token, } = input; tokio::spawn(async move { @@ -1275,15 +1306,15 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J return; } - let connection = match client - .connect_webrtc_sideband( + let connection = match tokio::select! { + connection = client.connect_webrtc_sideband( session_config, &call_id, sideband_headers, default_headers(), - ) - .await - { + ) => connection, + _ = stop_token.cancelled() => return, + } { Ok(connection) => connection, Err(err) => { if realtime_active.load(Ordering::Relaxed) { @@ -1311,6 +1342,9 @@ fn spawn_webrtc_sideband_input_task(input: RealtimeWebrtcSidebandInputTask) -> J handoff_state, session_kind, event_parser, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token, }) .await; }) @@ -1327,6 +1361,9 @@ async fn run_realtime_input_task(input: RealtimeInputTask) { handoff_state, session_kind, event_parser, + flush_transcript_tail_on_session_end, + transcript_tail_tx, + stop_token, } = input; let mut output_audio_state: Option = None; @@ -1334,6 +1371,7 @@ async fn run_realtime_input_task(input: RealtimeInputTask) { loop { let result = tokio::select! { + _ = stop_token.cancelled() => break, // Text input that should be sent into realtime. text = text_rx.recv() => { handle_text_input( @@ -1378,6 +1416,18 @@ async fn run_realtime_input_task(input: RealtimeInputTask) { break; } } + + if flush_transcript_tail_on_session_end + && let Some(transcript_delta) = + realtime_transcript_delta(&events.take_transcript_tail().await) + { + let _ = transcript_tail_tx + .send(wrap_realtime_delegation_input( + REALTIME_SESSION_ENDED_HANDOFF_INSTRUCTION, + Some(&transcript_delta), + )) + .await; + } } async fn handle_text_input( diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index 9733a9f66a..924750bc34 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -589,8 +589,8 @@ async fn shutdown_session_runtime(sess: &Arc) { if let Some(startup_prewarm) = sess.take_session_startup_prewarm().await { startup_prewarm.abort().await; } - sess.abort_all_tasks(TurnAbortReason::Interrupted).await; let _ = sess.conversation.shutdown().await; + sess.abort_all_tasks(TurnAbortReason::Interrupted).await; sess.services .unified_exec_manager .terminate_all_processes() diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index d0e69d6569..e22f70b052 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -209,6 +209,7 @@ async fn start_realtime_conversation(codex: &codex_core::CodexThread) -> Result< codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/core/tests/suite/realtime_conversation.rs b/codex-rs/core/tests/suite/realtime_conversation.rs index 1a95223bff..f4262e8e40 100644 --- a/codex-rs/core/tests/suite/realtime_conversation.rs +++ b/codex-rs/core/tests/suite/realtime_conversation.rs @@ -285,6 +285,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -431,6 +432,7 @@ async fn conversation_start_defaults_to_v2_and_gpt_realtime_1_5() -> Result<()> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -526,6 +528,7 @@ async fn conversation_webrtc_start_posts_generated_session() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -713,6 +716,7 @@ async fn conversation_webrtc_start_uses_avas_query() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -811,6 +815,7 @@ async fn conversation_webrtc_default_v1_ignores_configured_v2_voice() -> Result< test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -871,6 +876,7 @@ async fn conversation_webrtc_default_v1_rejects_explicit_v2_voice() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -941,6 +947,7 @@ async fn conversation_webrtc_start_uses_configured_call_base_url_for_avas() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1035,6 +1042,7 @@ async fn conversation_webrtc_close_while_sideband_connecting_drops_pending_join( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1126,6 +1134,7 @@ async fn conversation_webrtc_sideband_connect_failure_closes_with_error() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1219,6 +1228,7 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1271,27 +1281,46 @@ async fn conversation_start_uses_openai_env_key_fallback_with_chatgpt_auth() -> Ok(()) } -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn conversation_transport_close_emits_closed_event() -> Result<()> { +async fn assert_transport_close_tail_flush( + flush_transcript_tail_on_session_end: bool, +) -> Result<()> { skip_if_no_network!(Ok(())); - let session_updated = vec![json!({ - "type": "session.updated", - "session": { "id": "sess_1", "instructions": "backend prompt" } - })]; - let server = start_websocket_server(vec![vec![], vec![session_updated]]).await; + let api_server = start_mock_server().await; + let response_mock = responses::mount_sse_once( + &api_server, + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "ok"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let realtime_server = start_websocket_server(vec![vec![vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_1", "instructions": "backend prompt" } + }), + json!({ + "type": "conversation.input_transcript.delta", + "delta": "transport tail" + }), + ]]]) + .await; - let mut builder = test_codex(); - let test = builder.build_with_websocket_server(&server).await?; - assert!( - server - .wait_for_handshakes(/*expected*/ 1, Duration::from_secs(2)) - .await - ); + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; + } + }); + let test = builder.build(&api_server).await?; test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1334,8 +1363,27 @@ async fn conversation_transport_close_emits_closed_event() -> Result<()> { }) .await; assert_eq!(closed.reason.as_deref(), Some("transport_closed")); + if flush_transcript_tail_on_session_end { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while response_mock.requests().is_empty() { + assert!(tokio::time::Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(response_mock.single_request().message_input_texts("user").iter().any(|text| text + == "\n The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.\n user: transport tail\n")); + } else { + tokio::time::sleep(Duration::from_millis(200)).await; + assert!(response_mock.requests().is_empty()); + } - server.shutdown().await; + realtime_server.shutdown().await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_transport_close_tail_flush_is_opt_in() -> Result<()> { + assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ false).await?; + assert_transport_close_tail_flush(/*flush_transcript_tail_on_session_end*/ true).await?; Ok(()) } @@ -1389,6 +1437,7 @@ async fn conversation_start_preflight_failure_emits_realtime_error_only() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1440,6 +1489,7 @@ async fn conversation_start_connect_failure_emits_realtime_error_only() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1539,6 +1589,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1569,6 +1620,7 @@ async fn conversation_second_start_replaces_runtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1670,6 +1722,7 @@ async fn conversation_uses_experimental_realtime_ws_base_url_override() -> Resul test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1739,6 +1792,7 @@ async fn conversation_uses_default_realtime_backend_prompt() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1816,6 +1870,7 @@ async fn conversation_uses_empty_instructions_for_null_or_empty_prompt() -> Resu test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1886,6 +1941,7 @@ async fn conversation_uses_explicit_start_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1948,6 +2004,7 @@ async fn conversation_uses_configured_realtime_voice() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -1998,6 +2055,7 @@ async fn conversation_rejects_voice_for_wrong_realtime_version() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2049,6 +2107,7 @@ async fn conversation_uses_experimental_realtime_ws_backend_prompt_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2126,6 +2185,7 @@ async fn conversation_uses_experimental_realtime_ws_startup_context_override() - test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2197,6 +2257,7 @@ async fn conversation_disables_realtime_startup_context_with_empty_override() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2261,6 +2322,7 @@ async fn conversation_start_injects_startup_context_from_thread_history() -> Res test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2380,6 +2442,7 @@ async fn conversation_startup_context_current_thread_selects_many_turns_by_budge codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2492,6 +2555,7 @@ async fn conversation_startup_context_falls_back_to_workspace_map() -> Result<() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2556,6 +2620,7 @@ async fn conversation_startup_context_is_truncated_and_sent_once_per_start() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2641,6 +2706,7 @@ async fn conversation_user_text_turn_is_not_sent_to_realtime() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2742,6 +2808,7 @@ async fn realtime_v2_noop_tool_call_returns_empty_function_output_without_respon test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2845,6 +2912,7 @@ async fn conversation_mirrors_assistant_message_text_to_realtime_handoff() -> Re test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -2984,6 +3052,7 @@ async fn conversation_handoff_persists_across_item_done_until_turn_complete() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: Some(SILENT_CONTEXT_PREFIX.to_string()), @@ -3140,6 +3209,7 @@ async fn inbound_handoff_request_starts_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3254,6 +3324,7 @@ async fn inbound_handoff_request_uses_active_transcript() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3361,6 +3432,7 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3426,6 +3498,115 @@ async fn inbound_handoff_request_sends_transcript_delta_after_each_handoff() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn conversation_close_routes_only_remaining_transcript_tail_once() -> Result<()> { + skip_if_no_network!(Ok(())); + + let api_server = start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &api_server, + vec![ + responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "first ok"), + responses::ev_completed("resp-1"), + ]), + responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "tail ok"), + responses::ev_completed("resp-2"), + ]), + ], + ) + .await; + let realtime_server = start_websocket_server(vec![vec![ + vec![ + json!({ + "type": "session.updated", + "session": { "id": "sess_tail", "instructions": "backend prompt" } + }), + json!({ + "type": "conversation.input_transcript.delta", + "delta": "already handed off" + }), + json!({ + "type": "conversation.handoff.requested", + "handoff_id": "handoff_tail", + "item_id": "item_tail", + "input_transcript": "already handed off" + }), + json!({ + "type": "conversation.output_transcript.delta", + "delta": "remaining answer" + }), + json!({ + "type": "conversation.input_transcript.delta", + "delta": "remaining question" + }), + ], + vec![], + ]]) + .await; + let mut builder = test_codex().with_config({ + let realtime_base_url = realtime_server.uri().to_string(); + move |config| { + config.experimental_realtime_ws_base_url = Some(realtime_base_url); + config.realtime.version = RealtimeWsVersion::V1; + } + }); + let test = builder.build(&api_server).await?; + + test.codex + .submit(Op::RealtimeConversationStart(ConversationStartParams { + client_managed_handoffs: false, + flush_transcript_tail_on_session_end: true, + codex_responses_as_items: false, + codex_response_item_prefix: None, + codex_response_handoff_prefix: None, + model: None, + output_modality: RealtimeOutputModality::Audio, + include_startup_context: true, + prompt: Some(Some("backend prompt".to_string())), + realtime_session_id: None, + transport: None, + version: None, + voice: None, + })) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + test.codex.submit(Op::RealtimeConversationClose).await?; + + let closed = wait_for_event_match(&test.codex, |msg| match msg { + EventMsg::RealtimeConversationClosed(closed) => Some(closed.clone()), + _ => None, + }) + .await; + assert_eq!(closed.reason.as_deref(), Some("requested")); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while response_mock.requests().len() < 2 { + assert!(tokio::time::Instant::now() < deadline); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + test.codex.submit(Op::RealtimeConversationClose).await?; + tokio::time::sleep(Duration::from_millis(200)).await; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + assert!(requests[0].message_input_texts("user").iter().any(|text| text + == "\n already handed off\n user: already handed off\n")); + assert!(requests[1].message_input_texts("user").iter().any(|text| text + == "\n The user just ended their realtime session. Here is the remaining handoff/transcript tail. You probably do not have to do anything; acknowledge the handoff unless the transcript itself asks for something.\n assistant: remaining answer\nuser: remaining question\n")); + + realtime_server.shutdown().await; + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio() -> Result<()> { skip_if_no_network!(Ok(())); @@ -3466,6 +3647,7 @@ async fn inbound_conversation_item_does_not_start_turn_and_still_forwards_audio( test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3593,6 +3775,7 @@ async fn delegated_turn_user_role_echo_does_not_redelegate_and_still_forwards_au test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3750,6 +3933,7 @@ async fn inbound_handoff_request_does_not_block_realtime_event_forwarding() -> R test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -3896,6 +4080,7 @@ async fn inbound_handoff_request_steers_active_turn() -> Result<()> { test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, @@ -4053,6 +4238,7 @@ async fn inbound_handoff_request_starts_turn_and_does_not_block_realtime_audio() test.codex .submit(Op::RealtimeConversationStart(ConversationStartParams { client_managed_handoffs: false, + flush_transcript_tail_on_session_end: false, codex_responses_as_items: false, codex_response_item_prefix: None, codex_response_handoff_prefix: None, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 2c97af10aa..ca509a5a17 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -194,6 +194,9 @@ pub struct McpServerRefreshConfig { pub struct ConversationStartParams { /// Whether Codex response handoffs are managed through explicit client append calls. pub client_managed_handoffs: bool, + /// Whether to route any remaining transcript tail through Codex when the session ends. + /// TODO: Remove this rollout knob once transcript-tail flushing is always enabled. + pub flush_transcript_tail_on_session_end: bool, /// Sends automatic Codex responses as realtime conversation items instead of handoff appends. pub codex_responses_as_items: bool, /// Optional prefix added to automatic Codex response items when `codex_responses_as_items` is set. From 3c2bfe7c5d05c33b4d961d692fac9bb888867150 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Mon, 6 Jul 2026 14:29:19 -0700 Subject: [PATCH 17/76] Make Apps guidance react to MCP availability (#30226) ## Why Generic Apps guidance is emitted only while building static initial context. If the Apps MCP is unavailable then and recovers later in the same turn, its tools can become usable without the model receiving the guidance for using them. ## What - move generic Apps guidance into a persisted `apps_instructions` World State section - derive availability from the request's MCP runtime while preserving the existing feature, auth, orchestrator-MCP, and config gates - recognize legacy and retained Apps fragments so resume and compaction do not duplicate guidance - register Apps guidance as rollback-trimmable context - remove the old static injection path and its now-unused connector helper - keep tool construction on its existing independent `list_all_tools()` read rather than adding a request-wide cache; a reconnect between the two reads can differ for one request and reconciles on the next request Apps and plugin guidance now render after the remaining static host-skills block, with Apps before Plugins. ## Testing - `just fmt` - `just test -p codex-core apps_instructions` - `just test -p codex-core apps_guidance_appears_after_background_recovery_within_a_turn` - `just test -p codex-core drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn` --- codex-rs/core/src/apps/render.rs | 5 +- codex-rs/core/src/connectors.rs | 13 -- .../core/src/context/apps_instructions.rs | 10 -- .../context/world_state/apps_instructions.rs | 55 +++++++ .../world_state/apps_instructions_tests.rs | 69 +++++++++ codex-rs/core/src/context/world_state/mod.rs | 2 + .../core/src/context_manager/history_tests.rs | 4 + codex-rs/core/src/event_mapping.rs | 2 + codex-rs/core/src/session/mod.rs | 15 -- codex-rs/core/src/session/world_state.rs | 15 ++ .../core/tests/suite/mcp_tool_exposure.rs | 140 ++++++++++++++++++ codex-rs/core/tests/suite/plugins.rs | 4 +- 12 files changed, 293 insertions(+), 41 deletions(-) create mode 100644 codex-rs/core/src/context/world_state/apps_instructions.rs create mode 100644 codex-rs/core/src/context/world_state/apps_instructions_tests.rs diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 2331e13f77..b3d47913f6 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -5,7 +5,10 @@ use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option { - AppsInstructions::from_connectors(connectors).map(|instructions| instructions.render()) + connectors + .iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + .then(|| AppsInstructions.render()) } #[cfg(test)] diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 805d56b648..6e643f34d5 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -80,19 +80,6 @@ pub async fn list_accessible_connectors_from_mcp_tools( ) } -pub(crate) async fn list_accessible_and_enabled_connectors_from_manager( - mcp_connection_manager: &McpConnectionManager, - config: &Config, -) -> Vec { - with_app_enabled_state( - accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await), - config, - ) - .into_iter() - .filter(|connector| connector.is_accessible && connector.is_enabled) - .collect() -} - #[instrument(level = "trace", skip_all)] pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, diff --git a/codex-rs/core/src/context/apps_instructions.rs b/codex-rs/core/src/context/apps_instructions.rs index 761187c794..71d836d4ea 100644 --- a/codex-rs/core/src/context/apps_instructions.rs +++ b/codex-rs/core/src/context/apps_instructions.rs @@ -1,4 +1,3 @@ -use crate::connectors::AppInfo; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; @@ -8,15 +7,6 @@ use super::ContextualUserFragment; #[derive(Debug, Clone, PartialEq)] pub(crate) struct AppsInstructions; -impl AppsInstructions { - pub(crate) fn from_connectors(connectors: &[AppInfo]) -> Option { - connectors - .iter() - .any(|connector| connector.is_accessible && connector.is_enabled) - .then_some(Self) - } -} - impl ContextualUserFragment for AppsInstructions { fn role(&self) -> &'static str { "developer" diff --git a/codex-rs/core/src/context/world_state/apps_instructions.rs b/codex-rs/core/src/context/world_state/apps_instructions.rs new file mode 100644 index 0000000000..767e1c584e --- /dev/null +++ b/codex-rs/core/src/context/world_state/apps_instructions.rs @@ -0,0 +1,55 @@ +use super::PreviousSectionState; +use super::WorldStateSection; +use crate::context::AppsInstructions; +use crate::context::ContextualUserFragment; + +/// Whether generic Apps usage guidance should be visible to the model. +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct AppsInstructionsState { + available: bool, +} + +impl AppsInstructionsState { + pub(crate) fn new(available: bool) -> Self { + Self { available } + } +} + +impl WorldStateSection for AppsInstructionsState { + const ID: &'static str = "apps_instructions"; + type Snapshot = bool; + + fn snapshot(&self) -> Self::Snapshot { + self.available + } + + fn matches_legacy_fragment(role: &str, text: &str) -> bool { + role == "developer" && AppsInstructions::matches_text(text) + } + + fn has_retained_fragment_matcher() -> bool { + true + } + + fn matches_retained_fragment(role: &str, text: &str) -> bool { + Self::matches_legacy_fragment(role, text) + } + + fn render_diff( + &self, + previous: PreviousSectionState<'_, Self::Snapshot>, + ) -> Option> { + if !self.available + || matches!(previous, PreviousSectionState::Known(previous) if *previous) + || matches!(previous, PreviousSectionState::Unknown) + { + return None; + } + + Some(Box::new(AppsInstructions)) + } +} + +#[cfg(test)] +#[path = "apps_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/core/src/context/world_state/apps_instructions_tests.rs b/codex-rs/core/src/context/world_state/apps_instructions_tests.rs new file mode 100644 index 0000000000..92f6613594 --- /dev/null +++ b/codex-rs/core/src/context/world_state/apps_instructions_tests.rs @@ -0,0 +1,69 @@ +use super::*; +use crate::context::ContextualUserFragment; +use codex_protocol::models::ResponseItem; +use pretty_assertions::assert_eq; + +fn render(state: AppsInstructionsState, previous: PreviousSectionState<'_, bool>) -> Vec { + state + .render_diff(previous) + .into_iter() + .map(|fragment| fragment.render()) + .collect() +} + +#[test] +fn renders_only_when_apps_become_available() { + let unavailable = AppsInstructionsState::new(/*available*/ false); + let available = AppsInstructionsState::new(/*available*/ true); + let false_snapshot = false; + let true_snapshot = true; + + assert_eq!( + render(unavailable, PreviousSectionState::Absent), + Vec::::new() + ); + assert_eq!(render(available, PreviousSectionState::Absent).len(), 1); + assert_eq!( + render(available, PreviousSectionState::Known(&false_snapshot)).len(), + 1 + ); + assert_eq!( + render(available, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); + assert_eq!( + render(unavailable, PreviousSectionState::Known(&true_snapshot)), + Vec::::new() + ); +} + +#[test] +fn legacy_guidance_is_not_injected_again() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(AppsInstructionsState::new(/*available*/ true)); + let legacy: ResponseItem = ContextualUserFragment::into(AppsInstructions); + + assert!( + world_state + .render_history_diff(/*previous*/ None, &[legacy]) + .is_empty() + ); +} + +#[test] +fn persisted_guidance_is_restored_only_when_missing_from_history() { + let mut world_state = super::super::WorldState::default(); + world_state.add_section(AppsInstructionsState::new(/*available*/ true)); + let snapshot = world_state.snapshot(); + let retained: ResponseItem = ContextualUserFragment::into(AppsInstructions); + + assert_eq!( + world_state.render_history_diff(Some(&snapshot), &[]).len(), + 1 + ); + assert!( + world_state + .render_history_diff(Some(&snapshot), &[retained]) + .is_empty() + ); +} diff --git a/codex-rs/core/src/context/world_state/mod.rs b/codex-rs/core/src/context/world_state/mod.rs index 5dc22030ba..ccdc976c4c 100644 --- a/codex-rs/core/src/context/world_state/mod.rs +++ b/codex-rs/core/src/context/world_state/mod.rs @@ -1,4 +1,5 @@ mod agents_md; +mod apps_instructions; mod environment; mod plugins_instructions; @@ -17,6 +18,7 @@ use std::collections::BTreeMap; use std::fmt; pub(crate) use agents_md::AgentsMdState; +pub(crate) use apps_instructions::AppsInstructionsState; pub(crate) use environment::EnvironmentsState; pub(crate) use plugins_instructions::PluginsInstructionsState; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index df5abd4708..679e1a69f7 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -20,6 +20,7 @@ use codex_protocol::models::ReasoningItemContent; use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::default_input_modalities; +use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::PLUGINS_INSTRUCTIONS_OPEN_TAG; @@ -1022,6 +1023,9 @@ fn drop_last_n_user_turns_trims_context_updates_above_rolled_back_turn() { user_input_text_msg("turn 1 user"), assistant_msg("turn 1 assistant"), developer_msg("Generated images are saved to /tmp as /tmp/image-1.png by default."), + developer_msg(&format!( + "{APPS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_APPS_INSTRUCTIONS" + )), developer_msg(&format!( "{PLUGINS_INSTRUCTIONS_OPEN_TAG}\nROLLED_BACK_PLUGIN_INSTRUCTIONS" )), diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index 5e24b78d05..ef8f0fad5b 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -14,6 +14,7 @@ use codex_protocol::models::is_image_close_tag_text; 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::APPS_INSTRUCTIONS_OPEN_TAG; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_GUIDANCE_OPEN_TAG; use codex_protocol::protocol::CONTEXT_WINDOW_OPEN_TAG; @@ -32,6 +33,7 @@ use crate::web_search::web_search_action_detail; const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[ "", "", + APPS_INSTRUCTIONS_OPEN_TAG, COLLABORATION_MODE_OPEN_TAG, MULTI_AGENT_MODE_OPEN_TAG, PLUGINS_INSTRUCTIONS_OPEN_TAG, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index d4cfd9e05d..ba8eacb395 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -21,9 +21,7 @@ use crate::build_available_skills; use crate::compact; use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; -use crate::connectors; use crate::context::ApprovedCommandPrefixSaved; -use crate::context::AppsInstructions; use crate::context::AvailableSkillsInstructions; use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; @@ -3235,19 +3233,6 @@ impl Session { .push(PersonalitySpecInstructions::new(personality_message).render()); } } - if turn_context.config.include_apps_instructions && turn_context.apps_enabled() { - let accessible_and_enabled_connectors = - connectors::list_accessible_and_enabled_connectors_from_manager( - mcp.manager(), - &turn_context.config, - ) - .await; - if let Some(apps_instructions) = - AppsInstructions::from_connectors(&accessible_and_enabled_connectors) - { - developer_sections.push(apps_instructions.render()); - } - } if turn_context.config.include_skill_instructions { let available_skills = build_available_skills( turn_context.turn_skills.snapshot.outcome(), diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index 89684e9e50..a0d93cac83 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -1,6 +1,8 @@ use super::session::Session; use super::step_context::StepContext; +use crate::connectors; use crate::context::world_state::AgentsMdState; +use crate::context::world_state::AppsInstructionsState; use crate::context::world_state::EnvironmentsState; use crate::context::world_state::PluginsInstructionsState; use crate::context::world_state::WorldState; @@ -37,6 +39,19 @@ impl Session { .with_subagents(environment_subagents), ); } + let apps_available = + if turn_context.config.include_apps_instructions && turn_context.apps_enabled() { + let tools = step_context.mcp.manager().list_all_tools().await; + connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&tools), + &turn_context.config, + ) + .into_iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + } else { + false + }; + world_state.add_section(AppsInstructionsState::new(apps_available)); world_state.add_section(PluginsInstructionsState::new( step_context.mcp.plugins_available(), )); diff --git a/codex-rs/core/tests/suite/mcp_tool_exposure.rs b/codex-rs/core/tests/suite/mcp_tool_exposure.rs index 32ecf7cf2a..51ab7842ee 100644 --- a/codex-rs/core/tests/suite/mcp_tool_exposure.rs +++ b/codex-rs/core/tests/suite/mcp_tool_exposure.rs @@ -1,22 +1,32 @@ use anyhow::Result; use codex_features::Feature; use codex_mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::Op; +use codex_protocol::request_user_input::RequestUserInputAnswer; +use codex_protocol::request_user_input::RequestUserInputResponse; +use codex_protocol::user_input::UserInput; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::apps_test_server::SEARCH_CALENDAR_CREATE_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::search_capable_apps_builder; use core_test_support::responses; 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_response_created; 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::skip_if_no_network; +use core_test_support::wait_for_event; +use core_test_support::wait_for_event_match; use core_test_support::wait_for_mcp_server; use serde_json::Value; +use serde_json::json; +use std::collections::HashMap; use std::time::Duration; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -86,6 +96,136 @@ async fn code_mode_only_exposes_direct_model_only_mcp_namespaces() -> Result<()> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apps_guidance_appears_after_background_recovery_within_a_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let (apps_server, startup_control) = + AppsTestServer::mount_with_startup_control(&server).await?; + // Initial context is rendered twice before the first request, so keep both reads unavailable. + startup_control.fail_next_initialize_attempts(/*attempts*/ 2); + let call_id = "pause-for-apps"; + let response = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + call_id, + "request_user_input", + &json!({ + "questions": [{ + "id": "continue", + "header": "Continue", + "question": "Continue after Apps recovers?", + "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_assistant_message("msg-2", "done"), + ev_completed("resp-2"), + ]), + ], + ) + .await; + let mut builder = + apps_enabled_builder(apps_server.chatgpt_base_url.clone()).with_config(|config| { + config + .features + .enable(Feature::DeferredExecutor) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::DefaultModeRequestUserInput) + .expect("test config should allow feature update"); + }); + let test = builder.build(&server).await?; + let mcp_runtime = test.codex.current_mcp_runtime().await; + + test.codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "use an app after it recovers".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; + + let initial_requests = response.requests(); + assert_eq!(initial_requests.len(), 1); + let initial_request = &initial_requests[0]; + assert_eq!( + initial_request + .message_input_texts("developer") + .iter() + .filter(|text| text.contains("")) + .count(), + 0 + ); + + tokio::time::timeout(Duration::from_secs(3), async { + loop { + if !mcp_runtime.manager().list_all_tools().await.is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("Apps MCP should recover while the turn is paused"); + 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.requests(); + assert_eq!(requests.len(), 2); + assert_eq!( + requests[1] + .message_input_texts("developer") + .iter() + .filter(|text| text.contains("")) + .count(), + 1 + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn later_follow_up_uses_background_recovered_apps_after_mid_thread_startup_failures() -> Result<()> { diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index b3420feabe..aaf65baad0 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -255,8 +255,8 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() .find("## Plugins") .expect("expected plugins section in developer message"); assert!( - apps_pos < skills_pos && skills_pos < plugins_pos, - "expected Apps -> Skills -> Plugins order: {developer_messages:?}" + skills_pos < apps_pos && apps_pos < plugins_pos, + "expected Skills -> Apps -> Plugins order: {developer_messages:?}" ); assert!( !developer_text.contains("`sample`: inspect sample data"), From d847857f804945b0ded04a6189f67c1c2629ee2b Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Mon, 6 Jul 2026 17:48:06 -0400 Subject: [PATCH 18/76] Use popup token ranges for autocomplete insertion (#31190) ## Summary Autocomplete popup synchronization already identifies the active token's range and query, but accepting a result previously discarded that range and independently recomputed token boundaries around the cursor. Those calculations could disagree at ambiguous cursor positions. For example, in `@first| @second` (with `|` marking the cursor at the intervening space), the popup targets `@second`, while the old acceptance path replaces `@first`. This threads the active range through legacy file search, skill mentions, and mentions-v2 completion. File, image, and mention insertion now replace the same token that supplied the popup query, and image completion reuses the shared file insertion path. ## Stack This is PR 1 of 3. #31191 builds separator and dismissal behavior on these explicit replacement ranges, and #30463 then fixes token affinity between adjacent mentions. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 175 ++++++------------ 1 file changed, 56 insertions(+), 119 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index e568436b40..5ba8d88ff5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1826,42 +1826,10 @@ impl ChatComposer { }; let sel_path = sel.to_string_lossy().to_string(); - if Self::is_image_path(&sel_path) { - let path_buf = PathBuf::from(&sel_path); - match image::image_dimensions(&path_buf) { - Ok((width, height)) => { - tracing::debug!("selected image dimensions={}x{}", width, height); - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - self.draft.textarea.replace_range(start_idx..end_idx, ""); - self.draft.textarea.set_cursor(start_idx); - - self.attach_image(path_buf); - self.draft.textarea.insert_str(" "); - } - Err(err) => { - tracing::trace!("image dimensions lookup failed: {err}"); - self.insert_selected_path(&sel_path); - } - } - } else { - self.insert_selected_path(&sel_path); + if let Some((token_range, _)) = + self.current_editable_at_token_range_with_options(/*allow_empty*/ false) + { + self.insert_selected_file_path(token_range, &sel_path); } self.popups.active = ActivePopup::None; (InputResult::None, true) @@ -1935,8 +1903,10 @@ impl ChatComposer { }; if close_popup { - if let Some((insert_text, path)) = selected_mention { - self.insert_selected_mention(&insert_text, path.as_deref()); + if let Some((insert_text, path)) = selected_mention + && let Some((token_range, _)) = self.current_mention_token_range() + { + self.insert_selected_mention(token_range, &insert_text, path.as_deref()); } self.popups.active = ActivePopup::None; } @@ -2040,13 +2010,19 @@ impl ChatComposer { }; if close_popup { - if let Some(selected) = selected { + let token_range = self + .current_editable_at_token_range_with_options(/*allow_empty*/ true) + .map(|(range, _)| range); + if let (Some(selected), Some(token_range)) = (selected, token_range) { match selected { MentionV2Selection::File(path) => { - self.insert_selected_file_path(path.to_string_lossy().as_ref()); + self.insert_selected_file_path( + token_range, + path.to_string_lossy().as_ref(), + ); } MentionV2Selection::Tool { insert_text, path } => { - self.insert_selected_mention(&insert_text, path.as_deref()); + self.insert_selected_mention(token_range, &insert_text, path.as_deref()); } } } @@ -2068,42 +2044,25 @@ impl ChatComposer { || lower.ends_with(".webp") } - fn insert_selected_file_path(&mut self, selected_path: &str) { + fn insert_selected_file_path(&mut self, token_range: Range, selected_path: &str) { if Self::is_image_path(selected_path) { let path_buf = PathBuf::from(selected_path); match image::image_dimensions(&path_buf) { Ok((width, height)) => { tracing::debug!("selected image dimensions={}x{}", width, height); - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - - self.draft.textarea.replace_range(start_idx..end_idx, ""); + let start_idx = token_range.start; + self.draft.textarea.replace_range(token_range, ""); self.draft.textarea.set_cursor(start_idx); self.attach_image(path_buf); self.draft.textarea.insert_str(" "); } Err(err) => { tracing::trace!("image dimensions lookup failed: {err}"); - self.insert_selected_path(selected_path); + self.insert_selected_path(token_range, selected_path); } } } else { - self.insert_selected_path(selected_path); + self.insert_selected_path(token_range, selected_path); } } @@ -2372,7 +2331,10 @@ impl ChatComposer { Self::current_prefixed_token(textarea, '@', /*allow_empty*/ false) } - fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { + fn current_editable_at_token_range_with_options( + &self, + allow_empty: bool, + ) -> Option<(Range, String)> { let (range, token) = Self::current_prefixed_token_range(&self.draft.textarea, '@', allow_empty)?; if self @@ -2402,55 +2364,43 @@ impl ChatComposer { return None; } - Some(token) + Some((range, token)) + } + + fn current_editable_at_token_with_options(&self, allow_empty: bool) -> Option { + self.current_editable_at_token_range_with_options(allow_empty) + .map(|(_, token)| token) } fn current_editable_at_token(&self) -> Option { self.current_editable_at_token_with_options(/*allow_empty*/ false) } - fn current_mentions_v2_token(&self) -> Option { + fn current_mentions_v2_token_range(&self) -> Option<(Range, String)> { if !self.mentions_v2_enabled { return None; } - self.current_editable_at_token_with_options(/*allow_empty*/ true) + self.current_editable_at_token_range_with_options(/*allow_empty*/ true) } - fn current_mention_token(&self) -> Option { + fn current_mentions_v2_token(&self) -> Option { + self.current_mentions_v2_token_range() + .map(|(_, token)| token) + } + + fn current_mention_token_range(&self) -> Option<(Range, String)> { if !self.mentions_enabled() { return None; } - Self::current_prefixed_token(&self.draft.textarea, '$', /*allow_empty*/ true) + Self::current_prefixed_token_range(&self.draft.textarea, '$', /*allow_empty*/ true) + } + + fn current_mention_token(&self) -> Option { + self.current_mention_token_range().map(|(_, token)| token) } /// Replace the active `@token` (the one under the cursor) with `path`. - /// - /// The algorithm mirrors `current_at_token` so replacement works no matter - /// where the cursor is within the token and regardless of how many - /// `@tokens` exist in the line. - fn insert_selected_path(&mut self, path: &str) { - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - // Clamp to a valid char boundary to avoid panics when slicing. - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - // Determine token boundaries. - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - + fn insert_selected_path(&mut self, token_range: Range, path: &str) { // If the path contains whitespace, wrap it in double quotes so the // local prompt arg parser treats it as a single argument. Avoid adding // quotes when the path already contains one to keep behavior simple. @@ -2463,36 +2413,23 @@ impl ChatComposer { // Replace just the active `@token` so unrelated text elements, such as // large-paste placeholders, remain atomic and can still expand on submit. + let start_idx = token_range.start; self.draft .textarea - .replace_range(start_idx..end_idx, &format!("{inserted} ")); + .replace_range(token_range, &format!("{inserted} ")); let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1); self.draft.textarea.set_cursor(new_cursor); } - fn insert_selected_mention(&mut self, insert_text: &str, path: Option<&str>) { - let cursor_offset = self.draft.textarea.cursor(); - let text = self.draft.textarea.text(); - let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset); - - let before_cursor = &text[..safe_cursor]; - let after_cursor = &text[safe_cursor..]; - - let start_idx = before_cursor - .char_indices() - .rfind(|(_, c)| c.is_whitespace()) - .map(|(idx, c)| idx + c.len_utf8()) - .unwrap_or(0); - - let end_rel_idx = after_cursor - .char_indices() - .find(|(_, c)| c.is_whitespace()) - .map(|(idx, _)| idx) - .unwrap_or(after_cursor.len()); - let end_idx = safe_cursor + end_rel_idx; - + fn insert_selected_mention( + &mut self, + token_range: Range, + insert_text: &str, + path: Option<&str>, + ) { // Remove the active token and insert the selected mention as an atomic element. - self.draft.textarea.replace_range(start_idx..end_idx, ""); + let start_idx = token_range.start; + self.draft.textarea.replace_range(token_range, ""); self.draft.textarea.set_cursor(start_idx); let id = self.draft.textarea.insert_element(insert_text); From 1013295c2d326f3a4d54525a63b68fd1ee355cac Mon Sep 17 00:00:00 2001 From: jif Date: Mon, 6 Jul 2026 22:51:34 +0100 Subject: [PATCH 19/76] fix: attribut network requests to the exact exec on linux (#29697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Managed-network commands within one Codex conversation share the same HTTP and SOCKS proxy ingress. When several exec calls run concurrently, the proxy sees the requested destination but cannot tell which exec opened the connection. For example: ```text exec A: curl https://example.com/a ─┐ ├─> conversation proxy ─> Guardian exec B: curl https://example.com/b ─┘ host: example.com trigger: unknown ```. Three parallel network execs reached Guardian without their triggering call IDs or commands. Guardian denied the requests, but Codex could not safely associate those outcomes with the individual tool calls. ## What changes Keep the shared proxy ingress and tag each connection at the existing trusted Linux bridge: ```text exec A ─> existing Linux bridge ─> [token A][proxy bytes] ─┐ ├─> shared HTTP/SOCKS ingress exec B ─> existing Linux bridge ─> [token B][proxy bytes] ─┘ │ token A ─> exec A ─────┤ token B ─> exec B ─────┘ ``` The complete path is: ```text active exec registration │ ├─ registers its UUID as a short-lived attribution token ├─ passes the token to the Linux sandbox helper ├─ helper removes the token before launching the user command ├─ existing host bridge prepends the token to each proxy connection ├─ shared proxy consumes the bounded attribution frame └─ proxy attaches the matching execution-scoped state ├─ Guardian receives the exact call ID and command └─ a denial finishes/cancels the matching tool call ``` Dropping the active or deferred exec registration removes the token. Connections that were already accepted retain their resolved attribution; new connections using an expired token fail closed. ## Before and after Before, Guardian could receive only the network destination: ```json { "tool": "network_access", "host": "www.17track.net", "port": 443, "protocol": "https" } ``` After, the same request includes the action that caused it: ```json { "tool": "network_access", "host": "www.17track.net", "port": 443, "protocol": "https", "trigger": { "callId": "exec-network-first", "command": ["/bin/sh", "-c", "curl https://www.17track.net"] } } ``` ## Listener accounting This PR does **not** create proxy listeners per exec. ```text Existing topology: one conversation -> one HTTP listener + optional one SOCKS listener Discarded per-exec approach: one conversation -> existing listener pair + up to one additional listener pair per active exec This PR: one conversation -> existing listener pair only + one small token-map entry per active exec ``` The Linux sandbox already creates a trusted routing bridge for each sandboxed command. This PR adds a short frame write to that bridge rather than introducing another listener, task, or proxy process. The existing conversation-scoped listener pair remains. Making a single proxy service shared across multiple conversations would be a separate multi-tenant architecture change involving per-conversation policy, configuration, audit, and Guardian routing. ## Keeping the implementation small The attribution is bound once, when the TCP connection enters the proxy. The ingress installs an execution-scoped clone of the existing `NetworkProxyState`, so the established HTTP, SOCKS, MITM, policy, audit, and blocked-request paths continue using their existing state lookup. This avoids plumbing a new request-context type through every protocol handler. Outside the two ingress wrappers, protocol-specific request handling is unchanged. ## Security behavior - Tokens are generated from the existing random execution registration IDs. - The trusted Linux helper consumes and removes the token before executing user code. - Attribution frames have a fixed magic prefix, bounded token length, and bounded read timeout. - Unknown or expired tokens close the connection. - A token presented to a proxy for another environment closes the connection. - Existing unframed callers preserve the current conservative attribution behavior. ## Platform scope Exact bridge attribution is enabled on Linux. macOS and Windows retain their current shared-proxy behavior. ## Test coverage The concurrent end-to-end test starts two managed-network execs together and synchronizes them so both are active before either connects. It then inspects the two Guardian requests and compares the complete attribution pairs: ```text (exec-network-first, exact first command) (exec-network-second, exact second command) ``` Focused proxy coverage verifies the bounded frame and that a registered framed connection receives the matching execution and environment state. ## Scope This fixes the Linux network-to-exec attribution path and records a denial against the exact matching tool call. It intentionally does not change: - delivery of an entirely unattributed denial to the parent turn; - how parallel denials count toward the Guardian circuit breaker; - how the UI displays the rejection reason or completed-turn state. Those remain separate concerns from attribution. ## Relationship to #29456 and #29668 #29456 made the proxy environment and sandbox policy come from the same prepared network context. This PR adds the execution token to that prepared launch and consumes it at the shared ingress. This follows #29668's shared-ingress framing direction, but completes the production registration, Linux bridge, core call mapping, denial mapping, and concurrent end-to-end path. It also keeps attribution in the existing per-connection proxy state instead of introducing request-context plumbing through every HTTP, SOCKS, and MITM handler. This PR is intended to supersede #29668 for the Linux attribution fix. --------- Co-authored-by: viyatb-oai Co-authored-by: Codex --- codex-rs/Cargo.lock | 1 + .../core/src/network_policy_decision_tests.rs | 2 + codex-rs/core/src/tools/network_approval.rs | 149 +++++++-- .../core/src/tools/network_approval_tests.rs | 31 ++ codex-rs/core/src/tools/orchestrator.rs | 14 +- .../src/tools/runtimes/apply_patch_tests.rs | 2 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + .../core/src/tools/runtimes/unified_exec.rs | 4 +- codex-rs/core/src/tools/sandboxing.rs | 10 + codex-rs/core/src/tools/sandboxing_tests.rs | 1 + codex-rs/core/tests/suite/network_approval.rs | 285 +++++++++++++++++- codex-rs/linux-sandbox/Cargo.toml | 1 + codex-rs/linux-sandbox/src/proxy_routing.rs | 81 ++++- codex-rs/network-proxy/src/attribution.rs | 143 +++++++++ .../network-proxy/src/attribution_tests.rs | 63 ++++ codex-rs/network-proxy/src/http_proxy.rs | 8 +- codex-rs/network-proxy/src/lib.rs | 3 + codex-rs/network-proxy/src/network_policy.rs | 63 +++- codex-rs/network-proxy/src/proxy.rs | 26 ++ .../src/proxy/execution_scope.rs | 38 +++ codex-rs/network-proxy/src/runtime.rs | 70 ++++- codex-rs/network-proxy/src/socks5.rs | 11 +- 22 files changed, 951 insertions(+), 56 deletions(-) create mode 100644 codex-rs/network-proxy/src/attribution.rs create mode 100644 codex-rs/network-proxy/src/attribution_tests.rs create mode 100644 codex-rs/network-proxy/src/proxy/execution_scope.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 6ac51d403f..89d27d0ec0 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3264,6 +3264,7 @@ dependencies = [ "clap", "codex-core", "codex-install-context", + "codex-network-proxy", "codex-process-hardening", "codex-protocol", "codex-sandboxing", diff --git a/codex-rs/core/src/network_policy_decision_tests.rs b/codex-rs/core/src/network_policy_decision_tests.rs index 0640041847..8888bbe9c2 100644 --- a/codex-rs/core/src/network_policy_decision_tests.rs +++ b/codex-rs/core/src/network_policy_decision_tests.rs @@ -161,6 +161,7 @@ fn denied_network_policy_message_requires_deny_decision() { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), + execution_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), @@ -178,6 +179,7 @@ fn denied_network_policy_message_for_denylist_block_is_explicit() { method: Some("GET".to_string()), mode: None, protocol: "http".to_string(), + execution_id: None, decision: Some("deny".to_string()), source: Some("baseline_policy".to_string()), port: Some(80), diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 2b63164b4d..747ae80a6f 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -27,9 +27,11 @@ use codex_protocol::protocol::Event; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::WarningEvent; +use codex_sandboxing::SandboxType; use indexmap::IndexMap; use std::collections::HashMap; use std::collections::HashSet; +use std::io; use std::sync::Arc; use tokio::sync::Mutex; use tokio::sync::Notify; @@ -59,6 +61,7 @@ pub(crate) struct DeferredNetworkApproval { registration_id: String, cancellation_token: CancellationToken, finish_outcome: Arc>>, + _execution_proxy: Option, } impl DeferredNetworkApproval { @@ -89,6 +92,7 @@ pub(crate) struct ActiveNetworkApproval { registration_id: Option, mode: NetworkApprovalMode, cancellation_token: CancellationToken, + execution_proxy: NetworkProxy, } impl ActiveNetworkApproval { @@ -100,11 +104,16 @@ impl ActiveNetworkApproval { self.cancellation_token.clone() } + pub(crate) fn execution_proxy(&self) -> &NetworkProxy { + &self.execution_proxy + } + pub(crate) fn into_deferred(self) -> Option { let ActiveNetworkApproval { registration_id, mode, cancellation_token, + execution_proxy, } = self; match (mode, registration_id) { (NetworkApprovalMode::Deferred, Some(registration_id)) => { @@ -112,6 +121,7 @@ impl ActiveNetworkApproval { registration_id, cancellation_token, finish_outcome: Arc::new(OnceCell::new()), + _execution_proxy: Some(execution_proxy), }) } _ => None, @@ -241,6 +251,11 @@ enum ActiveNetworkApprovalAttribution { Ambiguous, } +struct NetworkRequestAttribution { + owner_call: Option>, + environment_id: Option, +} + #[derive(Default)] struct NetworkApprovalCallState { active_calls: IndexMap>, @@ -305,9 +320,8 @@ impl NetworkApprovalService { async fn resolve_single_active_call(&self) -> Option> { let calls = self.calls.lock().await; - // Blocked proxy requests are not attributed to a specific tool call. Only pick an owner - // when there is exactly one candidate; with concurrent calls, canceling one would be a guess. - // TODO: Carry blocked-request attribution so concurrent active calls can be handled safely. + // Shared proxy requests can still arrive without an execution ID. Only pick an owner when + // there is exactly one candidate; with concurrent calls, canceling one would be a guess. if calls.active_calls.len() == 1 { return calls.active_calls.values().next().cloned(); } @@ -315,6 +329,18 @@ impl NetworkApprovalService { None } + async fn resolve_active_call_by_execution_id( + &self, + execution_id: &str, + ) -> Option> { + self.calls + .lock() + .await + .active_calls + .get(execution_id) + .cloned() + } + async fn resolve_active_call_attribution(&self) -> ActiveNetworkApprovalAttribution { let calls = self.calls.lock().await; match calls.active_calls.len() { @@ -327,6 +353,54 @@ impl NetworkApprovalService { } } + async fn resolve_request_attribution( + &self, + request: &NetworkPolicyRequest, + ) -> Option { + if let Some(execution_id) = request.execution_id.as_deref() { + let call = self + .resolve_active_call_by_execution_id(execution_id) + .await?; + let environment_id = request + .environment_id + .clone() + .unwrap_or_else(|| call.environment_id.clone()); + return (call.environment_id == environment_id).then_some(NetworkRequestAttribution { + owner_call: Some(call), + environment_id: Some(environment_id), + }); + } + + if let Some(environment_id) = request.environment_id.clone() { + let owner_call = match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::Single(call) => { + (call.environment_id == environment_id).then_some(call) + } + ActiveNetworkApprovalAttribution::None + | ActiveNetworkApprovalAttribution::Ambiguous => None, + }; + return Some(NetworkRequestAttribution { + owner_call, + environment_id: Some(environment_id), + }); + } + + match self.resolve_active_call_attribution().await { + ActiveNetworkApprovalAttribution::None => Some(NetworkRequestAttribution { + owner_call: None, + environment_id: None, + }), + ActiveNetworkApprovalAttribution::Single(call) => { + let environment_id = call.environment_id.clone(); + Some(NetworkRequestAttribution { + owner_call: Some(call), + environment_id: Some(environment_id), + }) + } + ActiveNetworkApprovalAttribution::Ambiguous => None, + } + } + async fn get_or_create_pending_approval( &self, key: HostApprovalKey, @@ -393,8 +467,12 @@ impl NetworkApprovalService { return; }; - self.record_outcome_for_single_active_call(NetworkApprovalOutcome::DeniedByPolicy(message)) - .await; + let outcome = NetworkApprovalOutcome::DeniedByPolicy(message); + if let Some(execution_id) = blocked.execution_id.as_deref() { + self.record_call_outcome(execution_id, outcome).await; + } else { + self.record_outcome_for_single_active_call(outcome).await; + } } async fn active_turn_context( @@ -431,28 +509,13 @@ impl NetworkApprovalService { NetworkProtocol::Socks5Tcp => NetworkApprovalProtocol::Socks5Tcp, NetworkProtocol::Socks5Udp => NetworkApprovalProtocol::Socks5Udp, }; - let (owner_call, active_environment_id) = - if let Some(environment_id) = request.environment_id.clone() { - let owner_call = match self.resolve_active_call_attribution().await { - ActiveNetworkApprovalAttribution::Single(call) => { - (call.environment_id == environment_id).then_some(call) - } - ActiveNetworkApprovalAttribution::None - | ActiveNetworkApprovalAttribution::Ambiguous => None, - }; - (owner_call, Some(environment_id)) - } else { - match self.resolve_active_call_attribution().await { - ActiveNetworkApprovalAttribution::None => (None, None), - ActiveNetworkApprovalAttribution::Single(call) => { - let environment_id = call.environment_id.clone(); - (Some(call), Some(environment_id)) - } - ActiveNetworkApprovalAttribution::Ambiguous => { - return NetworkDecision::deny(REASON_NOT_ALLOWED); - } - } - }; + let Some(NetworkRequestAttribution { + owner_call, + environment_id: active_environment_id, + }) = self.resolve_request_attribution(&request).await + else { + return NetworkDecision::deny(REASON_NOT_ALLOWED); + }; let turn_context = Self::active_turn_context(session.as_ref()).await; let Some(environment_id) = active_environment_id.or_else(|| { turn_context @@ -788,20 +851,39 @@ pub(crate) async fn begin_network_approval( session: &Session, turn_id: &str, managed_network_active: bool, + selected_sandbox: SandboxType, spec: Option, -) -> Option { +) -> Result, ToolError> { let NetworkApprovalSpec { network, mode, trigger, command, environment_id, - } = spec?; - if !managed_network_active || network.is_none() { - return None; + } = match spec { + Some(spec) => spec, + None => return Ok(None), + }; + let Some(network) = network else { + return Ok(None); + }; + if !managed_network_active { + return Ok(None); } let registration_id = Uuid::new_v4().to_string(); + let execution_proxy = if selected_sandbox == SandboxType::LinuxSeccomp { + let attribution_token = Uuid::new_v4().to_string(); + network + .for_execution(&environment_id, ®istration_id, attribution_token) + .map_err(|err| { + ToolError::Codex(codex_protocol::error::CodexErr::Io(io::Error::other( + format!("failed to create execution-scoped network proxy: {err}"), + ))) + })? + } else { + network.clone() + }; let cancellation_token = CancellationToken::new(); session .services @@ -816,11 +898,12 @@ pub(crate) async fn begin_network_approval( ) .await; - Some(ActiveNetworkApproval { + Ok(Some(ActiveNetworkApproval { registration_id: Some(registration_id), mode, cancellation_token, - }) + execution_proxy, + })) } pub(crate) async fn finish_immediate_network_approval( diff --git a/codex-rs/core/src/tools/network_approval_tests.rs b/codex-rs/core/src/tools/network_approval_tests.rs index 7a9ba86cef..4fdeb50ed7 100644 --- a/codex-rs/core/src/tools/network_approval_tests.rs +++ b/codex-rs/core/src/tools/network_approval_tests.rs @@ -285,6 +285,12 @@ fn denied_blocked_request(host: &str) -> BlockedRequest { }) } +fn denied_blocked_request_for_execution(host: &str, execution_id: &str) -> BlockedRequest { + let mut blocked = denied_blocked_request(host); + blocked.execution_id = Some(execution_id.to_string()); + blocked +} + async fn register_call_with_default_shell_trigger( service: &NetworkApprovalService, registration_id: &str, @@ -429,6 +435,7 @@ async fn deferred_finish_reuses_denial_result_after_first_consumer() { registration_id: "registration-1".to_string(), cancellation_token, finish_outcome: Arc::new(OnceCell::new()), + _execution_proxy: None, }; service .record_call_outcome( @@ -481,3 +488,27 @@ async fn record_blocked_request_ignores_ambiguous_unattributed_blocked_requests( assert_eq!(service.take_call_outcome("registration-1").await, None); assert_eq!(service.take_call_outcome("registration-2").await, None); } + +#[tokio::test] +async fn attributed_blocked_request_targets_one_of_multiple_active_calls() { + let service = NetworkApprovalService::default(); + let first = register_call_with_default_shell_trigger(&service, "registration-1").await; + let second = register_call_with_default_shell_trigger(&service, "registration-2").await; + + service + .record_blocked_request(denied_blocked_request_for_execution( + "example.com", + "registration-2", + )) + .await; + + assert!(!first.is_cancelled()); + assert!(second.is_cancelled()); + assert_eq!(service.take_call_outcome("registration-1").await, None); + assert_eq!( + service.take_call_outcome("registration-2").await, + Some(NetworkApprovalOutcome::DeniedByPolicy( + "Network access to \"example.com\" was blocked: domain is not on the allowlist for the current sandbox mode.".to_string() + )) + ); +} diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index da3e4acddd..f82ad5bb70 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -69,13 +69,18 @@ impl ToolOrchestrator { where T: ToolRuntime, { - let network_approval = begin_network_approval( + let network_approval = match begin_network_approval( &tool_ctx.session, &tool_ctx.turn.sub_id, managed_network_active, + attempt.sandbox, tool.network_approval_spec(req, tool_ctx), ) - .await; + .await + { + Ok(network_approval) => network_approval, + Err(err) => return (Err(err), None), + }; let attempt_tool_ctx = ToolCtx { session: tool_ctx.session.clone(), @@ -99,6 +104,9 @@ impl ToolOrchestrator { network_denial_cancellation_token: network_approval .as_ref() .map(ActiveNetworkApproval::cancellation_token), + network_proxy: network_approval + .as_ref() + .map(ActiveNetworkApproval::execution_proxy), }; let run_result = tool .run(req, &attempt_with_network_approval, &attempt_tool_ctx) @@ -275,6 +283,7 @@ impl ToolOrchestrator { .permissions .windows_sandbox_private_desktop, network_denial_cancellation_token: None, + network_proxy: None, }; let initial_attempt_start = Instant::now(); @@ -457,6 +466,7 @@ impl ToolOrchestrator { .permissions .windows_sandbox_private_desktop, network_denial_cancellation_token: None, + network_proxy: None, }; // Second attempt. 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 a37cd45c13..c70002e62f 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -232,6 +232,7 @@ async fn file_system_sandbox_context_uses_active_attempt() { windows_sandbox_level: WindowsSandboxLevel::RestrictedToken, windows_sandbox_private_desktop: true, network_denial_cancellation_token: None, + network_proxy: None, }; let sandbox = ApplyPatchRuntime::file_system_sandbox_context_for_attempt(&req, &attempt) @@ -300,6 +301,7 @@ async fn no_sandbox_attempt_has_no_file_system_context() { windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, + network_proxy: None, }; assert_eq!( diff --git a/codex-rs/core/src/tools/runtimes/mod_tests.rs b/codex-rs/core/src/tools/runtimes/mod_tests.rs index 5c2614c9c0..f6ec38f6c2 100644 --- a/codex-rs/core/src/tools/runtimes/mod_tests.rs +++ b/codex-rs/core/src/tools/runtimes/mod_tests.rs @@ -118,6 +118,7 @@ async fn explicit_escalation_prepares_exec_without_managed_network() -> anyhow:: windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, + network_proxy: None, }; let exec_request = attempt diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5bb30645e3..a1b9b33c6d 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -318,10 +318,10 @@ impl<'a> ToolRuntime for UnifiedExecRunt req.sandbox_permissions, &file_system_sandbox_policy, ); - let managed_network = managed_network_for_sandbox_permissions( + let managed_network = attempt.network_proxy(managed_network_for_sandbox_permissions( req.network.as_ref(), launch_sandbox_permissions, - ); + )); let env = exec_env_for_sandbox_permissions(&req.env, launch_sandbox_permissions); let (env, managed_network_context) = match managed_network { Some(network) => { diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 4c351eeb94..571c5ce6df 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -427,9 +427,17 @@ pub(crate) struct SandboxAttempt<'a> { pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, pub network_denial_cancellation_token: Option, + pub(crate) network_proxy: Option<&'a NetworkProxy>, } impl<'a> SandboxAttempt<'a> { + pub(crate) fn network_proxy<'b>( + &'b self, + fallback: Option<&'b NetworkProxy>, + ) -> Option<&'b NetworkProxy> { + fallback.map(|fallback| self.network_proxy.unwrap_or(fallback)) + } + pub fn env_for( &self, command: SandboxCommand, @@ -437,6 +445,7 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let network = self.network_proxy(network); let request = self .manager .transform(SandboxTransformRequest { @@ -469,6 +478,7 @@ impl<'a> SandboxAttempt<'a> { network: Option<&NetworkProxy>, environment_id: Option<&str>, ) -> Result { + let network = self.network_proxy(network); let managed_network = command.managed_network.clone(); let exec_server_permissions = effective_permission_profile( self.exec_server_permissions, diff --git a/codex-rs/core/src/tools/sandboxing_tests.rs b/codex-rs/core/src/tools/sandboxing_tests.rs index 28b9de9ce1..e52186f772 100644 --- a/codex-rs/core/src/tools/sandboxing_tests.rs +++ b/codex-rs/core/src/tools/sandboxing_tests.rs @@ -227,6 +227,7 @@ fn exec_server_env_keeps_command_native_and_carries_sandbox_context() { windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, network_denial_cancellation_token: None, + network_proxy: None, }; let managed_network = ManagedNetworkSandboxContext { loopback_ports: vec![43123], diff --git a/codex-rs/core/tests/suite/network_approval.rs b/codex-rs/core/tests/suite/network_approval.rs index 6b96c0d635..437155f325 100644 --- a/codex-rs/core/tests/suite/network_approval.rs +++ b/codex-rs/core/tests/suite/network_approval.rs @@ -28,6 +28,7 @@ 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_response_created; +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::start_mock_server; @@ -56,6 +57,181 @@ use tempfile::TempDir; const NETWORK_TEST_HOST: &str = "codex-network-test.invalid"; const NETWORK_TEST_TARGET: &str = "http://codex-network-test.invalid:80"; +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg_attr( + not(target_os = "linux"), + ignore = "requires the trusted Linux proxy bridge" +)] +async fn guardian_receives_exact_triggers_for_concurrent_network_requests() -> Result<()> { + skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture"); + skip_if_host_windows!(Ok(())); + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let test = managed_network_unified_exec_test(&server).await?; + let barrier_dir = TempDir::new_in(test.cwd.path())?; + let first_marker = barrier_dir.path().join("first"); + let second_marker = barrier_dir.path().join("second"); + let network_command = |marker: &PathBuf, peer_marker: &PathBuf, host: &str| { + format!( + "touch '{}' && while [ ! -e '{}' ]; do sleep 0.01; done && python3 -c \"import urllib.request; urllib.request.build_opener(urllib.request.ProxyHandler()).open('http://{host}', timeout=10).read()\"", + marker.display(), + peer_marker.display(), + ) + }; + let first_command = network_command(&first_marker, &second_marker, "1.1.1.1"); + let second_command = network_command(&second_marker, &first_marker, "8.8.8.8"); + mount_sse_once_match( + &server, + |request: &wiremock::Request| { + !is_guardian_request(request) + && request_body_contains(request, "run both network requests") + && !request_body_contains(request, "exec-network-first") + }, + sse(vec![ + ev_response_created("resp-network-concurrent"), + ev_function_call( + "exec-network-first", + "exec_command", + &serde_json::to_string(&network_exec_args(&first_command))?, + ), + ev_function_call( + "exec-network-second", + "exec_command", + &serde_json::to_string(&network_exec_args(&second_command))?, + ), + ev_completed("resp-network-concurrent"), + ]), + ) + .await; + let first_guardian = mount_sse_once_match( + &server, + |request: &wiremock::Request| guardian_request_is_for(request, "exec-network-first"), + sse(vec![ + ev_response_created("resp-network-guardian-1"), + ev_assistant_message("msg-network-guardian-1", r#"{"outcome":"deny"}"#), + ev_completed("resp-network-guardian-1"), + ]), + ) + .await; + let second_guardian = mount_sse_once_match( + &server, + |request: &wiremock::Request| guardian_request_is_for(request, "exec-network-second"), + sse(vec![ + ev_response_created("resp-network-guardian-2"), + ev_assistant_message("msg-network-guardian-2", r#"{"outcome":"deny"}"#), + ev_completed("resp-network-guardian-2"), + ]), + ) + .await; + mount_sse_once_match( + &server, + |request: &wiremock::Request| { + !is_guardian_request(request) + && request_body_contains(request, "exec-network-first") + && request_body_contains(request, "exec-network-second") + }, + sse(vec![ + ev_response_created("resp-network-done"), + ev_assistant_message("msg-network-done", "done"), + ev_completed("resp-network-done"), + ]), + ) + .await; + + submit_managed_network_turn( + &test, + "run both network requests", + vec![local(test.config.cwd.clone())], + ApprovalsReviewer::AutoReview, + AskForApproval::OnRequest, + ) + .await?; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let actual_triggers = loop { + let mut actual_triggers = guardian_network_triggers(&[&first_guardian, &second_guardian])?; + actual_triggers.sort_unstable(); + actual_triggers.dedup(); + if actual_triggers.len() == 2 { + break actual_triggers; + } + if tokio::time::Instant::now() >= deadline { + anyhow::bail!("timed out waiting for both Guardian network reviews"); + } + tokio::time::sleep(Duration::from_millis(10)).await; + }; + wait_for_turn_complete(&test).await; + + assert_eq!( + actual_triggers, + vec![ + ("exec-network-first".to_string(), first_command), + ("exec-network-second".to_string(), second_command), + ] + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[cfg_attr( + not(target_os = "linux"), + ignore = "requires the trusted Linux proxy bridge" +)] +async fn guardian_receives_exact_trigger_for_single_network_request() -> Result<()> { + skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture"); + skip_if_host_windows!(Ok(())); + skip_if_no_network!(Ok(())); + skip_if_sandbox!(Ok(())); + + let server = start_mock_server().await; + let test = managed_network_unified_exec_test(&server).await?; + let command = "python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://1.1.1.1', timeout=10).read().decode(errors='replace'))\"".to_string(); + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-network-single"), + ev_function_call( + "exec-network-single", + "exec_command", + &serde_json::to_string(&network_exec_args(&command))?, + ), + ev_completed("resp-network-single"), + ]), + sse(vec![ + ev_response_created("resp-network-guardian"), + ev_assistant_message("msg-network-guardian", r#"{"outcome":"deny"}"#), + ev_completed("resp-network-guardian"), + ]), + sse(vec![ + ev_response_created("resp-network-done"), + ev_assistant_message("msg-network-done", "done"), + ev_completed("resp-network-done"), + ]), + ], + ) + .await; + + submit_managed_network_turn( + &test, + "run one network request", + vec![local(test.config.cwd.clone())], + ApprovalsReviewer::AutoReview, + AskForApproval::OnRequest, + ) + .await?; + wait_for_turn_complete(&test).await; + + assert_eq!( + guardian_network_triggers(&[&responses])?, + vec![("exec-network-single".to_string(), command)] + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn approved_network_host_for_one_environment_still_prompts_in_another() -> Result<()> { skip_if_target_windows!(Ok(()), "uses the POSIX/Python network fixture"); @@ -99,6 +275,8 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> &test, "fetch from the local environment", environments.clone(), + ApprovalsReviewer::User, + AskForApproval::UnlessTrusted, ) .await?; let approval = expect_network_approval(&test, LOCAL_ENVIRONMENT_ID).await?; @@ -122,6 +300,8 @@ async fn approved_network_host_for_one_environment_still_prompts_in_another() -> &test, "fetch from the remote environment", environments.clone(), + ApprovalsReviewer::User, + AskForApproval::UnlessTrusted, ) .await?; let approval = expect_network_approval(&test, REMOTE_ENVIRONMENT_ID).await?; @@ -225,12 +405,20 @@ async fn mount_exec_network_turn( } fn network_fetch_args(environment_id: &str) -> Value { + let command = format!( + "python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\"" + ); + let mut args = network_exec_args(&command); + args["environment_id"] = json!(environment_id); + args +} + +fn network_exec_args(command: &str) -> Value { json!({ "shell": "/bin/sh", - "cmd": format!("python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://{NETWORK_TEST_HOST}', timeout=2).read().decode(errors='replace'))\""), + "cmd": command, "login": false, "yield_time_ms": 1_000, - "environment_id": environment_id, }) } @@ -238,6 +426,8 @@ async fn submit_managed_network_turn( test: &TestCodex, prompt: &str, environments: Vec, + approvals_reviewer: ApprovalsReviewer, + approval_policy: AskForApproval, ) -> Result<()> { let permission_profile = PermissionProfile::workspace_write_with( &[], @@ -261,8 +451,8 @@ async fn submit_managed_network_turn( additional_context: Default::default(), thread_settings: codex_protocol::protocol::ThreadSettingsOverrides { environments: Some(turn_environment_selections), - approval_policy: Some(AskForApproval::OnRequest), - approvals_reviewer: Some(ApprovalsReviewer::User), + approval_policy: Some(approval_policy), + approvals_reviewer: Some(approvals_reviewer), sandbox_policy: Some(sandbox_policy), permission_profile, collaboration_mode: Some(codex_protocol::config_types::CollaborationMode { @@ -281,6 +471,93 @@ async fn submit_managed_network_turn( Ok(()) } +fn decoded_request_body(request: &wiremock::Request) -> Option> { + let is_zstd = request + .headers + .get("content-encoding") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().eq_ignore_ascii_case("zstd")) + }); + if is_zstd { + zstd::stream::decode_all(std::io::Cursor::new(&request.body)).ok() + } else { + Some(request.body.clone()) + } +} + +fn request_body_contains(request: &wiremock::Request, text: &str) -> bool { + decoded_request_body(request) + .and_then(|body| String::from_utf8(body).ok()) + .is_some_and(|body| body.contains(text)) +} + +fn is_guardian_request(request: &wiremock::Request) -> bool { + decoded_request_body(request) + .and_then(|body| serde_json::from_slice::(&body).ok()) + .is_some_and(|body| { + body.pointer("/client_metadata/x-openai-subagent") + .and_then(Value::as_str) + == Some("guardian") + }) +} + +fn guardian_request_is_for(request: &wiremock::Request, call_id: &str) -> bool { + decoded_request_body(request) + .and_then(|body| serde_json::from_slice::(&body).ok()) + .filter(|body| { + body.pointer("/client_metadata/x-openai-subagent") + .and_then(Value::as_str) + == Some("guardian") + }) + .and_then(|body| { + body.get("input") + .and_then(Value::as_array) + .and_then(|input| { + input + .iter() + .rev() + .find(|item| item.get("role").and_then(Value::as_str) == Some("user")) + }) + .cloned() + }) + .is_some_and(|latest_user_message| latest_user_message.to_string().contains(call_id)) +} + +fn guardian_network_triggers(responses: &[&ResponseMock]) -> Result> { + responses + .iter() + .flat_map(|responses| responses.requests()) + .filter(|request| { + request.body_json()["client_metadata"]["x-openai-subagent"].as_str() == Some("guardian") + }) + .map(|request| { + let user_texts = request.message_input_texts("user"); + let action: Value = serde_json::from_str( + user_texts + .iter() + .find(|text| text.contains("\"tool\": \"network_access\"")) + .context("expected network access JSON in Guardian request")? + .trim(), + )?; + Ok(( + action + .pointer("/trigger/callId") + .and_then(Value::as_str) + .context("expected exact trigger call id")? + .to_string(), + action + .pointer("/trigger/command/2") + .and_then(Value::as_str) + .context("expected exact trigger command")? + .to_string(), + )) + }) + .collect() +} + async fn expect_network_approval( test: &TestCodex, expected_environment_id: &str, diff --git a/codex-rs/linux-sandbox/Cargo.toml b/codex-rs/linux-sandbox/Cargo.toml index fc7937536c..0f7c68a152 100644 --- a/codex-rs/linux-sandbox/Cargo.toml +++ b/codex-rs/linux-sandbox/Cargo.toml @@ -19,6 +19,7 @@ workspace = true [target.'cfg(target_os = "linux")'.dependencies] clap = { workspace = true, features = ["derive"] } codex-install-context = { workspace = true } +codex-network-proxy = { workspace = true } codex-process-hardening = { workspace = true } codex-protocol = { workspace = true } codex-sandboxing = { workspace = true } diff --git a/codex-rs/linux-sandbox/src/proxy_routing.rs b/codex-rs/linux-sandbox/src/proxy_routing.rs index f171d91745..707cbaafbe 100644 --- a/codex-rs/linux-sandbox/src/proxy_routing.rs +++ b/codex-rs/linux-sandbox/src/proxy_routing.rs @@ -1,3 +1,5 @@ +use codex_network_proxy::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; +use codex_network_proxy::write_attribution_frame; use serde::Deserialize; use serde::Serialize; use std::collections::BTreeMap; @@ -71,8 +73,12 @@ struct ProxyRoutePlan { } pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { - let env: HashMap = std::env::vars().collect(); - let plan = plan_proxy_routes(&env); + let (attribution_token, plan) = extract_attribution_token_and_plan(std::env::vars().collect()); + // SAFETY: the sandbox helper is single-threaded here, before it forks bridge workers or + // executes the user command. + unsafe { + std::env::remove_var(PROXY_ATTRIBUTION_TOKEN_ENV_KEY); + } if plan.routes.is_empty() { let message = if plan.has_proxy_config { @@ -100,7 +106,11 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { let mut host_bridge_pids = Vec::with_capacity(socket_by_endpoint.len()); for (endpoint, socket_path) in &socket_by_endpoint { - host_bridge_pids.push(spawn_host_bridge(*endpoint, socket_path)?); + host_bridge_pids.push(spawn_host_bridge( + *endpoint, + socket_path, + attribution_token.as_deref(), + )?); } spawn_proxy_socket_dir_cleanup_worker(socket_dir, host_bridge_pids)?; @@ -121,6 +131,14 @@ pub(crate) fn prepare_host_proxy_route_spec() -> io::Result { serde_json::to_string(&ProxyRouteSpec { routes }).map_err(io::Error::other) } +fn extract_attribution_token_and_plan( + mut env: HashMap, +) -> (Option, ProxyRoutePlan) { + let attribution_token = env.remove(PROXY_ATTRIBUTION_TOKEN_ENV_KEY); + let plan = plan_proxy_routes(&env); + (attribution_token, plan) +} + pub(crate) fn activate_proxy_routes_in_netns(serialized_spec: &str) -> io::Result<()> { let spec: ProxyRouteSpec = serde_json::from_str(serialized_spec).map_err(io::Error::other)?; @@ -438,7 +456,11 @@ fn cleanup_proxy_socket_dir(socket_dir: &Path) -> io::Result<()> { } } -fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result { +fn spawn_host_bridge( + endpoint: SocketAddr, + uds_path: &Path, + attribution_token: Option<&str>, +) -> io::Result { let (read_fd, write_fd) = create_ready_pipe()?; let pid = unsafe { libc::fork() }; if pid < 0 { @@ -452,7 +474,7 @@ fn spawn_host_bridge(endpoint: SocketAddr, uds_path: &Path) -> io::Result io::Result io::Result<()> { +fn run_host_bridge( + endpoint: SocketAddr, + uds_path: &Path, + ready_fd: libc::c_int, + attribution_token: Option<&str>, +) -> io::Result<()> { harden_bridge_process()?; if uds_path.exists() { std::fs::remove_file(uds_path)?; @@ -482,13 +509,22 @@ fn run_host_bridge(endpoint: SocketAddr, uds_path: &Path, ready_fd: libc::c_int) ready_file.write_all(&[HOST_BRIDGE_READY])?; drop(ready_file); + let attribution_token = attribution_token.map(str::to_owned); loop { let (unix_stream, _) = listener.accept()?; + let attribution_token = attribution_token.clone(); std::thread::spawn(move || { - let tcp_stream = match TcpStream::connect(endpoint) { + let mut tcp_stream = match TcpStream::connect(endpoint) { Ok(stream) => stream, Err(_) => return, }; + if let Some(attribution_token) = attribution_token + && write_attribution_frame(&mut tcp_stream, &attribution_token).is_err() + { + // The shared ingress must reject unauthenticated connections; do not forward + // application bytes if this bridge cannot prove the exec attribution first. + return; + } let _ = proxy_bidirectional(tcp_stream, unix_stream); }); } @@ -673,12 +709,14 @@ fn close_fd(fd: libc::c_int) -> io::Result<()> { #[cfg(test)] mod tests { + use super::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use super::PROXY_SOCKET_DIR_PREFIX; use super::ProxyRouteEntry; use super::ProxyRouteSpec; use super::cleanup_proxy_socket_dir; use super::cleanup_stale_proxy_socket_dirs_in; use super::default_proxy_port; + use super::extract_attribution_token_and_plan; use super::is_proxy_env_key; use super::parse_loopback_proxy_endpoint; use super::parse_proxy_socket_dir_owner_pid; @@ -743,6 +781,35 @@ mod tests { ); } + #[test] + fn attribution_token_is_extracted_before_proxy_route_planning() { + let mut env = HashMap::new(); + env.insert( + "HTTP_PROXY".to_string(), + "http://127.0.0.1:43128".to_string(), + ); + env.insert( + PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), + "exec-token".to_string(), + ); + + let (attribution_token, plan) = extract_attribution_token_and_plan(env); + + assert_eq!(attribution_token.as_deref(), Some("exec-token")); + assert_eq!( + plan, + super::ProxyRoutePlan { + routes: vec![super::PlannedProxyRoute { + env_key: "HTTP_PROXY".to_string(), + endpoint: "127.0.0.1:43128" + .parse::() + .expect("valid socket"), + }], + has_proxy_config: true, + } + ); + } + #[test] fn rewrites_proxy_url_to_local_loopback_port() { let rewritten = diff --git a/codex-rs/network-proxy/src/attribution.rs b/codex-rs/network-proxy/src/attribution.rs new file mode 100644 index 0000000000..f526460fd6 --- /dev/null +++ b/codex-rs/network-proxy/src/attribution.rs @@ -0,0 +1,143 @@ +use crate::state::NetworkProxyState; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsMut; +use rama_tcp::TcpStream; +use std::io; +use std::io::Write; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncReadExt; + +/// Internal handoff from the trusted Linux proxy bridge. +#[doc(hidden)] +pub const PROXY_ATTRIBUTION_TOKEN_ENV_KEY: &str = "CODEX_NETWORK_PROXY_ATTRIBUTION"; + +const ATTRIBUTION_FRAME_MAGIC: &[u8; 8] = b"\0CDXPXY1"; +const MAX_ATTRIBUTION_TOKEN_LEN: usize = 128; +const ATTRIBUTION_FRAME_TIMEOUT: Duration = Duration::from_secs(3); + +pub(crate) struct BindConnectionAttribution { + inner: S, + state: Arc, + environment_id: Option, +} + +impl BindConnectionAttribution { + pub(crate) fn new( + inner: S, + state: Arc, + environment_id: Option, + ) -> Self { + Self { + inner, + state, + environment_id, + } + } +} + +impl Service for BindConnectionAttribution +where + S: Service, + S::Error: Into, +{ + type Output = S::Output; + type Error = BoxError; + + async fn serve(&self, mut stream: TcpStream) -> Result { + let state = match read_attribution_token(&mut stream).await? { + Some(token) => self.state.for_execution_token(&token).ok_or_else(|| { + io::Error::new( + io::ErrorKind::PermissionDenied, + "unknown network proxy attribution token", + ) + })?, + None => self.state.as_ref().clone(), + }; + if let Some(expected_environment_id) = self.environment_id.as_deref() + && state + .environment_id() + .is_some_and(|actual| actual != expected_environment_id) + { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "network proxy attribution environment mismatch", + ) + .into()); + } + stream.extensions_mut().insert(Arc::new(state)); + self.inner.serve(stream).await.map_err(Into::into) + } +} + +async fn read_attribution_token(stream: &mut TcpStream) -> Result, BoxError> { + let mut marker = [0_u8; 1]; + let read = stream.stream.peek(&mut marker).await?; + if read == 0 { + return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "empty proxy connection").into()); + } + if marker[0] != ATTRIBUTION_FRAME_MAGIC[0] { + return Ok(None); + } + + let token = tokio::time::timeout(ATTRIBUTION_FRAME_TIMEOUT, async { + let mut magic = [0_u8; ATTRIBUTION_FRAME_MAGIC.len()]; + stream.read_exact(&mut magic).await?; + if &magic != ATTRIBUTION_FRAME_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid network proxy attribution frame", + )); + } + + let token_len = stream.read_u16().await? as usize; + if token_len == 0 || token_len > MAX_ATTRIBUTION_TOKEN_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid network proxy attribution token length", + )); + } + let mut token = vec![0_u8; token_len]; + stream.read_exact(&mut token).await?; + String::from_utf8(token).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "network proxy attribution token is not UTF-8", + ) + }) + }) + .await + .map_err(|_| { + io::Error::new( + io::ErrorKind::TimedOut, + "network proxy attribution frame timed out", + ) + })??; + + Ok(Some(token)) +} + +/// Writes the trusted bridge preface consumed by the shared proxy ingress. +#[doc(hidden)] +pub fn write_attribution_frame(writer: &mut impl Write, token: &str) -> io::Result<()> { + if token.is_empty() || token.len() > MAX_ATTRIBUTION_TOKEN_LEN { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "invalid network proxy attribution token length", + )); + } + let token_len = u16::try_from(token.len()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "network proxy attribution token is too long", + ) + })?; + writer.write_all(ATTRIBUTION_FRAME_MAGIC)?; + writer.write_all(&token_len.to_be_bytes())?; + writer.write_all(token.as_bytes()) +} + +#[cfg(test)] +#[path = "attribution_tests.rs"] +mod tests; diff --git a/codex-rs/network-proxy/src/attribution_tests.rs b/codex-rs/network-proxy/src/attribution_tests.rs new file mode 100644 index 0000000000..e28110263c --- /dev/null +++ b/codex-rs/network-proxy/src/attribution_tests.rs @@ -0,0 +1,63 @@ +use super::BindConnectionAttribution; +use super::write_attribution_frame; +use crate::config::NetworkProxySettings; +use crate::runtime::network_proxy_state_for_policy; +use crate::state::NetworkProxyState; +use pretty_assertions::assert_eq; +use rama_core::Service; +use rama_core::error::BoxError; +use rama_core::extensions::ExtensionsRef; +use rama_core::service::service_fn; +use rama_tcp::TcpStream as RamaTcpStream; +use std::io; +use std::sync::Arc; +use tokio::io::AsyncWriteExt; +use tokio::net::TcpListener; +use tokio::net::TcpStream; + +#[test] +fn attribution_frame_has_bounded_binary_prefix() -> io::Result<()> { + let mut frame = Vec::new(); + write_attribution_frame(&mut frame, "token-1")?; + + assert_eq!(&frame[..8], b"\0CDXPXY1"); + assert_eq!(u16::from_be_bytes([frame[8], frame[9]]), 7); + assert_eq!(&frame[10..], b"token-1"); + Ok(()) +} + +#[tokio::test] +async fn framed_connection_receives_registered_execution_state() -> Result<(), BoxError> { + let state = Arc::new(network_proxy_state_for_policy( + NetworkProxySettings::default(), + )); + state.register_execution("token-1", "local", "execution-1"); + + let listener = TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + let client = tokio::spawn(async move { + let mut stream = TcpStream::connect(addr).await?; + let mut frame = Vec::new(); + write_attribution_frame(&mut frame, "token-1")?; + stream.write_all(&frame).await + }); + + let (stream, _) = listener.accept().await?; + let service = BindConnectionAttribution::new( + service_fn(|stream: RamaTcpStream| async move { + let state = stream.extensions().get::>().cloned(); + Ok::<_, io::Error>(state) + }), + state, + Some("local".to_string()), + ); + let actual = service + .serve(RamaTcpStream::new(stream)) + .await? + .expect("connection state"); + client.await??; + + assert_eq!(actual.environment_id(), Some("local")); + assert_eq!(actual.execution_id().as_deref(), Some("execution-1")); + Ok(()) +} diff --git a/codex-rs/network-proxy/src/http_proxy.rs b/codex-rs/network-proxy/src/http_proxy.rs index 180ef41c51..59cad9c20a 100644 --- a/codex-rs/network-proxy/src/http_proxy.rs +++ b/codex-rs/network-proxy/src/http_proxy.rs @@ -1,3 +1,4 @@ +use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; use crate::connect_policy::TargetCheckedTcpConnector; use crate::mitm; @@ -39,7 +40,6 @@ use rama_core::error::ErrorExt as _; use rama_core::error::OpaqueError; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; -use rama_core::layer::AddInputExtensionLayer; use rama_core::service::service_fn; use rama_core::stream::Stream; use rama_http::Body; @@ -159,7 +159,11 @@ async fn run_http_proxy_with_listener( info!("HTTP proxy listening on {addr}"); listener - .serve(AddInputExtensionLayer::new(state).into_layer(http_service)) + .serve(BindConnectionAttribution::new( + http_service, + state, + environment_id, + )) .await; Ok(()) } diff --git a/codex-rs/network-proxy/src/lib.rs b/codex-rs/network-proxy/src/lib.rs index 82ab48ba89..6bf6d8eb38 100644 --- a/codex-rs/network-proxy/src/lib.rs +++ b/codex-rs/network-proxy/src/lib.rs @@ -1,5 +1,6 @@ #![deny(clippy::print_stdout, clippy::print_stderr)] +mod attribution; mod certs; mod config; mod connect_policy; @@ -18,6 +19,8 @@ mod socks5; mod state; mod upstream; +pub use attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; +pub use attribution::write_attribution_frame; pub use certs::CUSTOM_CA_ENV_KEYS; pub use certs::is_managed_mitm_ca_trust_bundle_path; pub use config::NetworkDomainPermission; diff --git a/codex-rs/network-proxy/src/network_policy.rs b/codex-rs/network-proxy/src/network_policy.rs index e5792a07c0..956aa6f682 100644 --- a/codex-rs/network-proxy/src/network_policy.rs +++ b/codex-rs/network-proxy/src/network_policy.rs @@ -84,6 +84,7 @@ pub struct NetworkPolicyRequest { pub method: Option, pub command: Option, pub exec_policy_hint: Option, + pub execution_id: Option, } pub struct NetworkPolicyRequestArgs { @@ -118,6 +119,7 @@ impl NetworkPolicyRequest { method, command, exec_policy_hint, + execution_id: None, } } } @@ -199,6 +201,7 @@ fn emit_non_domain_policy_decision_audit_event( args: BlockDecisionAuditEventArgs<'_>, decision: &'static str, ) { + let execution_id = state.execution_id(); emit_policy_audit_event( state, PolicyAuditEventArgs { @@ -211,6 +214,7 @@ fn emit_non_domain_policy_decision_audit_event( server_port: args.server_port, method: args.method, client_addr: args.client_addr, + execution_id: execution_id.as_deref(), policy_override: false, }, ); @@ -226,6 +230,7 @@ struct PolicyAuditEventArgs<'a> { server_port: u16, method: Option<&'a str>, client_addr: Option<&'a str>, + execution_id: Option<&'a str>, policy_override: bool, } @@ -254,6 +259,7 @@ fn emit_policy_audit_event(state: &NetworkProxyState, args: PolicyAuditEventArgs server.port = args.server_port, http.request.method = args.method.unwrap_or(DEFAULT_METHOD), client.address = args.client_addr.unwrap_or(DEFAULT_CLIENT_ADDRESS), + execution.id = args.execution_id, network.policy.override = args.policy_override, ); } @@ -295,12 +301,20 @@ pub(crate) async fn evaluate_host_policy( decider: Option<&Arc>, request: &NetworkPolicyRequest, ) -> Result { + let execution_id = state.execution_id(); let host_decision = state.host_blocked(&request.host, request.port).await?; let (decision, policy_override) = match host_decision { HostBlockDecision::Allowed => (NetworkDecision::Allow, false), HostBlockDecision::Blocked(HostBlockReason::NotAllowed) => { if let Some(decider) = decider { - let decider_decision = map_decider_decision(decider.decide(request.clone()).await); + let mut request = request.clone(); + if request.environment_id.is_none() + && let Some(environment_id) = state.environment_id() + { + request.environment_id = Some(environment_id.to_string()); + } + request.execution_id = execution_id.clone(); + let decider_decision = map_decider_decision(decider.decide(request).await); let policy_override = matches!(decider_decision, NetworkDecision::Allow); (decider_decision, policy_override) } else { @@ -355,6 +369,7 @@ pub(crate) async fn evaluate_host_policy( server_port: request.port, method: request.method.as_deref(), client_addr: request.client_addr.as_deref(), + execution_id: execution_id.as_deref(), policy_override, }, ); @@ -679,6 +694,46 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn evaluate_host_policy_emits_execution_id_for_baseline_allow() { + let state = network_proxy_state_for_policy({ + let mut network = NetworkProxySettings::default(); + network.set_allowed_domains(vec!["example.com".to_string()]); + network + }); + state.register_execution("token-baseline-allow", "local", "execution-baseline-allow"); + let state = state + .for_execution_token("token-baseline-allow") + .expect("expected registered execution"); + let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { + protocol: NetworkProtocol::Http, + host: "example.com".to_string(), + port: 80, + environment_id: None, + client_addr: None, + method: None, + command: None, + exec_policy_hint: None, + }); + + let (decision, events) = capture_events(|| async { + evaluate_host_policy(&state, /*decider*/ None, &request) + .await + .unwrap() + }) + .await; + assert_eq!(decision, NetworkDecision::Allow); + + let event = find_event_by_name(&events, POLICY_DECISION_EVENT_NAME) + .expect("expected policy decision audit event"); + assert_eq!(event.field("network.policy.decision"), Some("allow")); + assert_eq!( + event.field("execution.id"), + Some("execution-baseline-allow") + ); + assert_ne!(event.field("execution.id"), Some("token-baseline-allow")); + } + #[tokio::test(flavor = "current_thread")] async fn evaluate_host_policy_emits_domain_event_for_baseline_deny() { let state = network_proxy_state_for_policy({ @@ -687,6 +742,10 @@ mod tests { network.set_denied_domains(vec!["blocked.com".to_string()]); network }); + state.register_execution("token-baseline-deny", "local", "execution-baseline-deny"); + let state = state + .for_execution_token("token-baseline-deny") + .expect("expected registered execution"); let request = NetworkPolicyRequest::new(NetworkPolicyRequestArgs { protocol: NetworkProtocol::Http, host: "blocked.com".to_string(), @@ -724,6 +783,8 @@ mod tests { assert_eq!(event.field("network.policy.override"), Some("false")); assert_eq!(event.field("http.request.method"), Some("GET")); assert_eq!(event.field("client.address"), Some("127.0.0.1:1234")); + assert_eq!(event.field("execution.id"), Some("execution-baseline-deny")); + assert_ne!(event.field("execution.id"), Some("token-baseline-deny")); } #[tokio::test(flavor = "current_thread")] diff --git a/codex-rs/network-proxy/src/proxy.rs b/codex-rs/network-proxy/src/proxy.rs index a338744b68..476c68aca8 100644 --- a/codex-rs/network-proxy/src/proxy.rs +++ b/codex-rs/network-proxy/src/proxy.rs @@ -1,3 +1,6 @@ +mod execution_scope; + +use crate::attribution::PROXY_ATTRIBUTION_TOKEN_ENV_KEY; use crate::config; use crate::credential_broker::BROKERED_CREDENTIALS_ENV_KEY; use crate::credential_broker::CREDENTIAL_BROKER_ACTIVE_ENV_KEY; @@ -23,6 +26,8 @@ use std::sync::RwLock; use tokio::task::JoinHandle; use tracing::warn; +use self::execution_scope::ExecutionScope; + #[derive(Debug, Clone, Parser)] #[command(name = "codex-network-proxy", about = "Codex network sandbox proxy")] pub struct Args {} @@ -233,6 +238,7 @@ impl NetworkProxyBuilder { reserved_listeners, policy_decider: self.policy_decider, environment_proxies: Arc::new(Mutex::new(HashMap::new())), + execution_scope: None, }) } } @@ -370,6 +376,7 @@ pub struct NetworkProxy { reserved_listeners: Option>, policy_decider: Option>, environment_proxies: Arc>>, + execution_scope: Option>, } impl std::fmt::Debug for NetworkProxy { @@ -424,6 +431,7 @@ pub const PROXY_ENV_KEYS: &[&str] = &[ CREDENTIAL_BROKER_ACTIVE_ENV_KEY, BROKERED_CREDENTIALS_ENV_KEY, ALLOW_LOCAL_BINDING_ENV_KEY, + PROXY_ATTRIBUTION_TOKEN_ENV_KEY, ELECTRON_GET_USE_PROXY_ENV_KEY, NODE_USE_ENV_PROXY_ENV_KEY, "HTTP_PROXY", @@ -697,6 +705,12 @@ impl NetworkProxy { runtime_settings.mitm_ca_trust_bundle.as_ref(), ); self.state.virtualize_child_credentials(&mut env); + if let Some(execution_scope) = self.execution_scope.as_ref() { + env.insert( + PROXY_ATTRIBUTION_TOKEN_ENV_KEY.to_string(), + execution_scope.attribution_token.clone(), + ); + } let mut loopback_ports = [ Some(addrs.http_addr), self.socks_enabled.then_some(addrs.socks_addr), @@ -778,6 +792,14 @@ impl NetworkProxy { } fn environment_proxy_addrs(&self, environment_id: &str) -> Result { + if let Some(execution_scope) = self.execution_scope.as_ref() { + anyhow::ensure!( + execution_scope.environment_id == environment_id, + "execution-scoped network proxy belongs to environment `{}`, not `{environment_id}`", + execution_scope.environment_id + ); + } + let mut proxies = self .environment_proxies .lock() @@ -895,6 +917,10 @@ impl NetworkProxy { } pub async fn run(&self) -> Result { + anyhow::ensure!( + self.execution_scope.is_none(), + "execution-scoped network proxy is already running" + ); let current_cfg = self.state.current_cfg().await?; if !current_cfg.network.enabled { warn!("network.enabled is false; skipping proxy listeners"); diff --git a/codex-rs/network-proxy/src/proxy/execution_scope.rs b/codex-rs/network-proxy/src/proxy/execution_scope.rs new file mode 100644 index 0000000000..37068625d4 --- /dev/null +++ b/codex-rs/network-proxy/src/proxy/execution_scope.rs @@ -0,0 +1,38 @@ +use super::*; + +pub(super) struct ExecutionScope { + pub(super) environment_id: String, + pub(super) attribution_token: String, + state: Arc, +} + +impl Drop for ExecutionScope { + fn drop(&mut self) { + self.state.unregister_execution(&self.attribution_token); + } +} + +impl NetworkProxy { + /// Returns a proxy that attributes trusted bridge connections to one execution. + pub fn for_execution( + &self, + environment_id: &str, + execution_id: &str, + attribution_token: String, + ) -> Result { + anyhow::ensure!( + self.execution_scope.is_none(), + "cannot scope an execution-scoped network proxy" + ); + self.state + .register_execution(&attribution_token, environment_id, execution_id); + + let mut proxy = self.clone(); + proxy.execution_scope = Some(Arc::new(ExecutionScope { + environment_id: environment_id.to_string(), + attribution_token, + state: Arc::clone(&self.state), + })); + Ok(proxy) + } +} diff --git a/codex-rs/network-proxy/src/runtime.rs b/codex-rs/network-proxy/src/runtime.rs index b486ab057a..a51f1d8f16 100644 --- a/codex-rs/network-proxy/src/runtime.rs +++ b/codex-rs/network-proxy/src/runtime.rs @@ -33,6 +33,7 @@ use std::net::SocketAddr; use std::path::Path; use std::pin::Pin; use std::sync::Arc; +use std::sync::Mutex; use std::time::Duration; use time::OffsetDateTime; use tokio::net::lookup_host; @@ -96,6 +97,8 @@ pub struct BlockedRequest { pub method: Option, pub mode: Option, pub protocol: String, + #[serde(skip)] + pub execution_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub decision: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -137,6 +140,7 @@ impl BlockedRequest { method, mode, protocol, + execution_id: None, decision, source, port, @@ -211,6 +215,9 @@ pub struct NetworkProxyState { blocked_request_observer: Arc>>>, credential_broker: CredentialBroker, audit_metadata: NetworkProxyAuditMetadata, + execution_attributions: Arc>>, + environment_id: Option>, + execution_id: Option>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -220,6 +227,12 @@ pub(crate) enum HostMitmRequirement { Always, } +#[derive(Clone)] +struct ExecutionAttribution { + environment_id: String, + execution_id: String, +} + impl std::fmt::Debug for NetworkProxyState { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { // Avoid logging internal state (config contents, derived globsets, etc.) which can be noisy @@ -236,6 +249,9 @@ impl Clone for NetworkProxyState { blocked_request_observer: self.blocked_request_observer.clone(), credential_broker: self.credential_broker.clone(), audit_metadata: self.audit_metadata.clone(), + execution_attributions: self.execution_attributions.clone(), + environment_id: self.environment_id.clone(), + execution_id: self.execution_id.clone(), } } } @@ -287,9 +303,59 @@ impl NetworkProxyState { reloader, blocked_request_observer: Arc::new(RwLock::new(blocked_request_observer)), audit_metadata, + execution_attributions: Arc::new(Mutex::new(HashMap::new())), + environment_id: None, + execution_id: None, } } + pub(crate) fn register_execution( + &self, + attribution_token: &str, + environment_id: &str, + execution_id: &str, + ) { + self.execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + attribution_token.to_string(), + ExecutionAttribution { + environment_id: environment_id.to_string(), + execution_id: execution_id.to_string(), + }, + ); + } + + pub(crate) fn unregister_execution(&self, attribution_token: &str) { + self.execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(attribution_token); + } + + pub(crate) fn for_execution_token(&self, token: &str) -> Option { + let attribution = self + .execution_attributions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .get(token)? + .clone(); + Some(Self { + environment_id: Some(attribution.environment_id.into()), + execution_id: Some(attribution.execution_id.into()), + ..self.clone() + }) + } + + pub(crate) fn environment_id(&self) -> Option<&str> { + self.environment_id.as_deref() + } + + pub(crate) fn execution_id(&self) -> Option { + self.execution_id.as_deref().map(str::to_string) + } + pub async fn set_blocked_request_observer( &self, blocked_request_observer: Option>, @@ -460,8 +526,9 @@ impl NetworkProxyState { } } - pub async fn record_blocked(&self, entry: BlockedRequest) -> Result<()> { + pub async fn record_blocked(&self, mut entry: BlockedRequest) -> Result<()> { self.reload_if_needed().await?; + entry.execution_id = self.execution_id(); let blocked_for_observer = entry.clone(); let blocked_request_observer = self.blocked_request_observer.read().await.clone(); let violation_line = blocked_request_violation_log_line(&entry); @@ -1306,6 +1373,7 @@ mod tests { method: Some("GET".to_string()), mode: Some(NetworkMode::Full), protocol: "http".to_string(), + execution_id: None, decision: Some("ask".to_string()), source: Some("decider".to_string()), port: Some(80), diff --git a/codex-rs/network-proxy/src/socks5.rs b/codex-rs/network-proxy/src/socks5.rs index 9561877043..d1c193198f 100644 --- a/codex-rs/network-proxy/src/socks5.rs +++ b/codex-rs/network-proxy/src/socks5.rs @@ -1,3 +1,4 @@ +use crate::attribution::BindConnectionAttribution; use crate::config::NetworkMode; use crate::connect_policy::TargetCheckedTcpConnector; use crate::mitm; @@ -23,13 +24,11 @@ use crate::state::BlockedRequestArgs; use crate::state::NetworkProxyState; use anyhow::Context as _; use anyhow::Result; -use rama_core::Layer; use rama_core::Service; use rama_core::error::BoxError; use rama_core::extensions::Extensions; use rama_core::extensions::ExtensionsMut; use rama_core::extensions::ExtensionsRef; -use rama_core::layer::AddInputExtensionLayer; use rama_core::service::service_fn; use rama_net::address::HostWithPort; use rama_net::client::EstablishedClientConnection; @@ -165,11 +164,15 @@ async fn run_socks5_with_listener( })); let socks_acceptor = base.with_udp_associator(udp_relay); listener - .serve(AddInputExtensionLayer::new(state).into_layer(socks_acceptor)) + .serve(BindConnectionAttribution::new( + socks_acceptor, + state, + environment_id, + )) .await; } else { listener - .serve(AddInputExtensionLayer::new(state).into_layer(base)) + .serve(BindConnectionAttribution::new(base, state, environment_id)) .await; } Ok(()) From d61ad78abc9b35f59e6d3a7eb2bd6b6ca9338230 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 6 Jul 2026 15:32:24 -0700 Subject: [PATCH 20/76] feat(code-mode): allow disabling V8 JIT (#31303) We want the option to be able to run code-mode in jitless mode. --- codex-rs/code-mode/src/lib.rs | 3 ++ codex-rs/code-mode/src/runtime/mod.rs | 20 +-------- codex-rs/code-mode/src/v8_init.rs | 65 +++++++++++++++++++++++++++ codex-rs/code-mode/tests/jit.rs | 41 +++++++++++++++++ 4 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 codex-rs/code-mode/src/v8_init.rs create mode 100644 codex-rs/code-mode/tests/jit.rs diff --git a/codex-rs/code-mode/src/lib.rs b/codex-rs/code-mode/src/lib.rs index 653d7b1608..6d332bf0c4 100644 --- a/codex-rs/code-mode/src/lib.rs +++ b/codex-rs/code-mode/src/lib.rs @@ -3,6 +3,7 @@ mod remote_session; mod runtime; mod service; mod session_runtime; +mod v8_init; pub(crate) type TaskFailureHandler = std::sync::Arc; @@ -12,3 +13,5 @@ pub use remote_session::ProcessOwnedCodeModeSessionProvider; pub use service::InProcessCodeModeSession; pub use service::InProcessCodeModeSessionProvider; pub use service::NoopCodeModeSessionDelegate; +pub use v8_init::V8JitMode; +pub use v8_init::initialize_v8; diff --git a/codex-rs/code-mode/src/runtime/mod.rs b/codex-rs/code-mode/src/runtime/mod.rs index 0b553123f7..0d32858724 100644 --- a/codex-rs/code-mode/src/runtime/mod.rs +++ b/codex-rs/code-mode/src/runtime/mod.rs @@ -7,7 +7,6 @@ mod value; use std::collections::HashMap; use std::panic::AssertUnwindSafe; use std::panic::catch_unwind; -use std::sync::OnceLock; use std::sync::mpsc as std_mpsc; use std::thread; @@ -21,6 +20,7 @@ use serde_json::Value as JsonValue; use tokio::sync::mpsc; use crate::TaskFailureHandler; +use crate::v8_init::ensure_v8_initialized; const EXIT_SENTINEL: &str = "__codex_code_mode_exit__"; @@ -84,7 +84,7 @@ pub(crate) fn spawn_runtime( ), String, > { - initialize_v8()?; + ensure_v8_initialized()?; let (command_tx, command_rx) = std_mpsc::channel(); let (control_tx, control_rx) = std_mpsc::channel(); @@ -165,22 +165,6 @@ pub(super) enum CompletionState { }, } -fn initialize_v8() -> Result<(), String> { - static PLATFORM: OnceLock, String>> = OnceLock::new(); - - match PLATFORM.get_or_init(|| { - v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA) - .map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?; - let platform = v8::new_default_platform(0, false).make_shared(); - v8::V8::initialize_platform(platform.clone()); - v8::V8::initialize(); - Ok(platform) - }) { - Ok(_) => Ok(()), - Err(error_text) => Err(error_text.clone()), - } -} - fn run_runtime( config: RuntimeConfig, event_tx: mpsc::UnboundedSender, diff --git a/codex-rs/code-mode/src/v8_init.rs b/codex-rs/code-mode/src/v8_init.rs new file mode 100644 index 0000000000..0d00d9fad5 --- /dev/null +++ b/codex-rs/code-mode/src/v8_init.rs @@ -0,0 +1,65 @@ +use std::sync::OnceLock; + +/// Controls whether V8 may generate executable code at runtime. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum V8JitMode { + #[default] + Enabled, + Disabled, +} + +struct V8Initialization { + _platform: v8::SharedRef, + jit_mode: V8JitMode, +} + +static V8_INITIALIZATION: OnceLock> = OnceLock::new(); + +/// Initializes the process-wide V8 platform with the requested JIT mode. +/// +/// Call this before executing any code-mode cells when JIT must be disabled. +/// V8 cannot change JIT mode after initialization, so a later call requesting +/// a different mode returns an error. Code mode initializes V8 with JIT enabled +/// by default when this function has not been called explicitly. +pub fn initialize_v8(jit_mode: V8JitMode) -> Result<(), String> { + match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(jit_mode)) { + Ok(initialization) if initialization.jit_mode == jit_mode => Ok(()), + Ok(initialization) => Err(format!( + "V8 was already initialized with JIT {}", + initialization.jit_mode.description() + )), + Err(error_text) => Err(error_text.clone()), + } +} + +pub(crate) fn ensure_v8_initialized() -> Result<(), String> { + match V8_INITIALIZATION.get_or_init(|| initialize_v8_with_mode(V8JitMode::Enabled)) { + Ok(_) => Ok(()), + Err(error_text) => Err(error_text.clone()), + } +} + +fn initialize_v8_with_mode(jit_mode: V8JitMode) -> Result { + v8::icu::set_common_data_77(deno_core_icudata::ICU_DATA) + .map_err(|error_code| format!("failed to initialize ICU data: {error_code}"))?; + match jit_mode { + V8JitMode::Enabled => {} + V8JitMode::Disabled => v8::V8::set_flags_from_string("--jitless"), + } + let platform = v8::new_default_platform(0, false).make_shared(); + v8::V8::initialize_platform(platform.clone()); + v8::V8::initialize(); + Ok(V8Initialization { + _platform: platform, + jit_mode, + }) +} + +impl V8JitMode { + fn description(self) -> &'static str { + match self { + Self::Enabled => "enabled", + Self::Disabled => "disabled", + } + } +} diff --git a/codex-rs/code-mode/tests/jit.rs b/codex-rs/code-mode/tests/jit.rs new file mode 100644 index 0000000000..0f8e866c47 --- /dev/null +++ b/codex-rs/code-mode/tests/jit.rs @@ -0,0 +1,41 @@ +use codex_code_mode::ExecuteRequest; +use codex_code_mode::InProcessCodeModeSession; +use codex_code_mode::RuntimeResponse; +use codex_code_mode::V8JitMode; +use codex_code_mode::initialize_v8; +use pretty_assertions::assert_eq; + +#[tokio::test] +async fn code_mode_runs_with_jit_disabled() { + initialize_v8(V8JitMode::Disabled).expect("initialize V8 without JIT"); + + let service = InProcessCodeModeSession::new(); + let started = service + .execute(ExecuteRequest { + tool_call_id: "call_1".to_string(), + enabled_tools: Vec::new(), + source: "21 * 2;".to_string(), + yield_time_ms: None, + max_output_tokens: None, + }) + .await + .expect("start code-mode cell"); + let cell_id = started.cell_id.clone(); + let response = started + .initial_response() + .await + .expect("execute code-mode cell"); + + assert_eq!( + response, + RuntimeResponse::Result { + cell_id, + content_items: Vec::new(), + error_text: None, + } + ); + assert_eq!( + initialize_v8(V8JitMode::Enabled), + Err("V8 was already initialized with JIT disabled".to_string()) + ); +} From 9e249964727c2aaf5327d1a4f52030ce4b61228a Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 16:12:07 -0700 Subject: [PATCH 21/76] chore: use .worktreeinclude for user Bazel config (#31271) ## Why Codex worktrees need to inherit the ignored `user.bazelrc` file so Bazel keeps using local developer settings. `.worktreeinclude` expresses that copy declaratively, so the dedicated setup script is no longer needed. ## What - add `user.bazelrc` to `.worktreeinclude` - remove the Python copy script and its environment setup hook ## Validation - `git diff --check` - confirmed `git check-ignore -v user.bazelrc` matches `.gitignore` --- .codex/environments/environment.toml | 3 -- .codex/environments/setup.py | 65 ---------------------------- .worktreeinclude | 1 + 3 files changed, 1 insertion(+), 68 deletions(-) delete mode 100644 .codex/environments/setup.py create mode 100644 .worktreeinclude diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index 39c40eb07f..b3c5910fbc 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -2,9 +2,6 @@ version = 1 name = "codex" -[setup] -script = "python ./.codex/environments/setup.py" - [[actions]] name = "Run" icon = "run" diff --git a/.codex/environments/setup.py b/.codex/environments/setup.py deleted file mode 100644 index a9d78c9405..0000000000 --- a/.codex/environments/setup.py +++ /dev/null @@ -1,65 +0,0 @@ -#!/usr/bin/env python3 - -"""Set up ignored files that should be shared with Codex worktrees.""" - -import shutil -import subprocess -from functools import cache -from pathlib import Path - - -@cache -def worktree_paths() -> tuple[Path, Path]: - script_dir = Path(__file__).resolve().parent - worktree_root = git_path(script_dir / "../..", "--show-toplevel") - common_git_dir = git_path(worktree_root, "--git-common-dir") - return worktree_root, common_git_dir.parent - - -def git_path(working_directory: Path, argument: str) -> Path: - output = subprocess.check_output( - [ - "git", - "-C", - str(working_directory), - "rev-parse", - "--path-format=absolute", - argument, - ], - text=True, - ) - return Path(output.strip()) - - -def copy_from_main_worktree_to_worktree(repo_relative_path: str) -> None: - relative_path = Path(repo_relative_path) - if relative_path.is_absolute() or ".." in relative_path.parts: - raise ValueError(f"path must be repository-relative: {repo_relative_path}") - - worktree_root, main_worktree = worktree_paths() - source_path = main_worktree / relative_path - destination_path = worktree_root / relative_path - - print(f" source: {source_path}") - print(f" destination: {destination_path}") - - if source_path == destination_path: - print(" result: running in the main worktree; nothing to copy") - elif destination_path.exists(): - print(" result: destination already exists; nothing to copy") - elif not source_path.is_file(): - print(" result: source does not exist; nothing to copy") - else: - destination_path.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(source_path, destination_path) - print(f" result: copied {repo_relative_path}") - - -def main() -> None: - print("Codex environment setup:") - # See codex-rs/docs/bazel.md for the repository's Bazel workflow. - copy_from_main_worktree_to_worktree("user.bazelrc") - - -if __name__ == "__main__": - main() diff --git a/.worktreeinclude b/.worktreeinclude new file mode 100644 index 0000000000..74824b01f9 --- /dev/null +++ b/.worktreeinclude @@ -0,0 +1 @@ +user.bazelrc From 9732b406e7976ed05d170623bc6ac4b4498a68e9 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 6 Jul 2026 16:26:56 -0700 Subject: [PATCH 22/76] fix: update crossbeam-epoch for RUSTSEC-2026-0204 (#31308) Bump our crossbeam-epoch dep to fix cargo-deny. --- MODULE.bazel.lock | 2 +- codex-rs/Cargo.lock | 5 +++-- codex-rs/code-mode-host/Cargo.toml | 1 + codex-rs/code-mode-host/tests/stdio.rs | 25 ++++++++++++------------- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index d572f68f21..3356b66c14 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -804,7 +804,7 @@ "critical-section_1.2.0": "{\"dependencies\":[],\"features\":{\"restore-state-bool\":[],\"restore-state-none\":[],\"restore-state-u16\":[],\"restore-state-u32\":[],\"restore-state-u64\":[],\"restore-state-u8\":[],\"restore-state-usize\":[],\"std\":[\"restore-state-bool\"]}}", "crossbeam-channel_0.5.15": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"num_cpus\",\"req\":\"^1.13.0\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"signal-hook\",\"req\":\"^0.3\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-utils/std\"]}}", "crossbeam-deque_0.8.6": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-epoch\",\"req\":\"^0.9.17\"},{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"crossbeam-epoch/std\",\"crossbeam-utils/std\"]}}", - "crossbeam-epoch_0.9.18": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}", + "crossbeam-epoch_0.9.20": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"name\":\"loom-crate\",\"optional\":true,\"package\":\"loom\",\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"loom\":[\"loom-crate\",\"crossbeam-utils/loom\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}", "crossbeam-queue_0.3.12": "{\"dependencies\":[{\"default_features\":false,\"name\":\"crossbeam-utils\",\"req\":\"^0.8.18\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"alloc\":[],\"default\":[\"std\"],\"nightly\":[\"crossbeam-utils/nightly\"],\"std\":[\"alloc\",\"crossbeam-utils/std\"]}}", "crossbeam-utils_0.8.21": "{\"dependencies\":[{\"name\":\"loom\",\"optional\":true,\"req\":\"^0.7.1\",\"target\":\"cfg(crossbeam_loom)\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"default\":[\"std\"],\"nightly\":[],\"std\":[]}}", "crossterm_winapi_0.9.1": "{\"dependencies\":[{\"features\":[\"winbase\",\"consoleapi\",\"processenv\",\"handleapi\",\"synchapi\",\"impl-default\"],\"name\":\"winapi\",\"req\":\"^0.3.8\",\"target\":\"cfg(windows)\"}],\"features\":{}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 89d27d0ec0..c0ff4a585a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2526,6 +2526,7 @@ dependencies = [ "codex-utils-cargo-bin", "pretty_assertions", "serde_json", + "tempfile", "tokio", "tokio-util", ] @@ -4822,9 +4823,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/codex-rs/code-mode-host/Cargo.toml b/codex-rs/code-mode-host/Cargo.toml index 90b043b097..86a36d2a27 100644 --- a/codex-rs/code-mode-host/Cargo.toml +++ b/codex-rs/code-mode-host/Cargo.toml @@ -28,3 +28,4 @@ codex-protocol = { workspace = true } codex-utils-cargo-bin = { workspace = true } pretty_assertions = { workspace = true } serde_json = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/code-mode-host/tests/stdio.rs b/codex-rs/code-mode-host/tests/stdio.rs index e7b7506163..8edcbcaa8d 100644 --- a/codex-rs/code-mode-host/tests/stdio.rs +++ b/codex-rs/code-mode-host/tests/stdio.rs @@ -504,13 +504,17 @@ return; next_callback_event(&mut events_rx).await, CallbackEvent::Started("tool_call_slow".to_string()) ); - assert_eq!( + let mut closure_events = vec![ next_callback_event(&mut events_rx).await, - CallbackEvent::Cancelled("tool_call_slow".to_string()) - ); - assert_eq!( next_callback_event(&mut events_rx).await, - CallbackEvent::CellClosed(running_cell_id.clone()) + ]; + closure_events.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}"))); + assert_eq!( + closure_events, + vec![ + CallbackEvent::Cancelled("tool_call_slow".to_string()), + CallbackEvent::CellClosed(running_cell_id.clone()), + ] ); assert_eq!( wait_task @@ -677,12 +681,9 @@ async fn oversized_initial_response_does_not_close_the_shared_host() { async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() { let host_program = codex_utils_cargo_bin::cargo_bin("codex-code-mode-host").expect("host binary"); - let proxy_dir = - std::env::temp_dir().join(format!("codex-code-mode-host-loss-{}", std::process::id())); - let proxy_program = proxy_dir.join("host-proxy.sh"); - let pid_path = proxy_dir.join("host.pid"); - let _ = std::fs::remove_dir_all(&proxy_dir); - std::fs::create_dir_all(&proxy_dir).expect("create host proxy directory"); + let proxy_dir = tempfile::tempdir().expect("create host proxy directory"); + let proxy_program = proxy_dir.path().join("host-proxy.sh"); + let pid_path = proxy_dir.path().join("host.pid"); std::fs::write( &proxy_program, format!( @@ -869,6 +870,4 @@ async fn child_process_loss_cleans_up_and_rebuilds_the_shared_host() { events_a.try_recv(), Err(mpsc::error::TryRecvError::Empty) )); - - std::fs::remove_dir_all(proxy_dir).expect("remove host proxy directory"); } From 3f6157004419e21547962670026c6f6001d06fe8 Mon Sep 17 00:00:00 2001 From: Channing Conger Date: Mon, 6 Jul 2026 16:58:05 -0700 Subject: [PATCH 23/76] [codex] bundle code mode host in release packages (#30202) ## Summary - build, strip, sign, and publish `codex-code-mode-host` with the primary Codex release binaries on Linux, macOS, and Windows - place the host beside `codex[.exe]` in canonical package archives, macOS DMGs, and the legacy Linux bundle so the runtime's sibling lookup succeeds - preserve and validate the host through standalone installers and Python runtime wheel staging - add package-builder coverage for source selection and the resulting package layout ## Why The process-owned code-mode client launches `codex-code-mode-host` as a sibling of the running Codex executable. Release artifacts currently build and bundle `codex` without that host, so code mode cannot start from installed packages. --- .../scripts/build-codex-package-archive.sh | 12 ++++ .github/workflows/rust-release-windows.yml | 10 +-- .github/workflows/rust-release.yml | 65 ++++++++++++------- scripts/codex_package/README.md | 7 +- scripts/codex_package/cargo.py | 14 +++- scripts/codex_package/cli.py | 14 ++++ scripts/codex_package/layout.py | 6 ++ scripts/codex_package/targets.py | 1 + scripts/codex_package/test_cargo.py | 21 ++++++ scripts/codex_package/test_layout.py | 56 ++++++++++++++++ scripts/install/install.ps1 | 1 + scripts/install/install.sh | 6 +- sdk/python/scripts/update_sdk_artifacts.py | 7 ++ .../test_artifact_workflow_and_binaries.py | 3 + 14 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 scripts/codex_package/test_layout.py diff --git a/.github/scripts/build-codex-package-archive.sh b/.github/scripts/build-codex-package-archive.sh index 229213cc53..4592e78ce5 100644 --- a/.github/scripts/build-codex-package-archive.sh +++ b/.github/scripts/build-codex-package-archive.sh @@ -9,6 +9,7 @@ Usage: build-codex-package-archive.sh \ --entrypoint-dir \ --archive-dir \ [--bwrap-bin ] \ + [--code-mode-host-bin ] \ [--zsh-manifest ] \ [--codex-command-runner-bin ] \ [--codex-windows-sandbox-setup-bin ] \ @@ -23,6 +24,7 @@ archive_dir="" target_suffixed_entrypoint="false" resource_args=() bwrap_bin_provided="false" +code_mode_host_bin_provided="false" command_runner_bin_provided="false" sandbox_setup_bin_provided="false" @@ -49,6 +51,11 @@ while [[ $# -gt 0 ]]; do bwrap_bin_provided="true" shift 2 ;; + --code-mode-host-bin) + resource_args+=(--code-mode-host-bin "${2:?--code-mode-host-bin requires a value}") + code_mode_host_bin_provided="true" + shift 2 + ;; --zsh-manifest) resource_args+=(--zsh-manifest "${2:?--zsh-manifest requires a value}") shift 2 @@ -114,6 +121,11 @@ case "$target" in ;; esac +code_mode_host_bin="${entrypoint_dir%/}/codex-code-mode-host${exe_suffix}" +if [[ "$code_mode_host_bin_provided" == "false" && -f "$code_mode_host_bin" ]]; then + resource_args+=(--code-mode-host-bin "$code_mode_host_bin") +fi + entrypoint_name="$entrypoint" if [[ "$target_suffixed_entrypoint" == "true" ]]; then entrypoint_name="${entrypoint_name}-${target}" diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 7069b6ca8f..31d832fcbe 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -7,7 +7,7 @@ on: # nested submodules. Prefer the system git CLI across every Cargo invocation. env: CARGO_NET_GIT_FETCH_WITH_CLI: "true" - WINDOWS_BINARIES: "codex codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server" + WINDOWS_BINARIES: "codex codex-code-mode-host codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server" jobs: build-windows-binaries: @@ -28,14 +28,14 @@ jobs: - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: primary - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" runs_on: group: ${{ github.event.repository.name }}-runners labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: primary - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" runs_on: group: ${{ github.event.repository.name }}-runners labels: ${{ github.event.repository.name }}-windows-arm64 @@ -56,14 +56,14 @@ jobs: - runner: windows-x64 target: x86_64-pc-windows-msvc bundle: app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" runs_on: group: ${{ github.event.repository.name }}-runners labels: ${{ github.event.repository.name }}-windows-x64 - runner: windows-arm64 target: aarch64-pc-windows-msvc bundle: app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" runs_on: group: ${{ github.event.repository.name }}-runners labels: ${{ github.event.repository.name }}-windows-arm64 diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 49d24abc18..93f03e63d1 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -83,50 +83,50 @@ jobs: target: aarch64-apple-darwin bundle: primary artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" build_dmg: "true" - runner: macos-15-xlarge target: aarch64-apple-darwin bundle: app-server artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" - runner: macos-15-xlarge target: x86_64-apple-darwin bundle: primary artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" build_dmg: "true" - runner: macos-15-xlarge target: x86_64-apple-darwin bundle: app-server artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" # Release artifacts intentionally ship MUSL-linked Linux binaries. - runner: ${{ github.event.repository.name }}-linux-x64-xl target: x86_64-unknown-linux-musl bundle: primary artifact_name: x86_64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" + binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ${{ github.event.repository.name }}-linux-x64-xl target: x86_64-unknown-linux-musl bundle: app-server artifact_name: x86_64-unknown-linux-musl-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" - runner: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-musl bundle: primary artifact_name: aarch64-unknown-linux-musl - binaries: "codex codex-responses-api-proxy bwrap" + binaries: "codex codex-code-mode-host codex-responses-api-proxy bwrap" build_dmg: "false" - runner: ${{ github.event.repository.name }}-linux-arm64 target: aarch64-unknown-linux-musl bundle: app-server artifact_name: aarch64-unknown-linux-musl-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" steps: @@ -335,6 +335,11 @@ jobs: mkdir -p "$dest" for binary in ${{ matrix.binaries }}; do + # Both variants package the host, but only the primary bundle publishes + # standalone binary archives to avoid duplicate release asset names. + if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then + continue + fi cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" if [[ "${{ matrix.target }}" == *linux* ]]; then cp "target/${{ matrix.target }}/release/${binary}.sigstore" \ @@ -347,9 +352,13 @@ jobs: rm -rf "$bundle_root" mkdir -p "$bundle_root/codex-resources" cp "$dest/codex-${{ matrix.target }}" "$bundle_root/codex" + cp "$dest/codex-code-mode-host-${{ matrix.target }}" "$bundle_root/codex-code-mode-host" cp "$dest/bwrap-${{ matrix.target }}" "$bundle_root/codex-resources/bwrap" - chmod 0755 "$bundle_root/codex" "$bundle_root/codex-resources/bwrap" - tar -C "$bundle_root" -cf - codex codex-resources/bwrap | + chmod 0755 \ + "$bundle_root/codex" \ + "$bundle_root/codex-code-mode-host" \ + "$bundle_root/codex-resources/bwrap" + tar -C "$bundle_root" -cf - codex codex-code-mode-host codex-resources/bwrap | zstd -T0 -19 -o "$dest/codex-${{ matrix.target }}-bundle.tar.zst" fi @@ -497,19 +506,19 @@ jobs: - target: aarch64-apple-darwin bundle: primary artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" - target: aarch64-apple-darwin bundle: app-server artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" - target: x86_64-apple-darwin bundle: primary artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" - target: x86_64-apple-darwin bundle: app-server artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -615,22 +624,22 @@ jobs: - target: aarch64-apple-darwin bundle: primary artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" build_dmg: "true" - target: aarch64-apple-darwin bundle: app-server artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" - target: x86_64-apple-darwin bundle: primary artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" build_dmg: "true" - target: x86_64-apple-darwin bundle: app-server artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" build_dmg: "false" steps: @@ -707,6 +716,11 @@ jobs: mkdir -p "$dest" for binary in ${{ matrix.binaries }}; do + # Both variants package the host, but only the primary bundle publishes + # standalone binary archives to avoid duplicate release asset names. + if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then + continue + fi cp "target/${{ matrix.target }}/release/${binary}" "$dest/${binary}-${{ matrix.target }}" done @@ -911,22 +925,22 @@ jobs: - target: aarch64-apple-darwin bundle: primary artifact_name: aarch64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" verify_dmg: "true" - target: aarch64-apple-darwin bundle: app-server artifact_name: aarch64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" verify_dmg: "false" - target: x86_64-apple-darwin bundle: primary artifact_name: x86_64-apple-darwin - binaries: "codex codex-responses-api-proxy" + binaries: "codex codex-code-mode-host codex-responses-api-proxy" verify_dmg: "true" - target: x86_64-apple-darwin bundle: app-server artifact_name: x86_64-apple-darwin-app-server - binaries: "codex-app-server" + binaries: "codex-app-server codex-code-mode-host" verify_dmg: "false" steps: @@ -983,6 +997,12 @@ jobs: binary_path="${RUNNER_TEMP}/signed-binaries/${binary}" verify_signed_binary "$binary_path" + # The app-server package contains the host, but its standalone archives + # are omitted above to avoid duplicate release asset names. + if [[ "${{ matrix.bundle }}" == "app-server" && "$binary" == "codex-code-mode-host" ]]; then + continue + fi + direct_archive_dir="${RUNNER_TEMP}/direct-archive-${binary}-${target}" rm -rf "$direct_archive_dir" mkdir -p "$direct_archive_dir" @@ -1014,6 +1034,7 @@ jobs: mkdir -p "$package_dir" tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir" verify_signed_binary "${package_dir}/bin/${package_entrypoint}" + verify_signed_binary "${package_dir}/bin/codex-code-mode-host" if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then exit 0 diff --git a/scripts/codex_package/README.md b/scripts/codex_package/README.md index b76c18c3c2..7850bbd354 100644 --- a/scripts/codex_package/README.md +++ b/scripts/codex_package/README.md @@ -10,7 +10,8 @@ The builder creates a canonical Codex package directory: . ├── codex-package.json ├── bin -│ └── [.exe] +│ ├── [.exe] +│ └── codex-code-mode-host[.exe] ├── codex-resources │ ├── bwrap # Linux only │ ├── zsh/bin/zsh # supported Unix targets only @@ -40,6 +41,7 @@ grouped `cargo build` command per package when they are needed and no prebuilt override was provided: - all targets: the selected entrypoint, unless `--entrypoint-bin` is provided +- all targets: `codex-code-mode-host`, unless `--code-mode-host-bin` is provided - Linux targets: `bwrap`, unless `--bwrap-bin` is provided - Windows targets: `codex-command-runner` and `codex-windows-sandbox-setup`, unless the corresponding prebuilt helper flags are provided @@ -50,6 +52,9 @@ explicit target. Release jobs that already built and signed/notarized the entrypoint should pass `--entrypoint-bin` so the package contains that exact binary instead of rebuilding it. +Release jobs should likewise pass `--code-mode-host-bin` so the package contains +the signed host executable beside the signed entrypoint. + Release jobs that already built package resource binaries should also pass the corresponding resource flags: `--bwrap-bin` for Linux packages, and `--codex-command-runner-bin` plus `--codex-windows-sandbox-setup-bin` for diff --git a/scripts/codex_package/cargo.py b/scripts/codex_package/cargo.py index 208d85d174..f759e44762 100644 --- a/scripts/codex_package/cargo.py +++ b/scripts/codex_package/cargo.py @@ -17,6 +17,7 @@ CODEX_RS_ROOT = REPO_ROOT / "codex-rs" @dataclass(frozen=True) class SourceBuildOutputs: entrypoint_bin: Path + code_mode_host_bin: Path bwrap_bin: Path | None codex_command_runner_bin: Path | None codex_windows_sandbox_setup_bin: Path | None @@ -29,6 +30,7 @@ def build_source_binaries( cargo: str, profile: str, entrypoint_bin: Path | None, + code_mode_host_bin: Path | None, bwrap_bin: Path | None, codex_command_runner_bin: Path | None, codex_windows_sandbox_setup_bin: Path | None, @@ -43,6 +45,7 @@ def build_source_binaries( spec, variant, build_entrypoint=entrypoint_bin is None, + build_code_mode_host=code_mode_host_bin is None, build_bwrap=spec.is_linux and bwrap_bin is None, build_codex_command_runner=spec.is_windows and codex_command_runner_bin is None, build_codex_windows_sandbox_setup=spec.is_windows @@ -61,7 +64,7 @@ def build_source_binaries( cmd.extend(["--bin", binary]) cargo_env = None - if entrypoint_bin is None: + if entrypoint_bin is None or code_mode_host_bin is None: codex_v8_env = resolve_codex_v8_cargo_env(spec) if codex_v8_env: cargo_env = {**os.environ, **codex_v8_env} @@ -80,6 +83,11 @@ def build_source_binaries( entrypoint_bin, output_dir / variant.entrypoint_name(spec), ), + code_mode_host_bin=( + code_mode_host_bin.resolve() + if code_mode_host_bin is not None + else output_dir / f"codex-code-mode-host{spec.exe_suffix}" + ), bwrap_bin=resolve_output_path( bwrap_bin, output_dir / "bwrap" if spec.is_linux else None, @@ -102,6 +110,7 @@ def source_binaries_for_target( variant: PackageVariant, *, build_entrypoint: bool, + build_code_mode_host: bool, build_bwrap: bool, build_codex_command_runner: bool, build_codex_windows_sandbox_setup: bool, @@ -109,6 +118,8 @@ def source_binaries_for_target( binaries = [] if build_entrypoint: binaries.append(variant.cargo_bin) + if build_code_mode_host: + binaries.append("codex-code-mode-host") if build_bwrap: binaries.append("bwrap") if build_codex_command_runner: @@ -174,6 +185,7 @@ def cargo_profile_dirname(profile: str) -> str: def validate_source_outputs(outputs: SourceBuildOutputs) -> None: for path in [ outputs.entrypoint_bin, + outputs.code_mode_host_bin, outputs.bwrap_bin, outputs.codex_command_runner_bin, outputs.codex_windows_sandbox_setup_bin, diff --git a/scripts/codex_package/cli.py b/scripts/codex_package/cli.py index 1dd276827e..f461d0df43 100644 --- a/scripts/codex_package/cli.py +++ b/scripts/codex_package/cli.py @@ -82,6 +82,14 @@ def parse_args() -> argparse.Namespace: "variant. If omitted, the entrypoint is built with Cargo." ), ) + parser.add_argument( + "--code-mode-host-bin", + type=Path, + help=( + "Optional prebuilt codex-code-mode-host executable. If omitted, " + "the host is built with Cargo." + ), + ) parser.add_argument( "--bwrap-bin", type=Path, @@ -148,6 +156,11 @@ def main() -> int: "prebuilt entrypoint executable", "--entrypoint-bin", ), + code_mode_host_bin=resolve_optional_input_path( + args.code_mode_host_bin, + "prebuilt code-mode host executable", + "--code-mode-host-bin", + ), bwrap_bin=resolve_optional_input_path( args.bwrap_bin, "prebuilt Linux bwrap executable", @@ -167,6 +180,7 @@ def main() -> int: version = read_workspace_version() inputs = PackageInputs( entrypoint_bin=source_outputs.entrypoint_bin, + code_mode_host_bin=source_outputs.code_mode_host_bin, rg_bin=resolve_rg_bin(spec, args.rg_bin), zsh_bin=resolve_zsh_bin(spec, args.zsh_manifest), bwrap_bin=source_outputs.bwrap_bin, diff --git a/scripts/codex_package/layout.py b/scripts/codex_package/layout.py index 63598672ea..f4fef6912c 100644 --- a/scripts/codex_package/layout.py +++ b/scripts/codex_package/layout.py @@ -51,6 +51,11 @@ def build_package_dir( bin_dir / entrypoint_name, is_windows=spec.is_windows, ) + copy_executable( + inputs.code_mode_host_bin, + bin_dir / f"codex-code-mode-host{spec.exe_suffix}", + is_windows=spec.is_windows, + ) copy_executable(inputs.rg_bin, path_dir / spec.rg_name, is_windows=spec.is_windows) if inputs.zsh_bin is not None: @@ -130,6 +135,7 @@ def validate_package_dir( required_files = [ Path("bin") / variant.entrypoint_name(spec), + Path("bin") / f"codex-code-mode-host{spec.exe_suffix}", Path("codex-path") / spec.rg_name, ] executable_files = list(required_files) diff --git a/scripts/codex_package/targets.py b/scripts/codex_package/targets.py index 8307a3e630..016202cf5f 100644 --- a/scripts/codex_package/targets.py +++ b/scripts/codex_package/targets.py @@ -39,6 +39,7 @@ class PackageVariant: @dataclass(frozen=True) class PackageInputs: entrypoint_bin: Path + code_mode_host_bin: Path rg_bin: Path zsh_bin: Path | None bwrap_bin: Path | None diff --git a/scripts/codex_package/test_cargo.py b/scripts/codex_package/test_cargo.py index 6185f8b8b3..1a4a572b9b 100644 --- a/scripts/codex_package/test_cargo.py +++ b/scripts/codex_package/test_cargo.py @@ -20,6 +20,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["aarch64-apple-darwin"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=False, build_codex_windows_sandbox_setup=False, @@ -35,6 +36,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["x86_64-unknown-linux-musl"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=False, build_codex_windows_sandbox_setup=False, @@ -50,6 +52,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["x86_64-pc-windows-msvc"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=False, build_codex_windows_sandbox_setup=False, @@ -63,6 +66,7 @@ class SourceBinariesForTargetTest(unittest.TestCase): TARGET_SPECS["x86_64-pc-windows-msvc"], PACKAGE_VARIANTS["codex"], build_entrypoint=False, + build_code_mode_host=False, build_bwrap=False, build_codex_command_runner=True, build_codex_windows_sandbox_setup=True, @@ -70,10 +74,25 @@ class SourceBinariesForTargetTest(unittest.TestCase): ["codex-command-runner", "codex-windows-sandbox-setup"], ) + def test_missing_code_mode_host_is_built_for_app_server(self) -> None: + self.assertEqual( + source_binaries_for_target( + TARGET_SPECS["aarch64-apple-darwin"], + PACKAGE_VARIANTS["codex-app-server"], + build_entrypoint=False, + build_code_mode_host=True, + build_bwrap=False, + build_codex_command_runner=False, + build_codex_windows_sandbox_setup=False, + ), + ["codex-code-mode-host"], + ) + def test_build_uses_prebuilt_windows_helpers_without_running_cargo(self) -> None: with tempfile.TemporaryDirectory() as temp_dir: root = Path(temp_dir) entrypoint = touch_file(root / "codex.exe") + code_mode_host = touch_file(root / "codex-code-mode-host.exe") command_runner = touch_file(root / "codex-command-runner.exe") sandbox_setup = touch_file(root / "codex-windows-sandbox-setup.exe") @@ -83,12 +102,14 @@ class SourceBinariesForTargetTest(unittest.TestCase): cargo=str(root / "cargo-that-should-not-run"), profile="release", entrypoint_bin=entrypoint, + code_mode_host_bin=code_mode_host, bwrap_bin=None, codex_command_runner_bin=command_runner, codex_windows_sandbox_setup_bin=sandbox_setup, ) self.assertEqual(outputs.entrypoint_bin, entrypoint) + self.assertEqual(outputs.code_mode_host_bin, code_mode_host) self.assertEqual(outputs.codex_command_runner_bin, command_runner) self.assertEqual(outputs.codex_windows_sandbox_setup_bin, sandbox_setup) diff --git a/scripts/codex_package/test_layout.py b/scripts/codex_package/test_layout.py new file mode 100644 index 0000000000..157bce5e9c --- /dev/null +++ b/scripts/codex_package/test_layout.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +from pathlib import Path +import sys +import tempfile +import unittest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from codex_package.layout import build_package_dir +from codex_package.layout import validate_package_dir +from codex_package.targets import PACKAGE_VARIANTS +from codex_package.targets import PackageInputs +from codex_package.targets import TARGET_SPECS + + +class PackageLayoutTest(unittest.TestCase): + def test_app_server_package_places_code_mode_host_beside_entrypoint(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + root = Path(temp_dir) + package_dir = root / "package" + package_dir.mkdir() + inputs = PackageInputs( + entrypoint_bin=touch_executable(root / "codex-app-server"), + code_mode_host_bin=touch_executable(root / "codex-code-mode-host"), + rg_bin=touch_executable(root / "rg"), + zsh_bin=None, + bwrap_bin=touch_executable(root / "bwrap"), + codex_command_runner_bin=None, + codex_windows_sandbox_setup_bin=None, + ) + + build_package_dir( + package_dir, + "1.2.3", + PACKAGE_VARIANTS["codex-app-server"], + TARGET_SPECS["x86_64-unknown-linux-musl"], + inputs, + ) + validate_package_dir( + package_dir, + PACKAGE_VARIANTS["codex-app-server"], + TARGET_SPECS["x86_64-unknown-linux-musl"], + include_zsh=False, + ) + + self.assertTrue((package_dir / "bin" / "codex-code-mode-host").is_file()) + + +def touch_executable(path: Path) -> Path: + path.touch(mode=0o755) + return path + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/install/install.ps1 b/scripts/install/install.ps1 index 986f6c6566..e27d34b223 100644 --- a/scripts/install/install.ps1 +++ b/scripts/install/install.ps1 @@ -538,6 +538,7 @@ function Test-PackageContentsAreComplete { $expectedFiles = @( "codex-package.json", "bin\codex.exe", + "bin\codex-code-mode-host.exe", "codex-path\rg.exe", "codex-resources\codex-command-runner.exe", "codex-resources\codex-windows-sandbox-setup.exe" diff --git a/scripts/install/install.sh b/scripts/install/install.sh index d44b353860..12b66f958a 100755 --- a/scripts/install/install.sh +++ b/scripts/install/install.sh @@ -680,7 +680,10 @@ install_package_release() { rm -rf "$stage_release" mkdir -p "$stage_release" tar -xzf "$archive_path" -C "$stage_release" - chmod 0755 "$stage_release/bin/codex" "$stage_release/codex-path/rg" + chmod 0755 \ + "$stage_release/bin/codex" \ + "$stage_release/bin/codex-code-mode-host" \ + "$stage_release/codex-path/rg" if [ -f "$stage_release/codex-resources/bwrap" ]; then chmod 0755 "$stage_release/codex-resources/bwrap" fi @@ -733,6 +736,7 @@ release_dir_is_complete() { package) [ -f "$release_dir/codex-package.json" ] && [ -x "$release_dir/bin/codex" ] && + [ -x "$release_dir/bin/codex-code-mode-host" ] && [ -x "$release_dir/codex" ] && [ -x "$release_dir/codex-path/rg" ] || return 1 diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index f3b97c6b6f..1c6ec59402 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -53,6 +53,10 @@ def runtime_binary_name() -> str: return "codex.exe" if _is_windows() else "codex" +def runtime_code_mode_host_name() -> str: + return "codex-code-mode-host.exe" if _is_windows() else "codex-code-mode-host" + + def staged_runtime_package_root(root: Path) -> Path: return root / RUNTIME_PACKAGE_ROOT @@ -276,6 +280,9 @@ def _validate_codex_package_layout(package_dir: Path, package_archive: Path) -> package_binary = package_dir / "bin" / runtime_binary_name() if not package_binary.is_file(): missing_entries.append(str(Path("bin") / runtime_binary_name())) + code_mode_host = package_dir / "bin" / runtime_code_mode_host_name() + if not code_mode_host.is_file(): + missing_entries.append(str(Path("bin") / runtime_code_mode_host_name())) if missing_entries: missing = ", ".join(missing_entries) raise RuntimeError(f"Missing Codex package layout entries in {package_archive}: {missing}") diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 301a75abac..ad1262e836 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -57,6 +57,7 @@ def _write_fake_codex_package(package_dir: Path, script) -> Path: (package_dir / "codex-path").mkdir() (package_dir / "codex-package.json").write_text('{"variant":"codex"}\n') (package_dir / "bin" / script.runtime_binary_name()).write_text("fake codex\n") + (package_dir / "bin" / script.runtime_code_mode_host_name()).write_text("fake code mode host\n") (package_dir / "codex-resources" / "bwrap").write_text("fake bwrap\n") (package_dir / "codex-path" / "rg").write_text("fake rg\n") return package_dir @@ -682,11 +683,13 @@ def test_stage_runtime_release_copies_package_layout_and_sets_version( assert { "metadata": (package_root / "codex-package.json").read_text(), "codex": (package_root / "bin" / script.runtime_binary_name()).read_text(), + "code_mode_host": (package_root / "bin" / script.runtime_code_mode_host_name()).read_text(), "bwrap": (package_root / "codex-resources" / "bwrap").read_text(), "rg": (package_root / "codex-path" / "rg").read_text(), } == { "metadata": '{"variant":"codex"}\n', "codex": "fake codex\n", + "code_mode_host": "fake code mode host\n", "bwrap": "fake bwrap\n", "rg": "fake rg\n", } From c71895f63b44f439e10a0f09dc5dadba3e976de9 Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Mon, 6 Jul 2026 17:12:39 -0700 Subject: [PATCH 24/76] [codex] app-server: expose plugin install policy source (#31293) ## Summary - Add `PluginInstallPolicySource` to the app-server v2 `PluginSummary` payload. - Propagate remote `installation_policy_source` through plugin summaries, details, installed-plugin caching, and app-server responses. - Preserve the supported source values and map unrecognized backend values to `null`. - Return `null` for local plugins and expose the field in generated JSON and TypeScript schemas. - Update app-server API documentation and plugin list/read coverage. ## Testing - `just test -p codex-app-server-protocol` (251 passed) - Targeted `codex-app-server` plugin list, installed, and read tests (4 passed) - Targeted `codex-core-plugins` known and unknown policy source tests (2 passed) - `just fmt` - `git diff --check` --- .../codex_app_server_protocol.schemas.json | 17 +++++ .../codex_app_server_protocol.v2.schemas.json | 17 +++++ .../json/v2/PluginInstalledResponse.json | 17 +++++ .../schema/json/v2/PluginListResponse.json | 17 +++++ .../schema/json/v2/PluginReadResponse.json | 17 +++++ .../json/v2/PluginShareListResponse.json | 17 +++++ .../v2/PluginInstallPolicySource.ts | 5 ++ .../schema/typescript/v2/PluginSummary.ts | 3 +- .../schema/typescript/v2/index.ts | 1 + .../src/protocol/v2/plugin.rs | 12 ++++ .../src/protocol/v2/tests.rs | 2 + codex-rs/app-server/README.md | 4 +- .../src/request_processors/plugins.rs | 3 + .../app-server/tests/suite/v2/plugin_list.rs | 33 +++++++++- .../app-server/tests/suite/v2/plugin_read.rs | 6 ++ .../app-server/tests/suite/v2/plugin_share.rs | 3 + codex-rs/core-plugins/src/manager_tests.rs | 1 + codex-rs/core-plugins/src/remote.rs | 31 +++++++++ .../core-plugins/src/remote/share/tests.rs | 2 + codex-rs/core-plugins/src/remote_tests.rs | 64 +++++++++++++++++++ codex-rs/tui/src/app/plugin_mentions.rs | 1 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + 22 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.ts 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 0eedef903a..2ffff2f96f 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 @@ -13423,6 +13423,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInstallResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -14306,6 +14313,16 @@ "installPolicy": { "$ref": "#/definitions/v2/PluginInstallPolicy" }, + "installPolicySource": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginInstallPolicySource" + }, + { + "type": "null" + } + ] + }, "installed": { "type": "boolean" }, 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 b428f25258..dc6e2c9693 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 @@ -9827,6 +9827,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInstallResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -10710,6 +10717,16 @@ "installPolicy": { "$ref": "#/definitions/PluginInstallPolicy" }, + "installPolicySource": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInstallPolicySource" + }, + { + "type": "null" + } + ] + }, "installed": { "type": "boolean" }, 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 55ad95be2e..f39b0f19b3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -63,6 +63,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInterface": { "properties": { "brandColor": { @@ -489,6 +496,16 @@ "installPolicy": { "$ref": "#/definitions/PluginInstallPolicy" }, + "installPolicySource": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInstallPolicySource" + }, + { + "type": "null" + } + ] + }, "installed": { "type": "boolean" }, 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 25d12e50dd..2f0c6e4b40 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -63,6 +63,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInterface": { "properties": { "brandColor": { @@ -489,6 +496,16 @@ "installPolicy": { "$ref": "#/definitions/PluginInstallPolicy" }, + "installPolicySource": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInstallPolicySource" + }, + { + "type": "null" + } + ] + }, "installed": { "type": "boolean" }, 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 b4b917602a..628cb13189 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -242,6 +242,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInterface": { "properties": { "brandColor": { @@ -629,6 +636,16 @@ "installPolicy": { "$ref": "#/definitions/PluginInstallPolicy" }, + "installPolicySource": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInstallPolicySource" + }, + { + "type": "null" + } + ] + }, "installed": { "type": "boolean" }, 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 751a6c5e7f..32bf946e38 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -37,6 +37,13 @@ ], "type": "string" }, + "PluginInstallPolicySource": { + "enum": [ + "WORKSPACE_SETTING", + "IMPLICIT_CANONICAL_APP" + ], + "type": "string" + }, "PluginInterface": { "properties": { "brandColor": { @@ -445,6 +452,16 @@ "installPolicy": { "$ref": "#/definitions/PluginInstallPolicy" }, + "installPolicySource": { + "anyOf": [ + { + "$ref": "#/definitions/PluginInstallPolicySource" + }, + { + "type": "null" + } + ] + }, "installed": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.ts new file mode 100644 index 0000000000..caa39628ac --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginInstallPolicySource.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 PluginInstallPolicySource = "WORKSPACE_SETTING" | "IMPLICIT_CANONICAL_APP"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index d12f55b3a3..10fd5aa25f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -4,6 +4,7 @@ import type { PluginAuthPolicy } from "./PluginAuthPolicy"; import type { PluginAvailability } from "./PluginAvailability"; import type { PluginInstallPolicy } from "./PluginInstallPolicy"; +import type { PluginInstallPolicySource } from "./PluginInstallPolicySource"; import type { PluginInterface } from "./PluginInterface"; import type { PluginShareContext } from "./PluginShareContext"; import type { PluginSource } from "./PluginSource"; @@ -24,7 +25,7 @@ localVersion: string | null, name: string, /** * Remote sharing context associated with this plugin when available. */ -shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, +shareContext: PluginShareContext | null, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, installPolicySource: PluginInstallPolicySource | null, authPolicy: PluginAuthPolicy, /** * Availability state for installing and using the plugin. */ 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 c335f144ec..00d39b31e3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -294,6 +294,7 @@ export type { PluginDetail } from "./PluginDetail"; export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; +export type { PluginInstallPolicySource } from "./PluginInstallPolicySource"; export type { PluginInstallResponse } from "./PluginInstallResponse"; export type { PluginInstalledParams } from "./PluginInstalledParams"; export type { PluginInstalledResponse } from "./PluginInstalledResponse"; 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 19a4ccd889..9bd53b5d1a 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -567,6 +567,17 @@ pub enum PluginInstallPolicy { InstalledByDefault, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginInstallPolicySource { + #[serde(rename = "WORKSPACE_SETTING")] + #[ts(rename = "WORKSPACE_SETTING")] + WorkspaceSetting, + #[serde(rename = "IMPLICIT_CANONICAL_APP")] + #[ts(rename = "IMPLICIT_CANONICAL_APP")] + ImplicitCanonicalApp, +} + #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[ts(export_to = "v2/")] pub enum PluginAuthPolicy { @@ -613,6 +624,7 @@ pub struct PluginSummary { pub installed: bool, pub enabled: bool, pub install_policy: PluginInstallPolicy, + pub install_policy_source: Option, pub auth_policy: PluginAuthPolicy, /// Availability state for installing and using the plugin. #[serde(default)] 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 77d8a1439b..377ecba2c2 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3650,6 +3650,7 @@ fn plugin_share_list_response_serializes_share_items() { installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: Some(PluginInstallPolicySource::WorkspaceSetting), auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: None, @@ -3672,6 +3673,7 @@ fn plugin_share_list_response_serializes_share_items() { "installed": false, "enabled": false, "installPolicy": "AVAILABLE", + "installPolicySource": "WORKSPACE_SETTING", "authPolicy": "ON_USE", "availability": "AVAILABLE", "interface": null, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a7986753ee..8300c70d94 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -213,8 +213,8 @@ Example with notification opt-out: - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. -- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). -- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). +- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Remote rows include nullable `installPolicySource`; local rows return `null`. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. - `skills/changed` — notification emitted when watched local skill files change. diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 35c12d49c1..f0d7558526 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -163,6 +163,7 @@ fn convert_configured_marketplace_plugin_to_plugin_summary( share_context, source: marketplace_plugin_source_to_info(plugin.source), install_policy: plugin.policy.installation.into(), + install_policy_source: None, auth_policy: plugin.policy.authentication.into(), availability: PluginAvailability::Available, interface: plugin.interface.map(local_plugin_interface_to_info), @@ -1104,6 +1105,7 @@ impl PluginRequestProcessor { installed: outcome.plugin.installed, enabled: outcome.plugin.enabled, install_policy: outcome.plugin.policy.installation.into(), + install_policy_source: None, auth_policy: outcome.plugin.policy.authentication.into(), availability: PluginAvailability::Available, interface: outcome.plugin.interface.map(local_plugin_interface_to_info), @@ -2180,6 +2182,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS installed: summary.installed, enabled: summary.enabled, install_policy: summary.install_policy, + install_policy_source: summary.install_policy_source, auth_policy: summary.auth_policy, availability: summary.availability, interface: summary.interface, 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 ae03b3a1a9..0e4bb4a16a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -9,6 +9,7 @@ use app_test_support::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInstalledParams; use codex_app_server_protocol::PluginInstalledResponse; use codex_app_server_protocol::PluginListMarketplaceKind; @@ -196,6 +197,12 @@ enabled = true ] ); assert_eq!(response.marketplace_load_errors, Vec::new()); + assert!( + response.marketplaces[0] + .plugins + .iter() + .all(|plugin| plugin.install_policy_source.is_none()) + ); Ok(()) } @@ -289,11 +296,17 @@ enabled = true remote_marketplace .plugins .iter() - .map(|plugin| plugin.id.clone()) + .map(|plugin| (plugin.id.clone(), plugin.install_policy_source)) .collect::>(), vec![ - "linear@openai-curated-remote".to_string(), - "remote-only@openai-curated-remote".to_string(), + ( + "linear@openai-curated-remote".to_string(), + Some(PluginInstallPolicySource::WorkspaceSetting), + ), + ( + "remote-only@openai-curated-remote".to_string(), + Some(PluginInstallPolicySource::WorkspaceSetting), + ), ] ); assert_eq!(response.marketplace_load_errors, Vec::new()); @@ -462,6 +475,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, @@ -754,6 +768,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(codex_app_server_protocol::PluginInterface { @@ -794,6 +809,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, @@ -1683,6 +1699,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "IMPLICIT_CANONICAL_APP", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { @@ -1722,6 +1739,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "WORKSPACE_SETTING", "authentication_policy": "ON_USE", "status": "ENABLED", "release": { @@ -1845,6 +1863,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - ); assert_eq!(remote_marketplace.plugins[0].installed, true); assert_eq!(remote_marketplace.plugins[0].enabled, true); + assert_eq!( + remote_marketplace.plugins[0].install_policy_source, + Some(PluginInstallPolicySource::ImplicitCanonicalApp) + ); assert_eq!( remote_marketplace.plugins[0].availability, codex_app_server_protocol::PluginAvailability::Available @@ -1880,6 +1902,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let cached_catalog: serde_json::Value = serde_json::from_slice(&std::fs::read(&cache_files[0])?)?; assert_eq!(cached_catalog["schema_version"], serde_json::json!(1)); + assert_eq!( + cached_catalog["plugins"][0]["installation_policy_source"], + serde_json::json!("IMPLICIT_CANONICAL_APP") + ); assert_eq!( cached_catalog["plugins"][0]["release"]["interface"]["default_prompts"], serde_json::json!(["Create a Linear issue", "Review my Linear projects"]) @@ -3938,6 +3964,7 @@ fn remote_installed_plugin_body_with_optional_app_manifest( "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "WORKSPACE_SETTING", "authentication_policy": "ON_USE", "release": {{ "version": "{release_version}", 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 41b7990ec8..e3e83957cd 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -25,6 +25,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginReadParams; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginShareDiscoverability; @@ -144,6 +145,7 @@ apps = true "name": "example-plugin", "scope": "GLOBAL", "installation_policy": "AVAILABLE", + "installation_policy_source": "IMPLICIT_CANONICAL_APP", "authentication_policy": "ON_USE", "release": { "version": "1.2.1", @@ -267,6 +269,10 @@ apps = true assert_eq!(response.plugin.summary.name, "example-plugin"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); assert_eq!(response.plugin.summary.share_context, None); + assert_eq!( + response.plugin.summary.install_policy_source, + Some(PluginInstallPolicySource::ImplicitCanonicalApp) + ); assert_eq!( response .plugin 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 a82b363806..3f1c2734f2 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -179,6 +179,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -583,6 +584,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -1184,6 +1186,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 469a9a7fc5..5e743416f2 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -781,6 +781,7 @@ fn remote_installed_plugin_in_marketplace( name: name.to_string(), enabled: true, install_policy: codex_app_server_protocol::PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 4c9bd2e672..b9b94f4ded 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -6,6 +6,7 @@ use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; +use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; use codex_login::CodexAuth; @@ -152,6 +153,7 @@ pub struct RemoteInstalledPlugin { pub name: String, pub enabled: bool, pub install_policy: PluginInstallPolicy, + pub install_policy_source: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub interface: Option, @@ -169,6 +171,7 @@ pub struct RemotePluginSummary { pub installed: bool, pub enabled: bool, pub install_policy: PluginInstallPolicy, + pub install_policy_source: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, pub interface: Option, @@ -528,6 +531,26 @@ struct RemoteAppTemplateResponse { reason: Option, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +enum RemotePluginInstallPolicySource { + #[serde(rename = "WORKSPACE_SETTING")] + WorkspaceSetting, + #[serde(rename = "IMPLICIT_CANONICAL_APP")] + ImplicitCanonicalApp, + #[serde(other)] + Unknown, +} + +impl RemotePluginInstallPolicySource { + fn into_protocol(self) -> Option { + match self { + Self::WorkspaceSetting => Some(PluginInstallPolicySource::WorkspaceSetting), + Self::ImplicitCanonicalApp => Some(PluginInstallPolicySource::ImplicitCanonicalApp), + Self::Unknown => None, + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] struct RemotePluginDirectoryItem { id: String, @@ -544,6 +567,7 @@ struct RemotePluginDirectoryItem { #[serde(default)] share_principals: Option>, installation_policy: PluginInstallPolicy, + installation_policy_source: Option, authentication_policy: PluginAuthPolicy, #[serde(rename = "status", default)] availability: PluginAvailability, @@ -1050,6 +1074,7 @@ pub fn group_remote_installed_plugins_by_marketplaces( installed: true, enabled: plugin.enabled, install_policy: plugin.install_policy, + install_policy_source: plugin.install_policy_source, auth_policy: plugin.auth_policy, availability: plugin.availability, interface: plugin.interface.clone(), @@ -1483,6 +1508,9 @@ fn build_remote_plugin_summary( installed: installed_plugin.is_some(), enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, + install_policy_source: plugin + .installation_policy_source + .and_then(RemotePluginInstallPolicySource::into_protocol), auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), @@ -1562,6 +1590,9 @@ fn remote_installed_plugin_to_cache_entry( name: plugin.name.clone(), enabled: installed_plugin.enabled, install_policy: plugin.installation_policy, + install_policy_source: plugin + .installation_policy_source + .and_then(RemotePluginInstallPolicySource::into_protocol), auth_policy: plugin.authentication_policy, availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index d7744681b9..e9887370c5 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -648,6 +648,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { installed: false, enabled: false, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), @@ -687,6 +688,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index f1d4478d7f..29fe8add19 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -33,6 +33,69 @@ fn build_remote_marketplace_preserves_directory_order_and_appends_installed_only ); } +#[test] +fn installation_policy_source_is_preserved_across_remote_summary_paths() { + let mut directory_plugin = directory_plugin("plugin-linear", "linear"); + directory_plugin.installation_policy_source = + Some(RemotePluginInstallPolicySource::ImplicitCanonicalApp); + let installed_plugin = RemotePluginInstalledItem { + plugin: directory_plugin.clone(), + enabled: true, + disabled_skill_names: Vec::new(), + }; + + let marketplace = build_remote_marketplace( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + vec![directory_plugin], + vec![installed_plugin.clone()], + /*include_installed_only*/ false, + ) + .expect("marketplace should be valid") + .expect("marketplace should not be empty"); + assert_eq!( + marketplace + .plugins + .into_iter() + .map(|plugin| plugin.install_policy_source) + .collect::>(), + vec![Some(PluginInstallPolicySource::ImplicitCanonicalApp)] + ); + + let mut installed_plugin = installed_plugin; + installed_plugin.plugin.installation_policy_source = + Some(RemotePluginInstallPolicySource::WorkspaceSetting); + let installed_plugin = remote_installed_plugin_to_cache_entry(&installed_plugin) + .expect("installed plugin should be valid"); + let marketplaces = group_remote_installed_plugins_by_marketplaces( + &[installed_plugin], + &[REMOTE_GLOBAL_MARKETPLACE_NAME], + ); + assert_eq!( + marketplaces + .into_iter() + .flat_map(|marketplace| marketplace.plugins) + .map(|plugin| plugin.install_policy_source) + .collect::>(), + vec![Some(PluginInstallPolicySource::WorkspaceSetting)] + ); +} + +#[test] +fn unknown_installation_policy_source_maps_to_none() { + let plugin = directory_plugin("plugin-linear", "linear"); + let mut plugin_json = serde_json::to_value(plugin).expect("plugin should serialize"); + plugin_json["installation_policy_source"] = + serde_json::Value::String("FUTURE_POLICY_SOURCE".to_string()); + let plugin: RemotePluginDirectoryItem = + serde_json::from_value(plugin_json).expect("unknown source should deserialize"); + + let summary = build_remote_plugin_summary(&plugin, /*installed_plugin*/ None) + .expect("summary should be valid"); + + assert_eq!(summary.install_policy_source, None); +} + fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { RemotePluginDirectoryItem { id: id.to_string(), @@ -44,6 +107,7 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { share_url: None, share_principals: None, installation_policy: PluginInstallPolicy::Available, + installation_policy_source: None, authentication_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, release: RemotePluginReleaseResponse { diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index ae2d25a4dc..cae0caa8f0 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -173,6 +173,7 @@ mod tests { installed: true, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: None, diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index 1f9374bbc3..5f890ab000 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1341,6 +1341,7 @@ pub(super) fn plugins_test_summary( installed, enabled, install_policy, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: Some(plugins_test_interface( @@ -1370,6 +1371,7 @@ pub(super) fn plugins_test_remote_summary( installed, enabled: true, install_policy: PluginInstallPolicy::Available, + install_policy_source: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: Some(plugins_test_interface( From 7226904ed11eac2755a6641fa32af1d573355aff Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 17:27:44 -0700 Subject: [PATCH 25/76] ci: share common workflow setup (#31318) ## Why CI jobs repeat common bootstrap steps, which makes it harder to keep Bazel and Cargo lanes aligned. Centralizing the lightweight setup gives us one place for future runner-wide optimizations without adding Rust toolchain or component installation to the Windows Bazel long poles. ## What - add zero-input `.github/actions/setup-ci` to set Cargo's git transport, install DotSlash and `just`, expose DotSlash from stable PATH locations, and enable Windows Git long paths - have `setup-bazel-ci` compose the common setup, then remove its DotSlash/test-prerequisite plumbing - migrate Bazel, Cargo CI, V8 canary, repo checks, nextest, and Windows release call sites while keeping Rust toolchain and MSVC setup explicit - preserve the existing nextest Dev Drive setup unchanged ## Validation - `just test-github-scripts` (30 tests) - parsed workflow and composite-action YAML with `yq` --- .github/actions/prepare-bazel-ci/action.yml | 5 --- .../run-argument-comment-lint/action.yml | 1 - .github/actions/setup-bazel-ci/action.yml | 27 +------------ .github/actions/setup-ci/action.yml | 39 +++++++++++++++++++ .github/scripts/test_v8_canary_changes.py | 9 +++++ .github/scripts/v8_canary_changes.py | 15 ++++++- .github/workflows/bazel.yml | 17 +++----- .github/workflows/cargo-deny.yml | 7 +--- .github/workflows/repo-checks.yml | 7 +--- .../rust-ci-full-nextest-platform.yml | 15 ++----- .github/workflows/rust-ci-full.yml | 15 ++----- .github/workflows/rust-ci.yml | 11 ++---- .github/workflows/rust-release-windows.yml | 10 ++--- .github/workflows/v8-canary.yml | 7 +--- 14 files changed, 89 insertions(+), 96 deletions(-) create mode 100644 .github/actions/setup-ci/action.yml diff --git a/.github/actions/prepare-bazel-ci/action.yml b/.github/actions/prepare-bazel-ci/action.yml index b41d80e0bc..a1beece2df 100644 --- a/.github/actions/prepare-bazel-ci/action.yml +++ b/.github/actions/prepare-bazel-ci/action.yml @@ -7,10 +7,6 @@ inputs: cache-scope: description: Logical namespace used to keep concurrent Bazel jobs from reserving the same repository cache key. required: true - install-test-prereqs: - description: Install DotSlash for Bazel-backed test jobs. - required: false - default: "false" outputs: repository-cache-path: description: Filesystem path used for the Bazel repository cache. @@ -30,7 +26,6 @@ runs: uses: ./.github/actions/setup-bazel-ci with: target: ${{ inputs.target }} - install-test-prereqs: ${{ inputs.install-test-prereqs }} - name: Compute bazel repository cache key id: cache_bazel_repository_key diff --git a/.github/actions/run-argument-comment-lint/action.yml b/.github/actions/run-argument-comment-lint/action.yml index 80fb23d417..31c752e86e 100644 --- a/.github/actions/run-argument-comment-lint/action.yml +++ b/.github/actions/run-argument-comment-lint/action.yml @@ -16,7 +16,6 @@ runs: - uses: ./.github/actions/setup-bazel-ci with: target: ${{ inputs.target }} - install-test-prereqs: true - name: Install Linux sandbox build dependencies if: ${{ runner.os == 'Linux' }} diff --git a/.github/actions/setup-bazel-ci/action.yml b/.github/actions/setup-bazel-ci/action.yml index 658dca2aa4..57e71fbc78 100644 --- a/.github/actions/setup-bazel-ci/action.yml +++ b/.github/actions/setup-bazel-ci/action.yml @@ -1,13 +1,9 @@ name: setup-bazel-ci -description: Prepare a Bazel CI runner with shared caches and optional test prerequisites. +description: Prepare a Bazel CI runner with shared caches. inputs: target: description: Target triple used for cache namespacing. required: true - install-test-prereqs: - description: Install DotSlash for Bazel-backed test jobs. - required: false - default: "false" outputs: repository-cache-path: description: Filesystem path used for the Bazel repository cache. @@ -16,21 +12,7 @@ outputs: runs: using: composite steps: - # Some integration tests rely on DotSlash being installed. - # See https://github.com/openai/codex/pull/7617. - - name: Install DotSlash - if: inputs.install-test-prereqs == 'true' - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - - name: Make DotSlash available in PATH (Unix) - if: inputs.install-test-prereqs == 'true' && runner.os != 'Windows' - shell: bash - run: cp "$(which dotslash)" /usr/local/bin - - - name: Make DotSlash available in PATH (Windows) - if: inputs.install-test-prereqs == 'true' && runner.os == 'Windows' - shell: pwsh - run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" + - uses: ./.github/actions/setup-ci - name: Set up Bazel uses: bazel-contrib/setup-bazel@c5acdfb288317d0b5c0bbd7a396a3dc868bb0f86 # 0.19.0 @@ -124,8 +106,3 @@ runs: if: runner.os == 'Windows' shell: pwsh run: ./.github/scripts/compute-bazel-windows-path.ps1 - - - name: Enable Git long paths (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: git config --global core.longpaths true diff --git a/.github/actions/setup-ci/action.yml b/.github/actions/setup-ci/action.yml new file mode 100644 index 0000000000..0924e29616 --- /dev/null +++ b/.github/actions/setup-ci/action.yml @@ -0,0 +1,39 @@ +name: setup-ci +description: Prepare common tools and environment shared by CI jobs. + +runs: + using: composite + steps: + - name: Prefer the Git CLI for Cargo git dependencies + shell: bash + run: echo "CARGO_NET_GIT_FETCH_WITH_CLI=true" >> "$GITHUB_ENV" + + - name: Install DotSlash + uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 + + - name: Install just + uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 + with: + tool: just@1.51.0 + + # Some integration tests spawn DotSlash from a stable system path rather + # than inheriting the action-local PATH entry. + - name: Make DotSlash available in PATH (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + if [[ -w /usr/local/bin ]]; then + cp "$(which dotslash)" /usr/local/bin + else + sudo cp "$(which dotslash)" /usr/local/bin + fi + + - name: Make DotSlash available in PATH (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe" + + - name: Enable Git long paths (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: git config --global core.longpaths true diff --git a/.github/scripts/test_v8_canary_changes.py b/.github/scripts/test_v8_canary_changes.py index e4ce6424ed..0d8d9a2b32 100644 --- a/.github/scripts/test_v8_canary_changes.py +++ b/.github/scripts/test_v8_canary_changes.py @@ -4,6 +4,7 @@ import unittest from pathlib import Path from v8_canary_changes import changed_files +from v8_canary_changes import canary_required from v8_canary_changes import merge_base from v8_canary_changes import resolved_v8_version from v8_canary_changes import windows_source_required @@ -46,6 +47,14 @@ version = "149.2.0" ) ) + def test_setup_ci_change_requires_canary_and_source_build(self) -> None: + changed_files = {".github/actions/setup-ci/action.yml"} + + self.assertTrue(canary_required(changed_files, "149.2.0", "149.2.0")) + self.assertTrue( + windows_source_required(changed_files, "149.2.0", "149.2.0") + ) + def test_manual_dispatch_requires_source_build(self) -> None: self.assertTrue( windows_source_required( diff --git a/.github/scripts/v8_canary_changes.py b/.github/scripts/v8_canary_changes.py index 0acc4ec3f6..53a064e384 100644 --- a/.github/scripts/v8_canary_changes.py +++ b/.github/scripts/v8_canary_changes.py @@ -21,6 +21,7 @@ ROOT = Path(__file__).resolve().parents[2] CANARY_PATH_PATTERNS = { ".bazelrc", ".github/actions/setup-bazel-ci/**", + ".github/actions/setup-ci/**", ".github/scripts/run_bazel_with_buildbuddy.py", ".github/scripts/rusty_v8_bazel.py", ".github/scripts/rusty_v8_module_bazel.py", @@ -40,6 +41,7 @@ CANARY_PATH_PATTERNS = { # Windows source builds are a narrower, more expensive subset of the canary. # A V8 version change also requires them even when no path below changed. WINDOWS_SOURCE_BUILD_PATHS = { + ".github/actions/setup-ci/**", ".github/scripts/rusty_v8_bazel.py", ".github/scripts/rusty_v8_module_bazel.py", ".github/scripts/v8_canary_changes.py", @@ -72,6 +74,15 @@ def canary_required( ) +def matching_windows_source_paths(changed_files: set[str]) -> set[str]: + """Return changed paths that require Windows rusty_v8 source builds.""" + return { + path + for path in changed_files + if any(fnmatchcase(path, pattern) for pattern in WINDOWS_SOURCE_BUILD_PATHS) + } + + def resolved_v8_version(cargo_lock: bytes) -> str: versions = sorted( { @@ -96,7 +107,7 @@ def windows_source_required( return ( force or base_v8_version != head_v8_version - or not changed_files.isdisjoint(WINDOWS_SOURCE_BUILD_PATHS) + or bool(matching_windows_source_paths(changed_files)) ) @@ -165,7 +176,7 @@ def main() -> None: if matched_canary_paths else "no relevant changes" ) - matched_windows_paths = sorted(files & WINDOWS_SOURCE_BUILD_PATHS) + matched_windows_paths = sorted(matching_windows_source_paths(files)) windows_source_reason = ( ", ".join(matched_windows_paths) if matched_windows_paths diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index 10dd8670de..3e48b741b7 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -57,10 +57,12 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' + - name: Prepare Bazel CI + id: prepare_bazel + uses: ./.github/actions/prepare-bazel-ci with: - tool: just + target: ${{ matrix.target }} + cache-scope: bazel-${{ github.job }} - name: Check rusty_v8 MODULE.bazel checksums if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' @@ -69,13 +71,6 @@ jobs: python3 .github/scripts/rusty_v8_bazel.py check-module-bazel just test-github-scripts - - name: Prepare Bazel CI - id: prepare_bazel - uses: ./.github/actions/prepare-bazel-ci - with: - target: ${{ matrix.target }} - cache-scope: bazel-${{ github.job }} - install-test-prereqs: "true" - name: Check MODULE.bazel.lock is up to date if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu' shell: bash @@ -176,7 +171,6 @@ jobs: # not save it from every shard below; duplicate uploads would sit on # the PR-blocking critical path after the useful test work is done. cache-scope: bazel-test - install-test-prereqs: "true" - name: bazel test shard env: @@ -287,7 +281,6 @@ jobs: with: target: x86_64-pc-windows-gnullvm cache-scope: bazel-${{ github.job }} - install-test-prereqs: "true" - name: bazel test //... env: diff --git a/.github/workflows/cargo-deny.yml b/.github/workflows/cargo-deny.yml index be00e1147d..ccbf80e12f 100644 --- a/.github/workflows/cargo-deny.yml +++ b/.github/workflows/cargo-deny.yml @@ -3,11 +3,6 @@ name: cargo-deny on: workflow_call: -# Cargo's libgit2 transport has been flaky when fetching git dependencies with -# nested submodules. Prefer the system git CLI across every Cargo invocation. -env: - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - jobs: cargo-deny: runs-on: ubuntu-latest @@ -21,6 +16,8 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: ./.github/actions/setup-ci + - name: Install Rust toolchain uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 diff --git a/.github/workflows/repo-checks.yml b/.github/workflows/repo-checks.yml index 3d0504172d..97932c4fd4 100644 --- a/.github/workflows/repo-checks.yml +++ b/.github/workflows/repo-checks.yml @@ -16,6 +16,8 @@ jobs: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: ./.github/actions/setup-ci + - name: Verify codex-rs Cargo manifests inherit workspace settings run: python3 .github/scripts/verify_cargo_workspace_manifests.py @@ -76,15 +78,10 @@ jobs: - name: Check root README ToC run: python3 scripts/readme_toc.py README.md - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - with: - tool: just@1.51.0 - name: Install uv uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 with: version: "0.11.3" - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - name: Check formatting (run `just fmt` to fix) run: just fmt-check diff --git a/.github/workflows/rust-ci-full-nextest-platform.yml b/.github/workflows/rust-ci-full-nextest-platform.yml index 65d1fb2be5..825c2d73d6 100644 --- a/.github/workflows/rust-ci-full-nextest-platform.yml +++ b/.github/workflows/rust-ci-full-nextest-platform.yml @@ -48,11 +48,6 @@ on: default: false type: boolean -# Caller workflow-level env does not flow through workflow_call, so keep the -# Cargo git transport hardening on the archive and shard jobs directly here. -env: - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - jobs: archive: name: Build nextest archive @@ -81,6 +76,8 @@ jobs: shell: pwsh run: ../.github/scripts/setup-dev-drive.ps1 + - uses: ./.github/actions/setup-ci + - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -91,9 +88,6 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap fi - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ inputs.target }} @@ -306,6 +300,8 @@ jobs: with: persist-credentials: false + - uses: ./.github/actions/setup-ci + - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -316,9 +312,6 @@ jobs: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends pkg-config libcap-dev bubblewrap fi - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: targets: ${{ inputs.target }} diff --git a/.github/workflows/rust-ci-full.yml b/.github/workflows/rust-ci-full.yml index cb99c8911e..dc38aaa86d 100644 --- a/.github/workflows/rust-ci-full.yml +++ b/.github/workflows/rust-ci-full.yml @@ -8,13 +8,6 @@ on: - "**full-ci**" workflow_dispatch: -# CI builds in debug (dev) for faster signal. -env: - # Cargo's libgit2 transport has been flaky on macOS when fetching git - # dependencies with nested submodules. Use the system git CLI, which has - # better network/proxy behavior and matches Cargo's own suggested fallback. - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - jobs: # --- CI that doesn't need specific targets --------------------------------- general: @@ -27,12 +20,10 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - with: - tool: just - name: cargo fmt run: cargo fmt -- --config imports_granularity=Item --check - name: Rust benchmark smoke test @@ -48,6 +39,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: @@ -65,6 +57,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: toolchain: nightly-2025-09-18 @@ -123,7 +116,6 @@ jobs: - uses: ./.github/actions/setup-bazel-ci with: target: ${{ runner.os }} - install-test-prereqs: true - name: Install Linux sandbox build dependencies if: ${{ runner.os == 'Linux' }} shell: bash @@ -256,6 +248,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - name: Install Linux build dependencies if: ${{ runner.os == 'Linux' }} shell: bash diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index cf1afdfe2d..01d51aac0e 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -3,11 +3,6 @@ on: workflow_call: workflow_dispatch: -# Cargo's libgit2 transport has been flaky when fetching git dependencies with -# nested submodules. Prefer the system git CLI across every Cargo invocation. -env: - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - jobs: # --- Detect what changed so the fast PR workflow only runs relevant jobs ---- changed: @@ -76,12 +71,10 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: ./.github/actions/setup-ci - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 with: components: rustfmt - - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 - with: - tool: just - name: cargo fmt run: cargo fmt -- --config imports_granularity=Item --check - name: Rust benchmark smoke test @@ -104,6 +97,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: ./.github/actions/setup-ci - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - uses: taiki-e/install-action@44c6d64aa62cd779e873306675c7a58e86d6d532 # v2.62.49 with: @@ -128,6 +122,7 @@ jobs: with: ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} persist-credentials: false + - uses: ./.github/actions/setup-ci - uses: dtolnay/rust-toolchain@e081816240890017053eacbb1bdf337761dc5582 # 1.95.0 - name: Install nightly argument-comment-lint toolchain shell: bash diff --git a/.github/workflows/rust-release-windows.yml b/.github/workflows/rust-release-windows.yml index 31d832fcbe..ccb2ee13f7 100644 --- a/.github/workflows/rust-release-windows.yml +++ b/.github/workflows/rust-release-windows.yml @@ -3,10 +3,7 @@ name: rust-release-windows on: workflow_call: -# Cargo's libgit2 transport has been flaky when fetching git dependencies with -# nested submodules. Prefer the system git CLI across every Cargo invocation. env: - CARGO_NET_GIT_FETCH_WITH_CLI: "true" WINDOWS_BINARIES: "codex codex-code-mode-host codex-responses-api-proxy codex-windows-sandbox-setup codex-command-runner codex-app-server" jobs: @@ -72,6 +69,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - name: Print runner specs (Windows) shell: powershell run: | @@ -164,6 +162,7 @@ jobs: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false + - uses: ./.github/actions/setup-ci - name: Download prebuilt Windows binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -221,6 +220,8 @@ jobs: with: persist-credentials: false + - uses: ./.github/actions/setup-ci + - name: Download prebuilt Windows primary binaries uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: @@ -270,9 +271,6 @@ jobs: "$dest/${binary}-${{ matrix.target }}.exe" done - - name: Install DotSlash - uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2 - - name: Build Codex package archives shell: bash run: | diff --git a/.github/workflows/v8-canary.yml b/.github/workflows/v8-canary.yml index 53d9585108..f338c341e4 100644 --- a/.github/workflows/v8-canary.yml +++ b/.github/workflows/v8-canary.yml @@ -9,11 +9,6 @@ on: pull_request: {} workflow_dispatch: -# Cargo's libgit2 transport has been flaky when fetching git dependencies with -# nested submodules. Prefer the system git CLI for Cargo builds and smoke tests. -env: - CARGO_NET_GIT_FETCH_WITH_CLI: "true" - concurrency: group: ${{ github.workflow }}::${{ github.event.pull_request.number > 0 && format('pr-{0}', github.event.pull_request.number) || github.ref_name }} cancel-in-progress: ${{ github.ref_name != 'main' }} @@ -325,6 +320,8 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: ./.github/actions/setup-ci + - name: Configure git for upstream checkout shell: bash run: git config --global core.symlinks true From 8a18312ee58b6dce10edca44bbed90365f384edb Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 17:42:43 -0700 Subject: [PATCH 26/76] app-server: cover selected environments in integration tests (#29992) ## Why Now that basic cross-OS app/exec support is wired up, it's time to clean up the tech debt of the remote_env_windows test and make sure its test logic is covered in more maintainable feature-specific tests. ## What - Add focused app-server tests for target-native `AGENTS.md` sources and content, plus shell and cwd context, while preserving explicit TODO baselines for the remaining host-scoped metadata. - Add a `TestAppServer` helper that waits for and returns the matching typed turn completion. - Remove redundant app-server coverage and dependencies from `remote_env_windows` while retaining its exec and apply-patch smoke coverage. A follow-up will remove these. ## Validation - `just test -p codex-app-server` - `bazel test //codex-rs/app-server:app-server-all-wine-exec-test --test_output=errors` - `bazel test //codex-rs/core/tests/remote_env_windows:smoke-test --test_output=errors` --- .../tests/common/test_app_server.rs | 50 +++- codex-rs/app-server/tests/suite/v2/mod.rs | 1 + .../tests/suite/v2/selected_environment.rs | 252 ++++++++++++++++++ .../core/tests/remote_env_windows/BUILD.bazel | 6 - .../remote_env_windows_test.rs | 171 ------------ 5 files changed, 298 insertions(+), 182 deletions(-) create mode 100644 codex-rs/app-server/tests/suite/v2/selected_environment.rs diff --git a/codex-rs/app-server/tests/common/test_app_server.rs b/codex-rs/app-server/tests/common/test_app_server.rs index cbb8ff161e..0fc0594baa 100644 --- a/codex-rs/app-server/tests/common/test_app_server.rs +++ b/codex-rs/app-server/tests/common/test_app_server.rs @@ -111,6 +111,7 @@ use codex_app_server_protocol::TurnCompletedNotification; use codex_app_server_protocol::TurnEnvironmentParams; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::WindowsSandboxSetupStartParams; use codex_exec_server::CODEX_EXEC_SERVER_NOISE_AUTH_TOKEN_ENV_VAR; @@ -216,15 +217,21 @@ impl TestAppServer { Ok(app_server) } + /// Returns the automatically selected test environment retained by this server. + /// + /// Tests can use the environment to arrange target-native filesystem fixtures before starting + /// a thread. Returns an error unless this server was created with [`Self::new_with_auto_env`]. + pub fn auto_env(&self) -> anyhow::Result<&TestEnv> { + self.auto_env + .as_ref() + .context("auto environment is unavailable; use TestAppServer::new_with_auto_env") + } + /// Returns app-server protocol parameters for the automatically selected /// test environment. Returns an error unless this server was created with /// [`Self::new_with_auto_env`]. pub fn auto_env_params(&self) -> anyhow::Result { - let selection = self - .auto_env - .as_ref() - .context("auto environment is unavailable; use TestAppServer::new_with_auto_env")? - .selection(); + let selection = self.auto_env()?.selection(); Ok(TurnEnvironmentParams { environment_id: selection.environment_id.clone(), cwd: selection.cwd.clone().into(), @@ -1052,6 +1059,39 @@ impl TestAppServer { self.send_request("turn/start", params).await } + /// Start a turn and return its matching typed completion notification. + pub async fn start_turn_and_wait_for_completion( + &mut self, + params: TurnStartParams, + ) -> anyhow::Result { + let thread_id = params.thread_id.clone(); + let request_id = self.send_turn_start_request(params).await?; + let response = self + .read_stream_until_response_message(RequestId::Integer(request_id)) + .await?; + let TurnStartResponse { turn } = crate::to_response(response)?; + let notification = self + .read_stream_until_matching_notification( + "turn/completed for started turn", + |notification| { + notification.method == "turn/completed" + && notification.params.as_ref().is_some_and(|params| { + serde_json::from_value::(params.clone()) + .is_ok_and(|completed| { + completed.thread_id == thread_id && completed.turn.id == turn.id + }) + }) + }, + ) + .await?; + let params = notification + .params + .context("turn/completed notification must include params")?; + let completed = serde_json::from_value(params) + .context("failed to deserialize turn/completed notification")?; + Ok(completed) + } + /// Send a `thread/inject_items` JSON-RPC request (v2). pub async fn send_thread_inject_items_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index bdb03271d0..3d55079dc6 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -60,6 +60,7 @@ mod review; mod safety_check_downgrade; #[cfg(not(target_os = "windows"))] mod selected_capability_stack; +mod selected_environment; mod skills_list; mod sleep; mod thread_archive; diff --git a/codex-rs/app-server/tests/suite/v2/selected_environment.rs b/codex-rs/app-server/tests/suite/v2/selected_environment.rs new file mode 100644 index 0000000000..513cd25d7f --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/selected_environment.rs @@ -0,0 +1,252 @@ +use std::collections::BTreeMap; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use app_test_support::PathBufExt; +use app_test_support::TestAppServer; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; +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_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::UserInput as V2UserInput; +use core_test_support::responses; +use pretty_assertions::assert_eq; +use tempfile::TempDir; +use tokio::time::timeout; + +const AGENTS_INSTRUCTIONS: &str = "selected environment workspace instructions"; +const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); + +fn text_turn_params(thread_id: String, prompt: &str) -> TurnStartParams { + TurnStartParams { + thread_id, + input: vec![V2UserInput::Text { + text: prompt.to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + } +} + +#[tokio::test] +async fn thread_start_reports_selected_environment_metadata() -> Result<()> { + let server = responses::start_mock_server().await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + cwd, + runtime_workspace_roots, + active_permission_profile, + .. + } = to_response(response)?; + let host_cwd = codex_home.path().to_path_buf().abs().canonicalize()?; + let cwd = cwd.canonicalize()?; + let runtime_workspace_roots = runtime_workspace_roots + .into_iter() + .map(|root| root.canonicalize()) + .collect::>>()?; + assert_eq!( + (cwd, runtime_workspace_roots, active_permission_profile), + ( + // TODO(anp): Return the selected environment's native cwd from thread/start. + host_cwd.clone(), + // TODO(anp): Derive runtime workspace roots from the selected remote environment. + vec![host_cwd], + // TODO(anp): Report the implicit built-in permission profile instead of None. + None, + ) + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_reports_selected_environment_instruction_source() -> Result<()> { + 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_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + + let (agents_source, environment_cwd) = { + let auto_env = app_server.auto_env()?; + let environment_cwd = auto_env.selection().cwd.clone(); + let agents_source = environment_cwd.join("AGENTS.md")?; + auto_env + .environment() + .get_filesystem() + .write_file( + &agents_source, + AGENTS_INSTRUCTIONS.as_bytes().to_vec(), + /*sandbox*/ None, + ) + .await?; + (agents_source, environment_cwd) + }; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: ThreadStartResponse = to_response(response)?; + + assert_eq!(response.instruction_sources, vec![agents_source.into()]); + timeout( + DEFAULT_READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(text_turn_params( + response.thread.id, + "inspect workspace instructions", + )), + ) + .await??; + + let user_context = response_mock.single_request().message_input_texts("user"); + let instructions = user_context + .iter() + .find(|text| text.starts_with("# AGENTS.md instructions")) + .context("selected environment instructions should be model visible")?; + let expected_instructions = format!( + "# AGENTS.md instructions for {}\n\n\n{AGENTS_INSTRUCTIONS}\n", + environment_cwd.inferred_native_path_string() + ); + assert_eq!(instructions, &expected_instructions); + + Ok(()) +} + +#[tokio::test] +async fn turn_model_context_uses_selected_environment() -> Result<()> { + 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_assistant_message("msg-1", "done"), + responses::ev_completed("resp-1"), + ]), + ) + .await; + let codex_home = TempDir::new()?; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + /*auto_compact_limit*/ 100_000, + /*requires_openai_auth*/ None, + "mock_provider", + "compact", + )?; + let mut app_server = TestAppServer::new_with_auto_env(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??; + let (environment_cwd, environment_shell) = { + let auto_env = app_server.auto_env()?; + ( + auto_env.selection().cwd.clone(), + auto_env.environment().info().await?.shell.name, + ) + }; + + let request_id = app_server + .send_thread_start_request_with_auto_env(ThreadStartParams::default()) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + thread, + runtime_workspace_roots, + .. + } = to_response(response)?; + timeout( + DEFAULT_READ_TIMEOUT, + app_server.start_turn_and_wait_for_completion(text_turn_params( + thread.id, + "inspect the selected environment", + )), + ) + .await??; + + let user_context = response_mock.single_request().message_input_texts("user"); + let environment_context = user_context + .iter() + .find(|text| text.starts_with("")) + .context("selected environment context should be model visible")?; + let shell = environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim) + .map(str::to_string); + let cwd = environment_context + .lines() + .find(|line| line.trim_start().starts_with("")) + .map(str::trim) + .map(str::to_string); + assert_eq!( + (shell, cwd), + ( + Some(format!("{environment_shell}")), + Some(format!( + "{}", + environment_cwd.inferred_native_path_string() + )), + ) + ); + let [runtime_workspace_root] = runtime_workspace_roots.as_slice() else { + anyhow::bail!("expected one runtime workspace root"); + }; + let expected_workspace_roots = format!( + "{}", + runtime_workspace_root.as_path().display() + ); + // TODO(anp): Derive model-visible workspace roots from the selected remote environment and + // render them using its native path convention. + assert!(environment_context.contains(&expected_workspace_roots)); + + Ok(()) +} diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 9b5ce52bd9..1f2465ffcb 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -6,15 +6,10 @@ wine_rust_test( srcs = ["remote_env_windows_test.rs"], crate_name = "remote_env_windows_test", crate_root = "remote_env_windows_test.rs", - host_binaries = { - "codex-app-server": "//codex-rs/app-server:codex-app-server", - }, windows_binaries = { "wine-windows-exec-server": "//codex-rs/exec-server/testing:windows-exec-server", }, deps = [ - "//codex-rs/app-server-protocol", - "//codex-rs/app-server/tests/common", "//codex-rs/core/tests/common", "//codex-rs/exec-server", "//codex-rs/exec-server/testing:wine-exec-server-test-support", @@ -24,7 +19,6 @@ wine_rust_test( "@crates//:anyhow", "@crates//:pretty_assertions", "@crates//:serde_json", - "@crates//:tempfile", "@crates//:tokio", ], ) diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 04f8914982..ff80316061 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -2,20 +2,7 @@ use anyhow::Context; use anyhow::Result; -use app_test_support::PathBufExt; -use app_test_support::TestAppServer; -use app_test_support::create_mock_responses_server_repeating_assistant; -use app_test_support::to_response; -use app_test_support::write_mock_responses_config_toml; -use codex_app_server_protocol::RequestId; -use codex_app_server_protocol::ThreadStartParams; -use codex_app_server_protocol::ThreadStartResponse; -use codex_app_server_protocol::TurnEnvironmentParams; -use codex_app_server_protocol::TurnStartParams; -use codex_app_server_protocol::TurnStartResponse; -use codex_app_server_protocol::UserInput as V2UserInput; use codex_exec_server::REMOTE_ENVIRONMENT_ID; -use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::Feature; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; @@ -35,20 +22,11 @@ use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; -use codex_utils_path_uri::LegacyAppPathString; -use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; -use serde_json::Value; use serde_json::json; -use std::collections::BTreeMap; -use std::fs; -use tempfile::TempDir; -use tokio::time::timeout; use wine_exec_server_test_support::WineExecServer; -const APP_SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { const CALL_ID: &str = "wine-cmd-smoke"; @@ -244,152 +222,3 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { }) .await } - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { - const AGENTS_INSTRUCTIONS: &str = "remote Windows workspace instructions"; - const NATIVE_CWD: &str = r"C:\windows"; - - WineExecServer - .scope(|exec_server_url, wine_prefix| async move { - let agents_path = PathUri::parse("file:///C:/windows/AGENTS.md")?; - fs::write( - wine_prefix.join("drive_c").join("windows").join("AGENTS.md"), - AGENTS_INSTRUCTIONS, - )?; - - let codex_home = TempDir::new()?; - let server = create_mock_responses_server_repeating_assistant("done").await; - write_mock_responses_config_toml( - codex_home.path(), - &server.uri(), - &BTreeMap::new(), - 100_000, - /*requires_openai_auth*/ None, - "mock", - "compact", - )?; - let mut app_server = TestAppServer::new_with_env( - codex_home.path(), - &[( - CODEX_EXEC_SERVER_URL_ENV_VAR, - Some(exec_server_url.as_str()), - )], - ) - .await?; - timeout(APP_SERVER_READ_TIMEOUT, app_server.initialize()).await??; - - let request_id = app_server - .send_thread_start_request(ThreadStartParams { - environments: Some(vec![TurnEnvironmentParams { - environment_id: REMOTE_ENVIRONMENT_ID.to_string(), - cwd: serde_json::from_value::(json!(NATIVE_CWD))?, - }]), - ..Default::default() - }) - .await?; - let response = timeout( - APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - let response: ThreadStartResponse = to_response(response)?; - assert!(!response.thread.id.is_empty()); - let host_cwd = codex_home.path().to_path_buf().abs(); - // TODO(anp): Return the selected environment's native cwd from thread/start. - assert_eq!(response.cwd, host_cwd); - // TODO(anp): Derive runtime workspace roots from the selected remote environment. - assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); - assert_eq!( - response.instruction_sources, - vec![LegacyAppPathString::from_path_uri( - &agents_path, - PathConvention::Windows, - )?] - ); - // TODO(anp): Report the implicit built-in permission profile instead of None. - assert_eq!(response.active_permission_profile, None); - - let turn_request_id = app_server - .send_turn_start_request(TurnStartParams { - thread_id: response.thread.id, - client_user_message_id: None, - input: vec![V2UserInput::Text { - text: "say done".to_string(), - text_elements: Vec::new(), - }], - ..Default::default() - }) - .await?; - let turn_response = timeout( - APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)), - ) - .await??; - let _: TurnStartResponse = to_response(turn_response)?; - timeout( - APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_notification_message("turn/completed"), - ) - .await??; - - let requests = server - .received_requests() - .await - .context("failed to fetch received requests")?; - let first_request = requests - .iter() - .find(|request| request.url.path().ends_with("/responses")) - .context("turn should send a Responses request")?; - let body = first_request.body_json::()?; - let remote_instructions = body["input"] - .as_array() - .into_iter() - .flatten() - .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter_map(|content| content.get("text").and_then(Value::as_str)) - .find(|text| text.contains(AGENTS_INSTRUCTIONS)) - .context("remote workspace instructions should be model visible")?; - assert!(remote_instructions.contains(r"# AGENTS.md instructions for C:\windows")); - let environment_context = body["input"] - .as_array() - .into_iter() - .flatten() - .filter(|item| item.get("role").and_then(Value::as_str) == Some("user")) - .filter_map(|item| item.get("content").and_then(Value::as_array)) - .flatten() - .filter_map(|content| content.get("text").and_then(Value::as_str)) - .find(|text| text.starts_with("")) - .context("environment context should be model visible")?; - // The model should see the remote environment's shell, not the Linux app-server's - // host shell. - assert_eq!( - environment_context - .lines() - .find(|line| line.trim_start().starts_with("")) - .map(str::trim), - Some("powershell"), - ); - // The model should see cwd using the remote environment's native path convention, not - // the Linux app-server's host path convention. - assert_eq!( - environment_context - .lines() - .find(|line| line.trim_start().starts_with("")) - .map(str::trim), - Some(r"C:\windows"), - ); - let host_workspace_roots = format!( - "{}", - codex_home.path().display() - ); - // TODO(anp): Derive model-visible workspace roots from the selected remote environment - // and render them using its native path convention. - assert!(environment_context.contains(&host_workspace_roots)); - - Ok(()) - }) - .await -} From 45be435135a7936f4b01916a6d8f0c8091229fb8 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 6 Jul 2026 18:08:35 -0700 Subject: [PATCH 27/76] Warn when configured service tiers are unsupported (#31284) ## Why Codex currently omits a configured `service_tier` when the selected model's catalog entry does not advertise support for it. That fallback is silent, so users can unknowingly send requests at the default tier instead. This makes cases such as #26604 difficult to diagnose. ## What changed - Emit the shared core `Warning` event during session startup when a configured service tier will be omitted because the initial model does not advertise support for it. - Do not warn on later model or service-tier changes, which keeps warning emission stateless and avoids client-specific handling. - Keep the existing request filtering behavior unchanged. ## Validation - `just test -p codex-core unsupported_service_tier` - `just test -p codex-core unsupported_configured_service_tier_warns_at_session_start` ### Manual validation - Launched the real TUI with the bundled catalog, where `gpt-5.5` advertises the `priority` tier but not `flex`, and configured `service_tier = "flex"`. - Confirmed the unsupported-tier warning appeared exactly once during startup. - Submitted two turns through a local Responses API SSE stub; both received mock replies and neither emitted another warning. - Inspected both captured `/v1/responses` request bodies and confirmed that neither contained a `service_tier` field. - Repeated the two-turn TUI flow against the live Responses API; both turns completed and the warning was not repeated. --- codex-rs/core/src/session/mod.rs | 33 ++++++++++++++-- codex-rs/core/tests/suite/model_switching.rs | 40 ++++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index ba8eacb395..91a21ef731 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -629,11 +629,14 @@ impl Codex { developer_instructions: None, }, }; - let service_tier = get_service_tier( - config.service_tier.clone(), - config.features.enabled(Feature::FastMode), + let fast_mode_enabled = config.features.enabled(Feature::FastMode); + let initial_service_tier_warning = unsupported_service_tier_warning( + config.service_tier.as_deref(), + fast_mode_enabled, &model_info, ); + let service_tier = + get_service_tier(config.service_tier.clone(), fast_mode_enabled, &model_info); let session_configuration = SessionConfiguration { provider: config.model_provider.clone(), collaboration_mode, @@ -706,6 +709,14 @@ impl Codex { error!("Failed to create session: {e:#}"); map_session_init_error(&e, &config.codex_home) })?; + if let Some(message) = initial_service_tier_warning { + session + .send_event_raw(Event { + id: INITIAL_SUBMIT_ID.to_owned(), + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + } let thread_id = session.thread_id; // This task will run until Op::Shutdown is received. @@ -908,6 +919,22 @@ fn get_service_tier( }) } +fn unsupported_service_tier_warning( + configured_service_tier: Option<&str>, + fast_mode_enabled: bool, + model_info: &ModelInfo, +) -> Option { + let service_tier = configured_service_tier.filter(|service_tier| { + fast_mode_enabled + && *service_tier != SERVICE_TIER_DEFAULT_REQUEST_VALUE + && !model_info.supports_service_tier(service_tier) + })?; + Some(format!( + "Configured service tier `{service_tier}` is not advertised as supported for model `{}` and will be omitted from requests.", + model_info.slug + )) +} + fn session_permission_profile_state_from_config( config: &Config, ) -> CodexResult { diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index b08ca7cc8a..9ef3675b3e 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -387,6 +387,46 @@ async fn unsupported_service_tier_is_omitted_from_http_turn() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unsupported_configured_service_tier_warns_at_session_start() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let model_slug = "test-no-tier-model"; + let model = test_model_info( + model_slug, + model_slug, + "no service tiers", + default_input_modalities(), + ); + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.service_tier = Some(ServiceTier::Flex.request_value().to_string()); + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + }); + let test = builder.build(&server).await?; + + let warning = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::Warning(warning) + if warning.message.contains("will be omitted from requests") + ) + }) + .await; + let EventMsg::Warning(warning) = warning else { + unreachable!("wait_for_event matched a warning") + }; + assert_eq!( + warning.message, + "Configured service tier `flex` is not advertised as supported for model `test-no-tier-model` and will be omitted from requests." + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn default_service_tier_override_is_omitted_from_http_turn() -> Result<()> { skip_if_no_network!(Ok(())); From 9acfe8965d44af60c71227f4577902e467ba5119 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 6 Jul 2026 18:12:24 -0700 Subject: [PATCH 28/76] Extract shared HTTP transport into codex-http-client (#31323) ## Why Codex-owned HTTP construction currently lives in `codex-client` alongside higher-level retry, SSE, and request-telemetry policy. That makes it difficult to apply shared network behavior consistently across crates, particularly system proxy/PAC resolution, custom CA handling, and the ChatGPT Cloudflare cookie policy. It also leaves no clear crate boundary for migrating direct `reqwest` usage behind a single Codex abstraction. This change establishes that low-level ownership boundary without changing request behavior. It builds on the system proxy support introduced in #26706, #26707, #26708, and #26709. ## What changed - Added `codex-rs/http-client` as the `codex-http-client` crate. - Moved request/response types, the concrete `reqwest` transport, custom CA handling, Cloudflare cookie policy, and macOS/Windows proxy resolution into the new crate. - Kept retry, SSE, and request-telemetry policy in `codex-client`. - Re-exported the moved API from `codex-client`, including compatibility aliases for `CodexHttpClient` and `CodexRequestBuilder`, so existing consumers do not change in this PR. - Moved the existing proxy and custom-CA tests with their implementation. ## Scope boundary This PR deliberately stops at the crate extraction. Stacked follow-up #31331 migrates downstream imports from `codex-client` to `codex-http-client`, keeping this change focused on ownership and compatibility rather than mixing in repository-wide call-site churn. ## Review guide GitHub reports 30 changed files, of which 17 are detected renames. A useful review order is: 1. Review the new boundary in `codex-rs/http-client/Cargo.toml` and `codex-rs/http-client/src/lib.rs`. 2. Review `codex-rs/codex-client/Cargo.toml` and `codex-rs/codex-client/src/lib.rs` for what remains in the higher-level crate and how compatibility is preserved. 3. Treat the renamed implementation and test files as moves. Their meaningful edits are limited to crate paths and normalizing the new crate's type names to `HttpClient` and `RequestBuilder`. 4. Review `codex-rs/Cargo.toml`, `codex-rs/Cargo.lock`, and the two `BUILD.bazel` files as mechanical workspace integration. ## Test plan - `just test -p codex-http-client -p codex-client` (38 tests) - Compile-checked the unchanged `codex-api`, `codex-backend-client`, `codex-cloud-tasks`, `codex-exec-server`, `codex-login`, and `codex-model-provider` consumers against the compatibility re-exports. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/31323). * #31331 * __->__ #31323 --- codex-rs/Cargo.lock | 54 +++++++++++-------- codex-rs/Cargo.toml | 2 + codex-rs/codex-client/BUILD.bazel | 1 - codex-rs/codex-client/Cargo.toml | 35 +----------- codex-rs/codex-client/README.md | 6 +-- codex-rs/codex-client/src/lib.rs | 42 ++------------- codex-rs/codex-client/src/retry.rs | 4 +- codex-rs/codex-client/src/sse.rs | 4 +- codex-rs/codex-client/src/telemetry.rs | 2 +- codex-rs/http-client/BUILD.bazel | 7 +++ codex-rs/http-client/Cargo.toml | 49 +++++++++++++++++ codex-rs/http-client/README.md | 9 ++++ .../src/bin/custom_ca_probe.rs | 8 +-- .../src/chatgpt_cloudflare_cookies.rs | 2 +- .../src/chatgpt_hosts.rs | 2 +- .../src/custom_ca.rs | 2 +- .../src/default_client.rs | 16 +++--- .../src/error.rs | 2 + codex-rs/http-client/src/lib.rs | 40 ++++++++++++++ .../src/outbound_proxy.rs | 2 +- .../src/outbound_proxy/macos.rs | 2 + .../src/outbound_proxy/windows.rs | 2 + .../src/outbound_proxy/windows_tests.rs | 2 + .../src/outbound_proxy_tests.rs | 2 + .../src/request.rs | 6 +-- .../src/transport.rs | 10 ++-- .../tests/ca_env.rs | 2 +- .../tests/fixtures/test-ca-trusted.pem | 0 .../tests/fixtures/test-ca.pem | 0 .../tests/fixtures/test-intermediate.pem | 0 30 files changed, 187 insertions(+), 128 deletions(-) create mode 100644 codex-rs/http-client/BUILD.bazel create mode 100644 codex-rs/http-client/Cargo.toml create mode 100644 codex-rs/http-client/README.md rename codex-rs/{codex-client => http-client}/src/bin/custom_ca_probe.rs (91%) rename codex-rs/{codex-client => http-client}/src/chatgpt_cloudflare_cookies.rs (98%) rename codex-rs/{codex-client => http-client}/src/chatgpt_hosts.rs (96%) rename codex-rs/{codex-client => http-client}/src/custom_ca.rs (99%) rename codex-rs/{codex-client => http-client}/src/default_client.rs (93%) rename codex-rs/{codex-client => http-client}/src/error.rs (91%) create mode 100644 codex-rs/http-client/src/lib.rs rename codex-rs/{codex-client => http-client}/src/outbound_proxy.rs (99%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy/macos.rs (99%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy/windows.rs (99%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy/windows_tests.rs (93%) rename codex-rs/{codex-client => http-client}/src/outbound_proxy_tests.rs (99%) rename codex-rs/{codex-client => http-client}/src/request.rs (98%) rename codex-rs/{codex-client => http-client}/src/transport.rs (94%) rename codex-rs/{codex-client => http-client}/tests/ca_env.rs (99%) rename codex-rs/{codex-client => http-client}/tests/fixtures/test-ca-trusted.pem (100%) rename codex-rs/{codex-client => http-client}/tests/fixtures/test-ca.pem (100%) rename codex-rs/{codex-client => http-client}/tests/fixtures/test-intermediate.pem (100%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c0ff4a585a..c6eeb7f978 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2392,33 +2392,12 @@ dependencies = [ name = "codex-client" version = "0.0.0" dependencies = [ - "bytes", - "codex-utils-cargo-bin", - "codex-utils-rustls-provider", + "codex-http-client", "eventsource-stream", "futures", "http 1.4.0", - "opentelemetry", - "opentelemetry_sdk", - "pretty_assertions", "rand 0.9.3", - "rcgen", - "reqwest 0.12.28", - "rustls", - "rustls-native-certs", - "rustls-pki-types", - "serde", - "serde_json", - "sha2 0.10.9", - "system-configuration", - "tempfile", - "thiserror 2.0.18", "tokio", - "tracing", - "tracing-opentelemetry", - "tracing-subscriber", - "windows-sys 0.52.0", - "zstd 0.13.3", ] [[package]] @@ -3214,6 +3193,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "codex-http-client" +version = "0.0.0" +dependencies = [ + "bytes", + "codex-utils-cargo-bin", + "codex-utils-rustls-provider", + "futures", + "http 1.4.0", + "opentelemetry", + "opentelemetry_sdk", + "pretty_assertions", + "rcgen", + "reqwest 0.12.28", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "sha2 0.10.9", + "system-configuration", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-opentelemetry", + "tracing-subscriber", + "windows-sys 0.52.0", + "zstd 0.13.3", +] + [[package]] name = "codex-image-generation-extension" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 2f73c216a9..ad08ce8e02 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -41,6 +41,7 @@ members = [ "core-plugins", "core-skills", "hooks", + "http-client", "secrets", "exec", "file-system", @@ -164,6 +165,7 @@ codex-cloud-tasks-mock-client = { path = "cloud-tasks-mock-client" } codex-code-mode = { path = "code-mode" } codex-code-mode-protocol = { path = "code-mode-protocol" } codex-home = { path = "codex-home" } +codex-http-client = { path = "http-client" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } codex-connectors-extension = { path = "ext/connectors" } diff --git a/codex-rs/codex-client/BUILD.bazel b/codex-rs/codex-client/BUILD.bazel index 3d26819111..dd7e504634 100644 --- a/codex-rs/codex-client/BUILD.bazel +++ b/codex-rs/codex-client/BUILD.bazel @@ -2,6 +2,5 @@ load("//:defs.bzl", "codex_rust_crate") codex_rust_crate( name = "codex-client", - compile_data = glob(["tests/fixtures/**"]), crate_name = "codex_client", ) diff --git a/codex-rs/codex-client/Cargo.toml b/codex-rs/codex-client/Cargo.toml index fcd97b6bda..8e354ebeea 100644 --- a/codex-rs/codex-client/Cargo.toml +++ b/codex-rs/codex-client/Cargo.toml @@ -5,47 +5,16 @@ name = "codex-client" version.workspace = true [dependencies] -bytes = { workspace = true } +codex-http-client = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } http = { workspace = true } -opentelemetry = { workspace = true } rand = { workspace = true } -reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] } -rustls = { workspace = true } -rustls-native-certs = { workspace = true } -rustls-pki-types = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -thiserror = { workspace = true } tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] } -tracing = { workspace = true } -tracing-opentelemetry = { workspace = true } -codex-utils-rustls-provider = { workspace = true } -zstd = { workspace = true } - -[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", -] } [lints] workspace = true -[dev-dependencies] -codex-utils-cargo-bin = { workspace = true } -opentelemetry_sdk = { workspace = true } -pretty_assertions = { workspace = true } -rcgen = { workspace = true } -tempfile = { workspace = true } -tracing-subscriber = { workspace = true } - [lib] doctest = false +test = false diff --git a/codex-rs/codex-client/README.md b/codex-rs/codex-client/README.md index 045ee7b343..1e4073117a 100644 --- a/codex-rs/codex-client/README.md +++ b/codex-rs/codex-client/README.md @@ -1,8 +1,8 @@ # codex-client -Generic transport layer that wraps HTTP requests, retries, and streaming primitives without any Codex/OpenAI awareness. +Higher-level request policy layered on `codex-http-client` without any Codex/OpenAI API awareness. -- Defines `HttpTransport` and a default `ReqwestTransport` plus thin `Request`/`Response` types. - Provides retry utilities (`RetryPolicy`, `RetryOn`, `run_with_retry`, `backoff`) that callers plug into for unary and streaming calls. - Supplies the `sse_stream` helper to turn byte streams into raw SSE `data:` frames with idle timeouts and surfaced stream errors. -- Consumed by higher-level crates like `codex-api`; it stays neutral on endpoints, headers, or API-specific error shapes. +- Defines the request telemetry callback used by higher-level clients. +- Re-exports the low-level HTTP types temporarily so consumers can migrate to `codex-http-client` incrementally. diff --git a/codex-rs/codex-client/src/lib.rs b/codex-rs/codex-client/src/lib.rs index 78daa6c288..4b83ba0dd0 100644 --- a/codex-rs/codex-client/src/lib.rs +++ b/codex-rs/codex-client/src/lib.rs @@ -1,49 +1,13 @@ -mod chatgpt_cloudflare_cookies; -mod chatgpt_hosts; -mod custom_ca; -mod default_client; -mod error; -mod outbound_proxy; -mod request; mod retry; mod sse; mod telemetry; -mod transport; -pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; -pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; -pub use crate::custom_ca::BuildCustomCaTransportError; -/// Test-only subprocess hook for custom CA coverage. -/// -/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It -/// is hidden from normal docs because ordinary callers should use -/// [`build_reqwest_client_with_custom_ca`] instead. -#[doc(hidden)] -pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests; -pub use crate::custom_ca::build_reqwest_client_with_custom_ca; -pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; -pub use crate::default_client::CodexHttpClient; -pub use crate::default_client::CodexRequestBuilder; -pub use crate::error::StreamError; -pub use crate::error::TransportError; -pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; -pub use crate::outbound_proxy::ClientRouteClass; -pub use crate::outbound_proxy::OutboundProxyConfig; -pub use crate::outbound_proxy::RouteFailureClass; -pub use crate::outbound_proxy::build_reqwest_client_for_route; -pub use crate::request::EncodedJsonBody; -pub use crate::request::PreparedRequestBody; -pub use crate::request::Request; -pub use crate::request::RequestBody; -pub use crate::request::RequestCompression; -pub use crate::request::Response; pub use crate::retry::RetryOn; pub use crate::retry::RetryPolicy; pub use crate::retry::backoff; pub use crate::retry::run_with_retry; pub use crate::sse::sse_stream; pub use crate::telemetry::RequestTelemetry; -pub use crate::transport::ByteStream; -pub use crate::transport::HttpTransport; -pub use crate::transport::ReqwestTransport; -pub use crate::transport::StreamResponse; +pub use codex_http_client::HttpClient as CodexHttpClient; +pub use codex_http_client::RequestBuilder as CodexRequestBuilder; +pub use codex_http_client::*; diff --git a/codex-rs/codex-client/src/retry.rs b/codex-rs/codex-client/src/retry.rs index c7bdd34b1e..9eb408878b 100644 --- a/codex-rs/codex-client/src/retry.rs +++ b/codex-rs/codex-client/src/retry.rs @@ -1,5 +1,5 @@ -use crate::error::TransportError; -use crate::request::Request; +use codex_http_client::Request; +use codex_http_client::TransportError; use rand::Rng; use std::future::Future; use std::time::Duration; diff --git a/codex-rs/codex-client/src/sse.rs b/codex-rs/codex-client/src/sse.rs index f3aba3a2c5..ed1591f6de 100644 --- a/codex-rs/codex-client/src/sse.rs +++ b/codex-rs/codex-client/src/sse.rs @@ -1,5 +1,5 @@ -use crate::error::StreamError; -use crate::transport::ByteStream; +use codex_http_client::ByteStream; +use codex_http_client::StreamError; use eventsource_stream::Eventsource; use futures::StreamExt; use tokio::sync::mpsc; diff --git a/codex-rs/codex-client/src/telemetry.rs b/codex-rs/codex-client/src/telemetry.rs index 457d47f4fc..b856414d33 100644 --- a/codex-rs/codex-client/src/telemetry.rs +++ b/codex-rs/codex-client/src/telemetry.rs @@ -1,4 +1,4 @@ -use crate::error::TransportError; +use codex_http_client::TransportError; use http::StatusCode; use std::time::Duration; diff --git a/codex-rs/http-client/BUILD.bazel b/codex-rs/http-client/BUILD.bazel new file mode 100644 index 0000000000..6092c05bf2 --- /dev/null +++ b/codex-rs/http-client/BUILD.bazel @@ -0,0 +1,7 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "http-client", + compile_data = glob(["tests/fixtures/**"]), + crate_name = "codex_http_client", +) diff --git a/codex-rs/http-client/Cargo.toml b/codex-rs/http-client/Cargo.toml new file mode 100644 index 0000000000..46afa51ba5 --- /dev/null +++ b/codex-rs/http-client/Cargo.toml @@ -0,0 +1,49 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-http-client" +version.workspace = true + +[dependencies] +bytes = { workspace = true } +codex-utils-rustls-provider = { workspace = true } +futures = { workspace = true } +http = { workspace = true } +opentelemetry = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] } +rustls = { workspace = true } +rustls-native-certs = { workspace = true } +rustls-pki-types = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["macros", "rt", "time", "sync"] } +tracing = { workspace = true } +tracing-opentelemetry = { workspace = true } +zstd = { workspace = true } + +[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", +] } + +[lints] +workspace = true + +[dev-dependencies] +codex-utils-cargo-bin = { workspace = true } +opentelemetry_sdk = { workspace = true } +pretty_assertions = { workspace = true } +rcgen = { workspace = true } +tempfile = { workspace = true } +tracing-subscriber = { workspace = true } + +[lib] +doctest = false diff --git a/codex-rs/http-client/README.md b/codex-rs/http-client/README.md new file mode 100644 index 0000000000..89e35ac59d --- /dev/null +++ b/codex-rs/http-client/README.md @@ -0,0 +1,9 @@ +# codex-http-client + +Low-level HTTP transport shared by Codex crates. + +- Defines the request, response, streaming, and transport types used for outbound HTTP calls. +- Owns the `reqwest` implementation, custom CA handling, and ChatGPT Cloudflare cookie policy. +- Resolves system, PAC/WPAD, environment, and direct proxy routes for supported clients. + +Higher-level retry, SSE, and request telemetry policy remains in `codex-client`. diff --git a/codex-rs/codex-client/src/bin/custom_ca_probe.rs b/codex-rs/http-client/src/bin/custom_ca_probe.rs similarity index 91% rename from codex-rs/codex-client/src/bin/custom_ca_probe.rs rename to codex-rs/http-client/src/bin/custom_ca_probe.rs index 81f5ba9bc2..c8db21510d 100644 --- a/codex-rs/codex-client/src/bin/custom_ca_probe.rs +++ b/codex-rs/http-client/src/bin/custom_ca_probe.rs @@ -10,9 +10,9 @@ //! - error messages guide users when CA files are invalid. //! - optional HTTPS probes can complete a request through the constructed client. //! -//! The detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`. +//! The detailed explanation of what "hermetic" means here lives in `codex_http_client::custom_ca`. //! This binary exists so the tests can exercise -//! [`codex_client::build_reqwest_client_for_subprocess_tests`] in a separate process without +//! [`codex_http_client::build_reqwest_client_for_subprocess_tests`] in a separate process without //! duplicating client-construction logic. use std::env; @@ -69,11 +69,11 @@ fn build_probe_client( if let Some(proxy_url) = proxy_url { let proxy = reqwest::Proxy::https(proxy_url) .map_err(|error| format!("failed to configure probe proxy {proxy_url}: {error}"))?; - return codex_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy)) + return codex_http_client::build_reqwest_client_with_custom_ca(builder.proxy(proxy)) .map_err(|error| error.to_string()); } - codex_client::build_reqwest_client_for_subprocess_tests(builder) + codex_http_client::build_reqwest_client_for_subprocess_tests(builder) .map_err(|error| error.to_string()) } diff --git a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs b/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs similarity index 98% rename from codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs rename to codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs index 7e5d22057c..f63763dad4 100644 --- a/codex-rs/codex-client/src/chatgpt_cloudflare_cookies.rs +++ b/codex-rs/http-client/src/chatgpt_cloudflare_cookies.rs @@ -7,7 +7,7 @@ use reqwest::header::HeaderValue; use crate::chatgpt_hosts::is_allowed_chatgpt_host; -// WARNING: this store is process-global and may be shared across auth contexts. +// WARNING: this HTTP cookie store is process-global and may be shared across auth contexts. // It must only ever contain Cloudflare infrastructure cookies. Never extend this // store to persist ChatGPT account, session, auth, or other user-specific cookie // data. diff --git a/codex-rs/codex-client/src/chatgpt_hosts.rs b/codex-rs/http-client/src/chatgpt_hosts.rs similarity index 96% rename from codex-rs/codex-client/src/chatgpt_hosts.rs rename to codex-rs/http-client/src/chatgpt_hosts.rs index dd0b99589c..0426c8bb36 100644 --- a/codex-rs/codex-client/src/chatgpt_hosts.rs +++ b/codex-rs/http-client/src/chatgpt_hosts.rs @@ -1,5 +1,5 @@ /// Returns whether `host` is one of the ChatGPT hosts Codex is allowed to treat -/// as first-party ChatGPT traffic. +/// as first-party ChatGPT HTTP traffic. pub fn is_allowed_chatgpt_host(host: &str) -> bool { const EXACT_HOSTS: &[&str] = &["chatgpt.com", "chat.openai.com", "chatgpt-staging.com"]; const SUBDOMAIN_SUFFIXES: &[&str] = &[".chatgpt.com", ".chatgpt-staging.com"]; diff --git a/codex-rs/codex-client/src/custom_ca.rs b/codex-rs/http-client/src/custom_ca.rs similarity index 99% rename from codex-rs/codex-client/src/custom_ca.rs rename to codex-rs/http-client/src/custom_ca.rs index 1a211beedf..4cfae8f23d 100644 --- a/codex-rs/codex-client/src/custom_ca.rs +++ b/codex-rs/http-client/src/custom_ca.rs @@ -1,4 +1,4 @@ -//! Custom CA handling for Codex outbound HTTP and websocket clients. +//! Custom CA handling shared by Codex outbound HTTP and websocket clients. //! //! Codex constructs outbound reqwest clients and secure websocket connections in a few crates, but //! they all need the same trust-store policy when enterprise proxies or gateways intercept TLS. diff --git a/codex-rs/codex-client/src/default_client.rs b/codex-rs/http-client/src/default_client.rs similarity index 93% rename from codex-rs/codex-client/src/default_client.rs rename to codex-rs/http-client/src/default_client.rs index 56b3ce4b16..b4465e7f8c 100644 --- a/codex-rs/codex-client/src/default_client.rs +++ b/codex-rs/http-client/src/default_client.rs @@ -14,47 +14,47 @@ use tracing::Span; use tracing_opentelemetry::OpenTelemetrySpanExt; #[derive(Clone, Debug)] -pub struct CodexHttpClient { +pub struct HttpClient { inner: reqwest::Client, } -impl CodexHttpClient { +impl HttpClient { pub fn new(inner: reqwest::Client) -> Self { Self { inner } } - pub fn get(&self, url: U) -> CodexRequestBuilder + pub fn get(&self, url: U) -> RequestBuilder where U: IntoUrl, { self.request(Method::GET, url) } - pub fn post(&self, url: U) -> CodexRequestBuilder + pub fn post(&self, url: U) -> RequestBuilder where U: IntoUrl, { self.request(Method::POST, url) } - pub fn request(&self, method: Method, url: U) -> CodexRequestBuilder + pub fn request(&self, method: Method, url: U) -> RequestBuilder where U: IntoUrl, { let url_str = url.as_str().to_string(); - CodexRequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) + RequestBuilder::new(self.inner.request(method.clone(), url), method, url_str) } } #[must_use = "requests are not sent unless `send` is awaited"] #[derive(Debug)] -pub struct CodexRequestBuilder { +pub struct RequestBuilder { builder: reqwest::RequestBuilder, method: Method, url: String, } -impl CodexRequestBuilder { +impl RequestBuilder { fn new(builder: reqwest::RequestBuilder, method: Method, url: String) -> Self { Self { builder, diff --git a/codex-rs/codex-client/src/error.rs b/codex-rs/http-client/src/error.rs similarity index 91% rename from codex-rs/codex-client/src/error.rs rename to codex-rs/http-client/src/error.rs index fa2bfb4f79..6b49229026 100644 --- a/codex-rs/codex-client/src/error.rs +++ b/codex-rs/http-client/src/error.rs @@ -1,3 +1,5 @@ +//! Errors returned by the shared Codex HTTP transport. + use http::HeaderMap; use http::StatusCode; use thiserror::Error; diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs new file mode 100644 index 0000000000..5f90435452 --- /dev/null +++ b/codex-rs/http-client/src/lib.rs @@ -0,0 +1,40 @@ +mod chatgpt_cloudflare_cookies; +mod chatgpt_hosts; +mod custom_ca; +mod default_client; +mod error; +mod outbound_proxy; +mod request; +mod transport; + +pub use crate::chatgpt_cloudflare_cookies::with_chatgpt_cloudflare_cookie_store; +pub use crate::chatgpt_hosts::is_allowed_chatgpt_host; +pub use crate::custom_ca::BuildCustomCaTransportError; +/// Test-only subprocess hook for custom CA coverage. +/// +/// This stays public only so the `custom_ca_probe` binary target can reuse the shared helper. It +/// is hidden from normal docs because ordinary callers should use +/// [`build_reqwest_client_with_custom_ca`] instead. +#[doc(hidden)] +pub use crate::custom_ca::build_reqwest_client_for_subprocess_tests; +pub use crate::custom_ca::build_reqwest_client_with_custom_ca; +pub use crate::custom_ca::maybe_build_rustls_client_config_with_custom_ca; +pub use crate::default_client::HttpClient; +pub use crate::default_client::RequestBuilder; +pub use crate::error::StreamError; +pub use crate::error::TransportError; +pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; +pub use crate::outbound_proxy::ClientRouteClass; +pub use crate::outbound_proxy::OutboundProxyConfig; +pub use crate::outbound_proxy::RouteFailureClass; +pub use crate::outbound_proxy::build_reqwest_client_for_route; +pub use crate::request::EncodedJsonBody; +pub use crate::request::PreparedRequestBody; +pub use crate::request::Request; +pub use crate::request::RequestBody; +pub use crate::request::RequestCompression; +pub use crate::request::Response; +pub use crate::transport::ByteStream; +pub use crate::transport::HttpTransport; +pub use crate::transport::ReqwestTransport; +pub use crate::transport::StreamResponse; diff --git a/codex-rs/codex-client/src/outbound_proxy.rs b/codex-rs/http-client/src/outbound_proxy.rs similarity index 99% rename from codex-rs/codex-client/src/outbound_proxy.rs rename to codex-rs/http-client/src/outbound_proxy.rs index ea6587052e..e643698a8c 100644 --- a/codex-rs/codex-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -1,4 +1,4 @@ -//! Conservative outbound proxy selection for resolver-aware clients. +//! Conservative outbound proxy selection for resolver-aware HTTP clients. //! //! When enabled, platform system discovery is tried first, explicit environment //! proxies are the fallback, and the final fallback is a direct connection. diff --git a/codex-rs/codex-client/src/outbound_proxy/macos.rs b/codex-rs/http-client/src/outbound_proxy/macos.rs similarity index 99% rename from codex-rs/codex-client/src/outbound_proxy/macos.rs rename to codex-rs/http-client/src/outbound_proxy/macos.rs index 3913ecc40b..fe07a6b498 100644 --- a/codex-rs/codex-client/src/outbound_proxy/macos.rs +++ b/codex-rs/http-client/src/outbound_proxy/macos.rs @@ -1,3 +1,5 @@ +//! macOS system proxy resolution through SystemConfiguration and CFNetwork. + use std::ffi::c_void; use std::ptr; use std::time::Duration; diff --git a/codex-rs/codex-client/src/outbound_proxy/windows.rs b/codex-rs/http-client/src/outbound_proxy/windows.rs similarity index 99% rename from codex-rs/codex-client/src/outbound_proxy/windows.rs rename to codex-rs/http-client/src/outbound_proxy/windows.rs index 9e64196705..4ff927723a 100644 --- a/codex-rs/codex-client/src/outbound_proxy/windows.rs +++ b/codex-rs/http-client/src/outbound_proxy/windows.rs @@ -1,3 +1,5 @@ +//! Windows system proxy resolution through WinHTTP. + use std::ffi::c_void; use std::ptr; diff --git a/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs b/codex-rs/http-client/src/outbound_proxy/windows_tests.rs similarity index 93% rename from codex-rs/codex-client/src/outbound_proxy/windows_tests.rs rename to codex-rs/http-client/src/outbound_proxy/windows_tests.rs index 3772c057db..589c06661c 100644 --- a/codex-rs/codex-client/src/outbound_proxy/windows_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy/windows_tests.rs @@ -1,3 +1,5 @@ +//! Windows proxy parsing tests. + use super::*; #[test] diff --git a/codex-rs/codex-client/src/outbound_proxy_tests.rs b/codex-rs/http-client/src/outbound_proxy_tests.rs similarity index 99% rename from codex-rs/codex-client/src/outbound_proxy_tests.rs rename to codex-rs/http-client/src/outbound_proxy_tests.rs index 46868e8f73..60a1d56396 100644 --- a/codex-rs/codex-client/src/outbound_proxy_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy_tests.rs @@ -1,3 +1,5 @@ +//! Shared outbound proxy policy tests. + use super::*; use pretty_assertions::assert_eq; use std::io::Read; diff --git a/codex-rs/codex-client/src/request.rs b/codex-rs/http-client/src/request.rs similarity index 98% rename from codex-rs/codex-client/src/request.rs rename to codex-rs/http-client/src/request.rs index 6d50f7cb57..7608860c3d 100644 --- a/codex-rs/codex-client/src/request.rs +++ b/codex-rs/http-client/src/request.rs @@ -1,7 +1,7 @@ use bytes::Bytes; +use http::HeaderMap; +use http::HeaderValue; use http::Method; -use reqwest::header::HeaderMap; -use reqwest::header::HeaderValue; use serde::Serialize; use serde_json::Value; use std::time::Duration; @@ -121,7 +121,7 @@ impl Request { Some(RequestBody::Json(_) | RequestBody::EncodedJson(_)) ); let trace_bytes = if self.compression != RequestCompression::None - && tracing::enabled!(target: "codex_client::transport", tracing::Level::TRACE) + && tracing::enabled!(target: "codex_http_client::transport", tracing::Level::TRACE) { match self.body.as_ref() { Some(RequestBody::Json(body)) => Some(Bytes::from( diff --git a/codex-rs/codex-client/src/transport.rs b/codex-rs/http-client/src/transport.rs similarity index 94% rename from codex-rs/codex-client/src/transport.rs rename to codex-rs/http-client/src/transport.rs index 708b73bf2b..bda6c28ee9 100644 --- a/codex-rs/codex-client/src/transport.rs +++ b/codex-rs/http-client/src/transport.rs @@ -1,5 +1,5 @@ -use crate::default_client::CodexHttpClient; -use crate::default_client::CodexRequestBuilder; +use crate::default_client::HttpClient; +use crate::default_client::RequestBuilder; use crate::error::TransportError; use crate::request::Request; use crate::request::RequestBody; @@ -35,17 +35,17 @@ pub trait HttpTransport: Send + Sync { #[derive(Clone, Debug)] pub struct ReqwestTransport { - client: CodexHttpClient, + client: HttpClient, } impl ReqwestTransport { pub fn new(client: reqwest::Client) -> Self { Self { - client: CodexHttpClient::new(client), + client: HttpClient::new(client), } } - fn build(&self, req: Request) -> Result { + fn build(&self, req: Request) -> Result { let prepared = req.prepare_body_for_send().map_err(TransportError::Build)?; let Request { diff --git a/codex-rs/codex-client/tests/ca_env.rs b/codex-rs/http-client/tests/ca_env.rs similarity index 99% rename from codex-rs/codex-client/tests/ca_env.rs rename to codex-rs/http-client/tests/ca_env.rs index 7ddd138a33..f35225fee7 100644 --- a/codex-rs/codex-client/tests/ca_env.rs +++ b/codex-rs/http-client/tests/ca_env.rs @@ -3,7 +3,7 @@ //! //! These tests intentionally run through `custom_ca_probe` and //! `build_reqwest_client_for_subprocess_tests` instead of calling the helper in-process. The -//! detailed explanation of what "hermetic" means here lives in `codex_client::custom_ca`; these +//! detailed explanation of what "hermetic" means here lives in `codex_http_client::custom_ca`; these //! tests add the process-level half of that contract by scrubbing inherited CA environment //! variables before each subprocess launch. Most assertions here cover CA file selection, PEM //! parsing, and user-facing errors. The HTTPS probes go further and perform real POSTs against diff --git a/codex-rs/codex-client/tests/fixtures/test-ca-trusted.pem b/codex-rs/http-client/tests/fixtures/test-ca-trusted.pem similarity index 100% rename from codex-rs/codex-client/tests/fixtures/test-ca-trusted.pem rename to codex-rs/http-client/tests/fixtures/test-ca-trusted.pem diff --git a/codex-rs/codex-client/tests/fixtures/test-ca.pem b/codex-rs/http-client/tests/fixtures/test-ca.pem similarity index 100% rename from codex-rs/codex-client/tests/fixtures/test-ca.pem rename to codex-rs/http-client/tests/fixtures/test-ca.pem diff --git a/codex-rs/codex-client/tests/fixtures/test-intermediate.pem b/codex-rs/http-client/tests/fixtures/test-intermediate.pem similarity index 100% rename from codex-rs/codex-client/tests/fixtures/test-intermediate.pem rename to codex-rs/http-client/tests/fixtures/test-intermediate.pem From 641aa1b61902753c2da02443a13bfdd3d039c568 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 6 Jul 2026 18:34:36 -0700 Subject: [PATCH 29/76] Migrate direct HTTP consumers to codex-http-client (#31331) ## Why #31323 introduces `codex-http-client` and leaves compatibility re-exports in `codex-client`. Low-level HTTP consumers should depend on the crate that now owns those APIs rather than continuing through the transitional compatibility layer. This stacked follow-up makes that ownership explicit and moves the repository toward enforcing the abstraction without mixing call-site churn into the extraction itself. ## What changed - Switched `codex-backend-client`, `codex-cloud-tasks`, `codex-exec-server`, `codex-login`, and `codex-model-provider` from `codex-client` to `codex-http-client` where they only use low-level HTTP APIs. - Added the direct dependency to `codex-api` for its custom-CA request and websocket paths while retaining `codex-client` for higher-level retry and transport policy. - Updated imports and normalized login's internal client type name from `CodexHttpClient` to `HttpClient`, while preserving its existing `CodexRequestBuilder` re-export. - Updated `Cargo.lock` to reflect the new direct dependency edges. ## Review guide This PR is intentionally mechanical: 20 files and 92 changed lines, with no runtime logic changes. The largest diff is `codex-rs/login/src/auth/default_client.rs`, where the only semantic-looking changes are type and import renames. The remaining source changes replace `codex_client` import paths with `codex_http_client`; the manifest and lockfile changes mirror those imports. ## Test plan - Compile-checked `codex-api`, `codex-backend-client`, `codex-cloud-tasks`, `codex-exec-server`, `codex-login`, and `codex-model-provider` together. --- codex-rs/Cargo.lock | 11 ++++---- codex-rs/backend-client/Cargo.toml | 2 +- codex-rs/backend-client/src/client.rs | 4 +-- codex-rs/cloud-tasks/Cargo.toml | 2 +- codex-rs/cloud-tasks/src/env_detect.rs | 2 +- codex-rs/codex-api/Cargo.toml | 1 + .../endpoint/realtime_websocket/methods.rs | 2 +- .../src/endpoint/responses_websocket.rs | 2 +- codex-rs/codex-api/src/files.rs | 2 +- codex-rs/exec-server/Cargo.toml | 2 +- .../src/client/reqwest_http_client.rs | 4 +-- codex-rs/login/Cargo.toml | 2 +- codex-rs/login/src/auth/default_client.rs | 26 +++++++++---------- codex-rs/login/src/auth/manager.rs | 8 +++--- .../login/src/auth/personal_access_token.rs | 4 +-- codex-rs/login/src/auth/revoke.rs | 6 ++--- codex-rs/login/src/lib.rs | 2 +- codex-rs/login/src/outbound_proxy.rs | 2 +- codex-rs/model-provider/Cargo.toml | 2 +- .../model-provider/src/amazon_bedrock/auth.rs | 6 ++--- 20 files changed, 47 insertions(+), 45 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c6eeb7f978..9d89f8eb1f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1936,6 +1936,7 @@ dependencies = [ "bytes", "chrono", "codex-client", + "codex-http-client", "codex-protocol", "codex-utils-rustls-provider", "eventsource-stream", @@ -2262,7 +2263,7 @@ dependencies = [ "anyhow", "codex-api", "codex-backend-openapi-models", - "codex-client", + "codex-http-client", "codex-login", "codex-model-provider", "codex-protocol", @@ -2431,11 +2432,11 @@ dependencies = [ "anyhow", "chrono", "clap", - "codex-client", "codex-cloud-tasks-client", "codex-cloud-tasks-mock-client", "codex-core", "codex-git-utils", + "codex-http-client", "codex-login", "codex-model-provider", "codex-tui", @@ -2895,9 +2896,9 @@ dependencies = [ "bytes", "clatter", "codex-api", - "codex-client", "codex-exec-server-protocol", "codex-file-system", + "codex-http-client", "codex-network-proxy", "codex-otel", "codex-protocol", @@ -3315,8 +3316,8 @@ dependencies = [ "base64 0.22.1", "chrono", "codex-agent-identity", - "codex-client", "codex-config", + "codex-http-client", "codex-keyring-store", "codex-model-provider-info", "codex-otel", @@ -3530,8 +3531,8 @@ dependencies = [ "codex-agent-identity", "codex-api", "codex-aws-auth", - "codex-client", "codex-feedback", + "codex-http-client", "codex-login", "codex-model-provider-info", "codex-models-manager", diff --git a/codex-rs/backend-client/Cargo.toml b/codex-rs/backend-client/Cargo.toml index f7b0c8b0f5..4ff5fb9b86 100644 --- a/codex-rs/backend-client/Cargo.toml +++ b/codex-rs/backend-client/Cargo.toml @@ -19,7 +19,7 @@ serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } codex-backend-openapi-models = { path = "../codex-backend-openapi-models" } codex-api = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index be444b6247..a5db25618b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -9,8 +9,8 @@ use crate::types::TokenUsageProfile; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_api::SharedAuthProvider; -use codex_client::build_reqwest_client_with_custom_ca; -use codex_client::with_chatgpt_cloudflare_cookie_store; +use codex_http_client::build_reqwest_client_with_custom_ca; +use codex_http_client::with_chatgpt_cloudflare_cookie_store; use codex_login::CodexAuth; use codex_login::default_client::get_codex_user_agent; use codex_protocol::account::PlanType as AccountPlanType; diff --git a/codex-rs/cloud-tasks/Cargo.toml b/codex-rs/cloud-tasks/Cargo.toml index e7997259a5..d842a95a94 100644 --- a/codex-rs/cloud-tasks/Cargo.toml +++ b/codex-rs/cloud-tasks/Cargo.toml @@ -16,7 +16,7 @@ workspace = true anyhow = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-cloud-tasks-client = { workspace = true } # TODO: codex-cloud-tasks-mock-client should be in dev-dependencies. codex-cloud-tasks-mock-client = { workspace = true } diff --git a/codex-rs/cloud-tasks/src/env_detect.rs b/codex-rs/cloud-tasks/src/env_detect.rs index cd38c7f347..6137f27ea8 100644 --- a/codex-rs/cloud-tasks/src/env_detect.rs +++ b/codex-rs/cloud-tasks/src/env_detect.rs @@ -1,4 +1,4 @@ -use codex_client::build_reqwest_client_with_custom_ca; +use codex_http_client::build_reqwest_client_with_custom_ca; use reqwest::header::CONTENT_TYPE; use reqwest::header::HeaderMap; use std::collections::HashMap; diff --git a/codex-rs/codex-api/Cargo.toml b/codex-rs/codex-api/Cargo.toml index 72aa66a810..084b058e12 100644 --- a/codex-rs/codex-api/Cargo.toml +++ b/codex-rs/codex-api/Cargo.toml @@ -10,6 +10,7 @@ base64 = { workspace = true } bytes = { workspace = true } chrono = { workspace = true } codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-protocol = { workspace = true } codex-utils-rustls-provider = { workspace = true } futures = { workspace = true } diff --git a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs index 0ae443d00a..7b1a74e4ae 100644 --- a/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs +++ b/codex-rs/codex-api/src/endpoint/realtime_websocket/methods.rs @@ -16,7 +16,7 @@ use crate::endpoint::realtime_websocket::protocol::parse_realtime_event; use crate::error::ApiError; use crate::provider::Provider; use codex_client::backoff; -use codex_client::maybe_build_rustls_client_config_with_custom_ca; +use codex_http_client::maybe_build_rustls_client_config_with_custom_ca; use codex_protocol::protocol::ConversationTextRole; use codex_protocol::protocol::RealtimeTranscriptDelta; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 7f261bba36..447450fae1 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -11,7 +11,7 @@ use crate::sse::ResponsesStreamEvent; use crate::sse::process_responses_event; use crate::telemetry::WebsocketTelemetry; use codex_client::TransportError; -use codex_client::maybe_build_rustls_client_config_with_custom_ca; +use codex_http_client::maybe_build_rustls_client_config_with_custom_ca; use codex_utils_rustls_provider::ensure_rustls_crypto_provider; use futures::SinkExt; use futures::StreamExt; diff --git a/codex-rs/codex-api/src/files.rs b/codex-rs/codex-api/src/files.rs index 8bcf1a4d0d..3cd3932d06 100644 --- a/codex-rs/codex-api/src/files.rs +++ b/codex-rs/codex-api/src/files.rs @@ -2,7 +2,7 @@ use std::time::Duration; use crate::AuthProvider; use bytes::Bytes; -use codex_client::build_reqwest_client_with_custom_ca; +use codex_http_client::build_reqwest_client_with_custom_ca; use futures::Stream; use reqwest::StatusCode; use reqwest::header::CONTENT_LENGTH; diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 35fa411021..ba9058f0c6 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -17,7 +17,7 @@ base64 = { workspace = true } bytes = { workspace = true } clatter = { workspace = true } codex-api = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-exec-server-protocol = { workspace = true } codex-file-system = { workspace = true } codex-network-proxy = { workspace = true } diff --git a/codex-rs/exec-server/src/client/reqwest_http_client.rs b/codex-rs/exec-server/src/client/reqwest_http_client.rs index 7ead01c9ba..d0cffa6a32 100644 --- a/codex-rs/exec-server/src/client/reqwest_http_client.rs +++ b/codex-rs/exec-server/src/client/reqwest_http_client.rs @@ -8,9 +8,9 @@ use std::error::Error as StdError; use std::time::Duration; -use codex_client::build_reqwest_client_with_custom_ca; -use codex_client::with_chatgpt_cloudflare_cookie_store; use codex_exec_server_protocol::JSONRPCErrorError; +use codex_http_client::build_reqwest_client_with_custom_ca; +use codex_http_client::with_chatgpt_cloudflare_cookie_store; use futures::FutureExt; use futures::StreamExt; use futures::future::BoxFuture; diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index 04066f5894..3eb253d7dc 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -11,7 +11,7 @@ workspace = true base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } codex-agent-identity = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-config = { workspace = true } codex-keyring-store = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index f72727bfca..1146a89d96 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -1,17 +1,17 @@ //! Default Codex HTTP client: shared `User-Agent`, `originator`, optional residency header, and -//! reqwest/`CodexHttpClient` construction. +//! reqwest/`HttpClient` construction. //! //! Use [`crate::default_client`] or [`codex_login::default_client`] from other crates in this //! 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_http_client::BuildCustomCaTransportError; +use codex_http_client::BuildRouteAwareHttpClientError; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClient; +pub use codex_http_client::RequestBuilder as CodexRequestBuilder; +use codex_http_client::build_reqwest_client_for_route; +use codex_http_client::build_reqwest_client_with_custom_ca; +use codex_http_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; use reqwest::header::HeaderMap; use reqwest::header::HeaderValue; @@ -197,9 +197,9 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String { /// /// 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 { +pub fn create_client() -> HttpClient { let inner = build_reqwest_client(); - CodexHttpClient::new(inner) + HttpClient::new(inner) } /// Builds the default reqwest client used for ordinary Codex HTTP traffic. @@ -282,8 +282,8 @@ pub(crate) fn build_default_auth_reqwest_client( 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) +) -> Result { + build_default_auth_reqwest_client(endpoint, auth_route_config).map(HttpClient::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 844410874d..a2d005dfa6 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -55,8 +55,8 @@ 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; -use codex_client::CodexHttpClient; use codex_config::types::AuthCredentialsStoreMode; +use codex_http_client::HttpClient; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::PlanType as InternalPlanType; use codex_protocol::auth::RefreshTokenFailedError; @@ -171,7 +171,7 @@ pub struct ChatgptAuthTokens { #[derive(Debug, Clone)] struct ChatgptAuthState { auth_dot_json: Arc>>, - client: CodexHttpClient, + client: HttpClient, } const TOKEN_REFRESH_INTERVAL: i64 = 8; @@ -805,7 +805,7 @@ impl ChatgptAuth { &self.storage } - fn client(&self) -> &CodexHttpClient { + fn client(&self) -> &HttpClient { &self.state.client } @@ -1331,7 +1331,7 @@ fn persist_tokens( // The caller is responsible for persisting any returned tokens. async fn request_chatgpt_token_refresh( refresh_token: String, - client: &CodexHttpClient, + client: &HttpClient, ) -> Result { let refresh_request = RefreshRequest { client_id: oauth_client_id(), diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index ea75648d1b..09c6d5d2b3 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -1,4 +1,4 @@ -use codex_client::CodexHttpClient; +use codex_http_client::HttpClient; use codex_protocol::account::PlanType as AccountPlanType; use codex_protocol::auth::PlanType as InternalPlanType; use serde::Deserialize; @@ -77,7 +77,7 @@ impl PersonalAccessTokenAuth { } async fn hydrate_personal_access_token( - client: &CodexHttpClient, + client: &HttpClient, endpoint: &str, access_token: &str, ) -> std::io::Result { diff --git a/codex-rs/login/src/auth/revoke.rs b/codex-rs/login/src/auth/revoke.rs index 076b8b53da..ca22adb571 100644 --- a/codex-rs/login/src/auth/revoke.rs +++ b/codex-rs/login/src/auth/revoke.rs @@ -7,7 +7,7 @@ use serde::Serialize; use std::time::Duration; -use codex_client::CodexHttpClient; +use codex_http_client::HttpClient; use codex_protocol::auth::AuthMode; use super::manager::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; @@ -95,7 +95,7 @@ fn resolved_auth_mode(auth_dot_json: &AuthDotJson) -> AuthMode { } async fn revoke_oauth_token( - client: &CodexHttpClient, + client: &HttpClient, endpoint: &str, token: &str, kind: RevokeTokenKind, @@ -181,7 +181,7 @@ mod tests { .mount(&server) .await; - let client = CodexHttpClient::new(reqwest::Client::new()); + let client = HttpClient::new(reqwest::Client::new()); let endpoint = format!("{}/oauth/revoke", server.uri()); let error = revoke_oauth_token( &client, diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 44c2f1c4d3..7ea415fcc5 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -7,8 +7,8 @@ mod outbound_proxy; mod pkce; mod server; -pub use codex_client::BuildCustomCaTransportError as BuildLoginHttpClientError; pub use codex_config::types::AuthCredentialsStoreMode; +pub use codex_http_client::BuildCustomCaTransportError as BuildLoginHttpClientError; pub use device_code_auth::DeviceCode; pub use device_code_auth::complete_device_code_login; pub use device_code_auth::request_device_code; diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs index bf7dfbeb4d..66441e3637 100644 --- a/codex-rs/login/src/outbound_proxy.rs +++ b/codex-rs/login/src/outbound_proxy.rs @@ -1,4 +1,4 @@ -use codex_client::OutboundProxyConfig; +use codex_http_client::OutboundProxyConfig; /// Auth-layer adapter around client-owned proxy policy. /// diff --git a/codex-rs/model-provider/Cargo.toml b/codex-rs/model-provider/Cargo.toml index 29599c6c7e..b6ee6acbfc 100644 --- a/codex-rs/model-provider/Cargo.toml +++ b/codex-rs/model-provider/Cargo.toml @@ -16,7 +16,7 @@ workspace = true codex-api = { workspace = true } codex-agent-identity = { workspace = true } codex-aws-auth = { workspace = true } -codex-client = { workspace = true } +codex-http-client = { workspace = true } codex-feedback = { workspace = true } codex-login = { workspace = true } codex-model-provider-info = { workspace = true } diff --git a/codex-rs/model-provider/src/amazon_bedrock/auth.rs b/codex-rs/model-provider/src/amazon_bedrock/auth.rs index 6dd897050a..b9b318d9b1 100644 --- a/codex-rs/model-provider/src/amazon_bedrock/auth.rs +++ b/codex-rs/model-provider/src/amazon_bedrock/auth.rs @@ -6,9 +6,9 @@ use codex_api::SharedAuthProvider; use codex_aws_auth::AwsAuthContext; use codex_aws_auth::AwsAuthError; use codex_aws_auth::AwsRequestToSign; -use codex_client::Request; -use codex_client::RequestBody; -use codex_client::RequestCompression; +use codex_http_client::Request; +use codex_http_client::RequestBody; +use codex_http_client::RequestCompression; use codex_login::auth::BedrockApiKeyAuth; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_protocol::error::CodexErr; From 8f3431054fe44dbf0d901bb12c522bb87c64fd47 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Mon, 6 Jul 2026 19:08:50 -0700 Subject: [PATCH 30/76] fix: restore Codex environment setup table (#31337) ## Why Creating a Codex worktree skips local environment setup because `.codex/environments/environment.toml` omits the required `setup` object. ## What Restore an empty `[setup]` table with `script = ""`, preserving the intended no-op setup while satisfying the environment parser schema. ## Validation - Parsed the final TOML and verified `setup.script` is present as a string. --- .codex/environments/environment.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml index b3c5910fbc..ae0bd24ca8 100644 --- a/.codex/environments/environment.toml +++ b/.codex/environments/environment.toml @@ -2,6 +2,10 @@ version = 1 name = "codex" +# TODO(anp) make it optional to specify this field +[setup] +script = "" + [[actions]] name = "Run" icon = "run" From 831c14fc39f7810703263c31a4a712842dba35f4 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Mon, 6 Jul 2026 19:28:44 -0700 Subject: [PATCH 31/76] Preserve managed exec policy after rules parse errors (#31188) ## Why Cleanup for #31179 exposed a core fallback bug that the TUI had been handling locally. When a custom `.rules` file fails to parse, nonfatal clients warn and continue, but `load_exec_policy_with_warning` replaced the entire policy with `Policy::empty()` before managed requirements were merged. App-server and desktop clients could therefore silently lose required prompt and forbidden rules. This fix is intentionally separate from #31179 so the TUI cleanup does not depend on it. ## What changed - Preserve the managed requirements exec policy when custom file rules fail to parse, while returning the existing warning and discarding the file-based policy. - Use the same nonfatal fallback when loading network proxy policy. - Keep parse errors fatal for strict clients such as `codex exec`. --- codex-rs/core/src/exec_policy.rs | 11 ++- codex-rs/core/src/exec_policy_tests.rs | 36 +++++++++ codex-rs/core/src/network_proxy_loader.rs | 27 +++---- .../core/src/network_proxy_loader_tests.rs | 50 ++++++++++++ codex-rs/core/tests/suite/exec_policy.rs | 80 +++++++++++++++++++ 5 files changed, 188 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/exec_policy.rs b/codex-rs/core/src/exec_policy.rs index cbffcd7e43..8f31f150b3 100644 --- a/codex-rs/core/src/exec_policy.rs +++ b/codex-rs/core/src/exec_policy.rs @@ -554,12 +554,19 @@ pub fn format_exec_policy_error_with_source(error: &ExecPolicyError) -> String { } } -async fn load_exec_policy_with_warning( +pub(crate) async fn load_exec_policy_with_warning( config_stack: &ConfigLayerStack, ) -> Result<(Policy, Option), ExecPolicyError> { match load_exec_policy(config_stack).await { Ok(policy) => Ok((policy, None)), - Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))), + Err(err @ ExecPolicyError::ParsePolicy { .. }) => { + let policy = config_stack + .requirements() + .exec_policy + .as_deref() + .map_or_else(Policy::empty, |policy| policy.as_ref().clone()); + Ok((policy, Some(err))) + } Err(err) => Err(err), } } diff --git a/codex-rs/core/src/exec_policy_tests.rs b/codex-rs/core/src/exec_policy_tests.rs index cf17387ef5..95d29e0bac 100644 --- a/codex-rs/core/src/exec_policy_tests.rs +++ b/codex-rs/core/src/exec_policy_tests.rs @@ -374,6 +374,42 @@ async fn merges_requirements_exec_policy_network_rules() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn malformed_custom_rules_preserve_requirements_exec_policy() -> anyhow::Result<()> { + let temp_dir = tempdir()?; + let policy_dir = temp_dir.path().join(RULES_DIR_NAME); + fs::create_dir_all(&policy_dir)?; + fs::write(policy_dir.join("broken.rules"), "prefix_rule(")?; + + let mut requirements_exec_policy = Policy::empty(); + requirements_exec_policy.add_prefix_rule(&["rm".to_string()], Decision::Forbidden)?; + let requirements = ConfigRequirements { + exec_policy: Some(Sourced::new( + RequirementsExecPolicy::new(requirements_exec_policy), + RequirementSource::Unknown, + )), + ..ConfigRequirements::default() + }; + let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path())?; + let layer = ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + TomlValue::Table(Default::default()), + ); + let config_stack = + ConfigLayerStack::new(vec![layer], requirements, ConfigRequirementsToml::default())?; + + let (policy, warning) = load_exec_policy_with_warning(&config_stack).await?; + + assert!(matches!(warning, Some(ExecPolicyError::ParsePolicy { .. }))); + assert_eq!( + policy + .check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow) + .decision, + Decision::Forbidden + ); + Ok(()) +} + #[tokio::test] async fn preserves_host_executables_when_requirements_overlay_is_present() -> anyhow::Result<()> { let temp_dir = tempdir()?; diff --git a/codex-rs/core/src/network_proxy_loader.rs b/codex-rs/core/src/network_proxy_loader.rs index 0a4fe2d7c2..62ed44ad81 100644 --- a/codex-rs/core/src/network_proxy_loader.rs +++ b/codex-rs/core/src/network_proxy_loader.rs @@ -2,9 +2,8 @@ use crate::config::find_codex_home; use crate::config::is_builtin_permission_profile_name; use crate::config::reject_unknown_builtin_permission_profile; use crate::config::resolve_permission_profile; -use crate::exec_policy::ExecPolicyError; use crate::exec_policy::format_exec_policy_error_with_source; -use crate::exec_policy::load_exec_policy; +use crate::exec_policy::load_exec_policy_with_warning; use anyhow::Context; use anyhow::Result; use codex_config::CONFIG_TOML_FILE; @@ -64,13 +63,15 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec (policy, None), - Err(err @ ExecPolicyError::ParsePolicy { .. }) => { - (codex_execpolicy::Policy::empty(), Some(err)) - } - Err(err) => return Err(err.into()), - }; + let layer_mtimes = collect_layer_mtimes(&config_layer_stack); + let state = build_config_state_from_layers(&config_layer_stack).await?; + Ok((state, layer_mtimes)) +} + +async fn build_config_state_from_layers( + config_layer_stack: &ConfigLayerStack, +) -> Result { + let (exec_policy, warning) = load_exec_policy_with_warning(config_layer_stack).await?; if let Some(err) = warning.as_ref() { tracing::warn!( "failed to parse execpolicy while building network proxy state: {}", @@ -78,12 +79,10 @@ async fn build_config_state_with_mtimes() -> Result<(ConfigState, Vec Vec { diff --git a/codex-rs/core/src/network_proxy_loader_tests.rs b/codex-rs/core/src/network_proxy_loader_tests.rs index d6c8efc9ae..f7c7c2e452 100644 --- a/codex-rs/core/src/network_proxy_loader_tests.rs +++ b/codex-rs/core/src/network_proxy_loader_tests.rs @@ -5,6 +5,9 @@ use codex_config::ConfigLayerSource; use codex_config::ConfigLayerStack; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; +use codex_config::RequirementSource; +use codex_config::RequirementsExecPolicy; +use codex_config::Sourced; use codex_config::permissions_toml::NetworkDomainPermissionToml; use codex_config::permissions_toml::NetworkDomainPermissionsToml; use codex_execpolicy::Decision; @@ -12,6 +15,8 @@ use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::fs; +use tempfile::tempdir; #[test] fn higher_precedence_profile_network_overlays_domain_entries() { @@ -225,6 +230,51 @@ fn execpolicy_network_rules_overlay_network_lists() { ); } +#[tokio::test] +async fn malformed_custom_rules_preserve_managed_denied_domain() { + let temp_dir = tempdir().expect("create temp dir"); + let policy_dir = temp_dir.path().join("rules"); + fs::create_dir_all(&policy_dir).expect("create policy dir"); + fs::write(policy_dir.join("broken.rules"), "prefix_rule(").expect("write malformed policy"); + + let mut requirements_exec_policy = Policy::empty(); + requirements_exec_policy + .add_network_rule( + "blocked.example.com", + NetworkRuleProtocol::Https, + Decision::Forbidden, + /*justification*/ None, + ) + .expect("managed network rule should be valid"); + let requirements = ConfigRequirements { + exec_policy: Some(Sourced::new( + RequirementsExecPolicy::new(requirements_exec_policy), + RequirementSource::Unknown, + )), + ..ConfigRequirements::default() + }; + let dot_codex_folder = AbsolutePathBuf::from_absolute_path(temp_dir.path()) + .expect("dot codex folder should be absolute"); + let layers = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::Project { dot_codex_folder }, + toml::Value::Table(Default::default()), + )], + requirements, + ConfigRequirementsToml::default(), + ) + .expect("layer stack should be valid"); + + let state = build_config_state_from_layers(&layers) + .await + .expect("proxy state should tolerate malformed custom rules"); + + assert_eq!( + state.config.network.denied_domains(), + Some(vec!["blocked.example.com".to_string()]) + ); +} + #[test] fn apply_network_constraints_includes_allow_all_unix_sockets_flag() { let config: toml::Value = toml::from_str( diff --git a/codex-rs/core/tests/suite/exec_policy.rs b/codex-rs/core/tests/suite/exec_policy.rs index 4e64c66b0a..0c73b74ab2 100644 --- a/codex-rs/core/tests/suite/exec_policy.rs +++ b/codex-rs/core/tests/suite/exec_policy.rs @@ -1,6 +1,7 @@ #![allow(clippy::unwrap_used)] use anyhow::Result; +use codex_config::test_support::CloudConfigBundleFixture; use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -17,6 +18,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::skip_if_target_windows; 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; @@ -255,6 +257,84 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { Ok(()) } +#[tokio::test] +async fn malformed_custom_rules_preserve_managed_forbidden_prefix() -> Result<()> { + skip_if_target_windows!( + Ok(()), + "managed prefix fixture uses POSIX executable semantics" + ); + + let mut builder = test_codex() + .with_cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement( + r#" +[rules] +prefix_rules = [ + { pattern = [{ token = "echo" }], decision = "forbidden" }, +] +"#, + ), + ) + .with_config(|config| { + config + .features + .enable(Feature::UnifiedExec) + .expect("test config should allow feature update"); + let policy_path = config.codex_home.join("rules").join("broken.rules"); + fs::create_dir_all( + policy_path + .parent() + .expect("policy directory must have a parent"), + ) + .expect("create policy directory"); + fs::write(policy_path, "prefix_rule(").expect("write malformed policy file"); + }); + let server = start_mock_server().await; + let test = builder.build_with_auto_env(&server).await?; + let call_id = "managed-shell-forbidden"; + let args = json!({ + "cmd": "echo blocked", + "yield_time_ms": 1_000, + }); + + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-managed-1"), + ev_function_call(call_id, "exec_command", &serde_json::to_string(&args)?), + ev_completed("resp-managed-1"), + ]), + ) + .await; + let results_mock = mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("msg-managed-1", "done"), + ev_completed("resp-managed-2"), + ]), + ) + .await; + + test.submit_turn_with_approval_and_permission_profile( + "run shell command", + AskForApproval::Never, + PermissionProfile::Disabled, + ) + .await?; + + let output_item = results_mock.single_request().function_call_output(call_id); + let output = output_item + .get("output") + .and_then(Value::as_str) + .expect("function call output should include a string output payload"); + assert!( + output.contains("policy forbids commands starting with `echo`"), + "unexpected output: {output}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn shell_command_empty_script_with_collaboration_mode_does_not_panic() -> Result<()> { let server = start_mock_server().await; From 775ef7dcc7d50069e759f3e11454f412a3e39d95 Mon Sep 17 00:00:00 2001 From: ashwinnathan-openai Date: Mon, 6 Jul 2026 23:23:56 -0400 Subject: [PATCH 32/76] [codex] Support sequential cutoff reasoning summaries (#31306) ## Summary When enabled for the OpenAI provider, Codex sends `stream_options.reasoning_summary_delivery = "sequential_cutoff"` on HTTP and WebSocket requests, including prewarm, and renders completed summary sections from `reasoning_summary_text.done`. Flag-off and non-OpenAI behavior is unchanged. ## Expected rollout ```text reasoning 0 added summary 0 done summary 1 done summary 2 starts summary 2 cancelled / incomplete reasoning 0 done <-- cancel summary 2 work and mark it incomplete message 1 added message 1 text streams message 1 completed ``` Depends on [openai/openai#1096660](https://github.com/openai/openai/pull/1096660). --- codex-rs/codex-api/src/common.rs | 21 +++++ .../src/endpoint/responses_websocket.rs | 1 + codex-rs/codex-api/src/lib.rs | 2 + codex-rs/codex-api/src/sse/responses.rs | 38 ++++++++ codex-rs/codex-api/tests/clients.rs | 3 + codex-rs/core/config.schema.json | 6 ++ codex-rs/core/src/client.rs | 16 +++- codex-rs/core/src/client_common_tests.rs | 4 + codex-rs/core/src/client_tests.rs | 3 + codex-rs/core/src/session/session.rs | 3 + codex-rs/core/src/session/tests.rs | 9 ++ codex-rs/core/src/session/turn.rs | 45 +++++++++ codex-rs/core/src/turn_timing.rs | 1 + codex-rs/core/tests/responses_headers.rs | 3 + codex-rs/core/tests/suite/client.rs | 58 ++++++++++++ .../core/tests/suite/client_websockets.rs | 42 ++++++++- codex-rs/core/tests/suite/items.rs | 93 +++++++++++++++++++ codex-rs/features/src/lib.rs | 8 ++ codex-rs/memories/write/src/runtime.rs | 1 + codex-rs/otel/src/events/session_telemetry.rs | 1 + 20 files changed, 352 insertions(+), 6 deletions(-) diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 254a5cb38f..e4600e26aa 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -104,6 +104,11 @@ pub enum ResponseEvent { delta: String, summary_index: i64, }, + ReasoningSummaryDone { + item_id: String, + text: String, + summary_index: i64, + }, ReasoningContentDelta { delta: String, content_index: i64, @@ -148,6 +153,17 @@ pub struct Reasoning { pub context: Option, } +#[derive(Debug, Serialize, Clone, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ReasoningSummaryDelivery { + SequentialCutoff, +} + +#[derive(Debug, Serialize, Clone, PartialEq)] +pub struct StreamOptions { + pub reasoning_summary_delivery: ReasoningSummaryDelivery, +} + #[derive(Debug, Serialize, Default, Clone, PartialEq)] #[serde(rename_all = "snake_case")] pub enum TextFormatType { @@ -209,6 +225,8 @@ pub struct ResponsesApiRequest { pub reasoning: Option, pub store: bool, pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, pub include: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub service_tier: Option, @@ -233,6 +251,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { reasoning: request.reasoning.clone(), store: request.store, stream: request.stream, + stream_options: request.stream_options.clone(), include: request.include.clone(), service_tier: request.service_tier.clone(), prompt_cache_key: request.prompt_cache_key.clone(), @@ -258,6 +277,8 @@ pub struct ResponseCreateWsRequest { pub reasoning: Option, pub store: bool, pub stream: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_options: Option, pub include: Vec, #[serde(skip_serializing_if = "Option::is_none")] pub service_tier: Option, diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index 447450fae1..cce056f638 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -851,6 +851,7 @@ mod tests { reasoning: None, store: false, stream: true, + stream_options: None, include: vec!["reasoning.encrypted_content".to_string()], service_tier: Some("priority".to_string()), prompt_cache_key: Some("cache-key".to_string()), diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index 72de4d0585..1d1a4c8c6e 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -34,11 +34,13 @@ pub use crate::common::RawMemory; pub use crate::common::RawMemoryMetadata; pub use crate::common::Reasoning; pub use crate::common::ReasoningContext; +pub use crate::common::ReasoningSummaryDelivery; pub use crate::common::ResponseCreateWsRequest; pub use crate::common::ResponseEvent; pub use crate::common::ResponseStream; pub use crate::common::ResponsesApiRequest; pub use crate::common::ResponsesWsRequest; +pub use crate::common::StreamOptions; pub use crate::common::TextControls; pub use crate::common::WS_REQUEST_HEADER_TRACEPARENT_CLIENT_METADATA_KEY; pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY; diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 17d31a02ee..265d3ced5c 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -167,6 +167,7 @@ pub struct ResponsesStreamEvent { item_id: Option, call_id: Option, delta: Option, + text: Option, summary_index: Option, content_index: Option, safety_buffering: Option, @@ -358,6 +359,17 @@ pub fn process_responses_event( })); } } + "response.reasoning_summary_text.done" => { + if let (Some(item_id), Some(text), Some(summary_index)) = + (event.item_id, event.text, event.summary_index) + { + return Ok(Some(ResponseEvent::ReasoningSummaryDone { + item_id, + text, + summary_index, + })); + } + } "response.reasoning_text.delta" => { if let (Some(delta), Some(content_index)) = (event.delta, event.content_index) { return Ok(Some(ResponseEvent::ReasoningContentDelta { @@ -796,6 +808,32 @@ mod tests { } } + #[tokio::test] + async fn parses_reasoning_summary_done() { + let events = run_sse(vec![ + json!({ + "type": "response.reasoning_summary_text.done", + "item_id": "reasoning-1", + "summary_index": 0, + "text": "Checking", + }), + json!({ + "type": "response.completed", + "response": { "id": "resp1" }, + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::ReasoningSummaryDone { + item_id, + text, + summary_index: 0, + } if item_id == "reasoning-1" && text == "Checking" + ); + } + #[tokio::test] async fn error_when_missing_completed() { let item1 = json!({ diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 60a9852cfa..ab4ab1d08f 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -321,6 +321,7 @@ async fn responses_client_stream_request_preserves_item_ids() -> Result<()> { reasoning: None, store: false, stream: true, + stream_options: None, include: Vec::new(), service_tier: None, prompt_cache_key: None, @@ -407,6 +408,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { reasoning: None, store: false, stream: true, + stream_options: None, include: Vec::new(), service_tier: None, prompt_cache_key: None, @@ -526,6 +528,7 @@ async fn azure_store_sends_ids_and_headers() -> Result<()> { reasoning: None, store: true, stream: true, + stream_options: None, include: Vec::new(), service_tier: None, prompt_cache_key: None, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 876180dd7e..0a48882b09 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -469,6 +469,9 @@ "computer_use": { "type": "boolean" }, + "concurrent_reasoning_summaries": { + "type": "boolean" + }, "connectors": { "type": "boolean" }, @@ -4838,6 +4841,9 @@ "computer_use": { "type": "boolean" }, + "concurrent_reasoning_summaries": { + "type": "boolean" + }, "connectors": { "type": "boolean" }, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 43a758b0e8..716466b189 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -56,6 +56,7 @@ use codex_api::ResponsesWebsocketConnection as ApiWebSocketConnection; use codex_api::ResponsesWsRequest; use codex_api::SharedAuthProvider; use codex_api::SseTelemetry; +use codex_api::StreamOptions; use codex_api::TransportError; use codex_api::WebsocketTelemetry; use codex_api::auth_header_telemetry; @@ -203,6 +204,7 @@ struct ModelClientState { include_timing_metrics: bool, beta_features_header: Option, item_ids_enabled: bool, + concurrent_reasoning_summaries_enabled: bool, include_attestation: bool, attestation_provider: Option>, disable_websockets: AtomicBool, @@ -311,6 +313,7 @@ fn responses_request_properties_match( reasoning: previous_reasoning, store: previous_store, stream: previous_stream, + stream_options: _, include: previous_include, service_tier: previous_service_tier, prompt_cache_key: previous_prompt_cache_key, @@ -327,6 +330,7 @@ fn responses_request_properties_match( reasoning: current_reasoning, store: current_store, stream: current_stream, + stream_options: _, include: current_include, service_tier: current_service_tier, prompt_cache_key: current_prompt_cache_key, @@ -342,6 +346,8 @@ fn responses_request_properties_match( && previous_reasoning == current_reasoning && previous_store == current_store && previous_stream == current_stream + // Stream options control delivery for this response, not the context + // referenced by `previous_response_id`. && previous_include == current_include && previous_service_tier == current_service_tier && previous_prompt_cache_key == current_prompt_cache_key @@ -409,6 +415,7 @@ impl ModelClient { include_timing_metrics: bool, beta_features_header: Option, item_ids_enabled: bool, + concurrent_reasoning_summaries_enabled: bool, attestation_provider: Option>, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); @@ -431,6 +438,7 @@ impl ModelClient { include_timing_metrics, beta_features_header, item_ids_enabled, + concurrent_reasoning_summaries_enabled, include_attestation, attestation_provider, disable_websockets: AtomicBool::new(false), @@ -820,7 +828,8 @@ impl ModelClient { responses_metadata: &CodexResponsesMetadata, ) -> Result { let mut input = prompt.get_formatted_input_for_request(model_info.use_responses_lite); - if !self.state.provider.info().is_openai() { + let is_openai = self.state.provider.info().is_openai(); + if !is_openai { input .iter_mut() .for_each(ResponseItem::clear_internal_chat_message_metadata_passthrough); @@ -848,6 +857,10 @@ impl ModelClient { } else { (prompt.base_instructions.text.clone(), Some(tools)) }; + let stream_options = (self.state.concurrent_reasoning_summaries_enabled && is_openai) + .then_some(StreamOptions { + reasoning_summary_delivery: codex_api::ReasoningSummaryDelivery::SequentialCutoff, + }); let reasoning = Self::build_reasoning(model_info, effort, summary); let include = if reasoning.is_some() { vec!["reasoning.encrypted_content".to_string()] @@ -882,6 +895,7 @@ impl ModelClient { reasoning, store: provider.is_azure_responses_endpoint(), stream: true, + stream_options, include, service_tier, prompt_cache_key, diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 38c4aaecb5..b8d0a364a3 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -116,6 +116,7 @@ fn serializes_text_verbosity_when_set() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: None, @@ -163,6 +164,7 @@ fn serializes_text_schema_with_strict_format() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: None, @@ -224,6 +226,7 @@ fn omits_text_when_not_set() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: None, @@ -247,6 +250,7 @@ fn serializes_flex_service_tier_when_set() { reasoning: None, store: false, stream: true, + stream_options: None, include: vec![], prompt_cache_key: None, service_tier: Some(ServiceTier::Flex.to_string()), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 82c464625d..c05f56b713 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -103,6 +103,7 @@ fn test_model_client_with_thread_id( /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ) } @@ -147,6 +148,7 @@ async fn compact_uses_bearer_after_agent_identity_session_fallback() -> anyhow:: /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ); let prompt = Prompt { @@ -821,6 +823,7 @@ fn model_client_with_counting_attestation( /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, Some(Arc::new(CountingAttestationProvider { calls: attestation_calls.clone(), })), diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 2261eaa495..78a7f02588 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1111,6 +1111,9 @@ impl Session { config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ config + .features + .enabled(Feature::ConcurrentReasoningSummaries), attestation_provider, ) .with_prompt_cache_key_override( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 6cc223cd7d..1ff469cf4c 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -490,6 +490,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ) .new_session() @@ -5460,6 +5461,10 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new( @@ -7586,6 +7591,10 @@ where config.features.enabled(Feature::RuntimeMetrics), Session::build_model_client_beta_features_header(config.as_ref()), /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new( diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index f16b525002..708f42f0ff 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1909,6 +1909,11 @@ async fn try_run_sampling_request( turn_context.provider.info().name.as_str(), ); let sampling_timing_guard = turn_context.turn_timing_state.begin_sampling(); + let uses_sequential_cutoff_reasoning_summaries = turn_context + .config + .features + .enabled(Feature::ConcurrentReasoningSummaries) + && turn_context.provider.info().is_openai(); let mut stream = client_session .stream( prompt, @@ -2291,6 +2296,9 @@ async fn try_run_sampling_request( delta, summary_index, } => { + if uses_sequential_cutoff_reasoning_summaries { + continue; + } if let Some(active) = active_item.as_ref() { if !active_item_is_streaming_to_client { continue; @@ -2309,6 +2317,9 @@ async fn try_run_sampling_request( } } ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { + if uses_sequential_cutoff_reasoning_summaries { + continue; + } if let Some(active) = active_item.as_ref() { if !active_item_is_streaming_to_client { continue; @@ -2323,6 +2334,40 @@ async fn try_run_sampling_request( error_or_panic("ReasoningSummaryPartAdded without active item".to_string()); } } + ResponseEvent::ReasoningSummaryDone { + item_id, + text, + summary_index, + } => { + if !uses_sequential_cutoff_reasoning_summaries { + continue; + } + let Some(active) = active_item.as_ref() else { + continue; + }; + if !active_item_is_streaming_to_client || active.id() != item_id { + continue; + } + if summary_index > 0 { + sess.send_event( + &turn_context, + EventMsg::AgentReasoningSectionBreak(AgentReasoningSectionBreakEvent { + item_id: item_id.clone(), + summary_index, + }), + ) + .await; + } + let event = ReasoningContentDeltaEvent { + thread_id: sess.thread_id.to_string(), + turn_id: turn_context.sub_id.clone(), + item_id, + delta: text, + summary_index, + }; + sess.send_event(&turn_context, EventMsg::ReasoningContentDelta(event)) + .await; + } ResponseEvent::ReasoningContentDelta { delta, content_index, diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 04f7aeb69b..d0f4f46cc3 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -334,6 +334,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { } ResponseEvent::OutputTextDelta(_) | ResponseEvent::ReasoningSummaryDelta { .. } + | ResponseEvent::ReasoningSummaryDone { .. } | ResponseEvent::ReasoningContentDelta { .. } => true, ResponseEvent::Created | ResponseEvent::ServerModel(_) diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 5b7c2c65b8..4c5f906160 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -131,6 +131,7 @@ async fn responses_stream_includes_subagent_header_on_review() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -265,6 +266,7 @@ async fn responses_stream_includes_subagent_header_on_other() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); @@ -385,6 +387,7 @@ async fn responses_respects_model_info_overrides_from_config() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index c87539f940..92624bfcb3 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1334,6 +1334,10 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); @@ -2376,6 +2380,9 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { let TestCodex { codex, .. } = test_codex() .with_config(|config| { config.model_reasoning_summary = Some(ReasoningSummary::Concise); + let _ = config + .features + .enable(Feature::ConcurrentReasoningSummaries); }) .build(&server) .await?; @@ -2412,6 +2419,55 @@ async fn configured_reasoning_summary_is_sent() -> anyhow::Result<()> { .and_then(|reasoning| reasoning.get("context")), None ); + pretty_assertions::assert_eq!( + request_body + .get("stream_options") + .and_then(|stream_options| stream_options.get("reasoning_summary_delivery")) + .and_then(|value| value.as_str()), + Some("sequential_cutoff") + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sequential_cutoff_is_omitted_for_non_openai_provider() -> anyhow::Result<()> { + skip_if_no_network!(Ok(())); + let server = MockServer::start().await; + + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config.model_provider.name = "mock".to_string(); + let _ = config + .features + .enable(Feature::ConcurrentReasoningSummaries); + }) + .build(&server) + .await?; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "hello".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await + .unwrap(); + + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + + let request_body = resp_mock.single_request().body_json(); + pretty_assertions::assert_eq!(request_body.get("stream_options"), None); Ok(()) } @@ -2583,6 +2639,7 @@ async fn reasoning_summary_is_omitted_when_disabled() -> anyhow::Result<()> { .and_then(|reasoning| reasoning.get("summary")), None ); + pretty_assertions::assert_eq!(request_body.get("stream_options"), None); Ok(()) } @@ -2948,6 +3005,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*include_timing_metrics*/ false, /*beta_features_header*/ None, /*item_ids_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index d5218cdcab..c56a8680ab 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -386,7 +386,13 @@ async fn responses_websocket_request_prewarm_reuses_connection() { ]]) .await; - let harness = websocket_harness_with_options(&server, /*runtime_metrics_enabled*/ true).await; + let mut provider = websocket_provider(&server); + provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; + let harness = websocket_harness_with_provider_options( + provider, /*runtime_metrics_enabled*/ true, + /*concurrent_reasoning_summaries_enabled*/ true, + ) + .await; let mut client_session = harness.client.new_session(); let prompt = prompt_with_input(vec![message_item("hello")]); let responses_metadata = prewarm_metadata(&harness, /*turn_id*/ None); @@ -422,6 +428,10 @@ async fn responses_websocket_request_prewarm_reuses_connection() { assert_eq!(warmup["type"].as_str(), Some("response.create")); assert_eq!(warmup["generate"].as_bool(), Some(false)); + assert_eq!( + warmup["stream_options"]["reasoning_summary_delivery"].as_str(), + Some("sequential_cutoff") + ); assert_eq!(warmup["tools"], serde_json::json!([])); let warmup_turn_metadata: serde_json::Value = serde_json::from_str( warmup["client_metadata"]["x-codex-turn-metadata"] @@ -436,6 +446,10 @@ async fn responses_websocket_request_prewarm_reuses_connection() { assert_eq!(follow_up["type"].as_str(), Some("response.create")); assert_eq!(follow_up["previous_response_id"].as_str(), Some("warm-1")); assert_eq!(follow_up["input"], serde_json::json!([])); + assert_eq!( + follow_up["stream_options"]["reasoning_summary_delivery"].as_str(), + Some("sequential_cutoff") + ); server.shutdown().await; } @@ -990,8 +1004,11 @@ async fn responses_websocket_v2_incremental_requests_are_reused_across_turns() { // use OpenAI provider to check metadata logic let mut provider = websocket_provider(&server); provider.name = ModelProviderInfo::create_openai_provider(/*base_url*/ None).name; - let harness = - websocket_harness_with_provider_options(provider, /*runtime_metrics_enabled*/ false).await; + let harness = websocket_harness_with_provider_options( + provider, /*runtime_metrics_enabled*/ false, + /*concurrent_reasoning_summaries_enabled*/ false, + ) + .await; // Turn one: initiate let prompt_one = prompt_with_input(vec![message_item("hello")]); @@ -2195,13 +2212,18 @@ async fn websocket_harness_with_options( server: &WebSocketTestServer, runtime_metrics_enabled: bool, ) -> WebsocketTestHarness { - websocket_harness_with_provider_options(websocket_provider(server), runtime_metrics_enabled) - .await + websocket_harness_with_provider_options( + websocket_provider(server), + runtime_metrics_enabled, + /*concurrent_reasoning_summaries_enabled*/ false, + ) + .await } async fn websocket_harness_with_provider_options( provider: ModelProviderInfo, runtime_metrics_enabled: bool, + concurrent_reasoning_summaries_enabled: bool, ) -> WebsocketTestHarness { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home).await; @@ -2212,6 +2234,12 @@ async fn websocket_harness_with_provider_options( .enable(Feature::RuntimeMetrics) .expect("test config should allow feature update"); } + if concurrent_reasoning_summaries_enabled { + config + .features + .enable(Feature::ConcurrentReasoningSummaries) + .expect("test config should allow feature update"); + } let config = Arc::new(config); let model_info = codex_core::test_support::construct_model_info_offline(MODEL, &config); let thread_id = ThreadId::new(); @@ -2251,6 +2279,10 @@ async fn websocket_harness_with_provider_options( runtime_metrics_enabled, /*beta_features_header*/ None, /*item_ids_enabled*/ config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ + config + .features + .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, ); diff --git a/codex-rs/core/tests/suite/items.rs b/codex-rs/core/tests/suite/items.rs index 515d496b8b..196277394a 100644 --- a/codex-rs/core/tests/suite/items.rs +++ b/codex-rs/core/tests/suite/items.rs @@ -1,6 +1,7 @@ #![cfg(not(target_os = "windows"))] use anyhow::Ok; +use codex_features::Feature; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; @@ -1134,6 +1135,98 @@ async fn reasoning_content_delta_has_item_metadata() -> anyhow::Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn sequential_cutoff_renders_done_summaries_for_active_reasoning_item() -> anyhow::Result<()> +{ + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + + let TestCodex { codex, .. } = test_codex() + .with_config(|config| { + config + .features + .enable(Feature::ConcurrentReasoningSummaries) + .expect("test config should allow feature update"); + }) + .build(&server) + .await?; + let summary_done = |item_id: &str, summary_index: i64, text: &str| { + serde_json::json!({ + "type": "response.reasoning_summary_text.done", + "item_id": item_id, + "summary_index": summary_index, + "text": text, + }) + }; + mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_reasoning_item_added("reasoning-1", &[""]), + // Sequential cutoff renders atomic done events, not partial legacy events. + ev_reasoning_summary_text_delta("partial"), + summary_done("reasoning-1", 0, "step one"), + summary_done("reasoning-1", 1, "step two"), + ev_reasoning_item("reasoning-1", &["step one", "step two"], &[]), + ev_message_item_added("message-1", ""), + // Drop a stale completion rather than attaching it to the active message. + summary_done("reasoning-1", 2, "late step"), + ev_output_text_delta("Done"), + ev_assistant_message("message-1", "Done"), + ev_completed("resp-1"), + ]), + ) + .await; + + codex + .submit(Op::UserInput { + items: vec![UserInput::Text { + text: "reason through it".into(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + responsesapi_client_metadata: None, + additional_context: Default::default(), + thread_settings: Default::default(), + }) + .await?; + + let reasoning_item = wait_for_event_match(&codex, |ev| match ev { + EventMsg::ItemStarted(ItemStartedEvent { + item: TurnItem::Reasoning(item), + .. + }) => Some(item.clone()), + _ => None, + }) + .await; + + let mut summary_deltas = Vec::new(); + let mut summary_sections = Vec::new(); + loop { + match wait_for_event(&codex, |_| true).await { + EventMsg::ReasoningContentDelta(event) => { + summary_deltas.push((event.item_id, event.delta, event.summary_index)); + } + EventMsg::AgentReasoningSectionBreak(event) => { + summary_sections.push((event.item_id, event.summary_index)); + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert_eq!( + summary_deltas, + vec![ + (reasoning_item.id.clone(), "step one".to_string(), 0), + (reasoning_item.id, "step two".to_string(), 1), + ] + ); + assert_eq!(summary_sections, vec![("reasoning-1".to_string(), 1)]); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reasoning_raw_content_delta_respects_flag() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 7ab7df18d1..ce5ca459b4 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -204,6 +204,8 @@ pub enum Feature { ResizeAllImages, /// Generate Responses API item IDs for client-created history items. ItemIds, + /// Request sequential cutoff reasoning summary delivery. + ConcurrentReasoningSummaries, /// Allow prompting and installing missing MCP dependencies. SkillMcpDependencyInstall, /// Removed compatibility flag for deleted skill env var dependency prompting. @@ -1184,6 +1186,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::ConcurrentReasoningSummaries, + key: "concurrent_reasoning_summaries", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::SkillMcpDependencyInstall, key: "skill_mcp_dependency_install", diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index 6c7ad3978f..ae0903cfdc 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -261,6 +261,7 @@ impl MemoryStartupContext { config.features.enabled(Feature::RuntimeMetrics), /*beta_features_header*/ None, config.features.enabled(Feature::ItemIds), + /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, ); diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 440f8f04a5..c470f8dc7c 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1195,6 +1195,7 @@ impl SessionTelemetry { ResponseEvent::OutputTextDelta(_) => "text_delta".into(), ResponseEvent::ToolCallInputDelta { .. } => "tool_input_delta".into(), ResponseEvent::ReasoningSummaryDelta { .. } => "reasoning_summary_delta".into(), + ResponseEvent::ReasoningSummaryDone { .. } => "reasoning_summary_done".into(), ResponseEvent::ReasoningContentDelta { .. } => "reasoning_content_delta".into(), ResponseEvent::ReasoningSummaryPartAdded { .. } => { "reasoning_summary_part_added".into() From 9365b08467b79a116ff78f00931306d3dfca004f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 6 Jul 2026 20:25:33 -0700 Subject: [PATCH 33/76] exec-server: use virtual time in Noise relay test (#31344) ## Why `fragmented_writes_yield_to_keepalive_and_queued_pong` deliberately blocks WebSocket writes while exercising keepalive and queued-Pong scheduling. It previously advanced those states with wall-clock sleeps. Under a sufficiently delayed CI worker, those sleeps and scheduling gaps could consume the test-only 100 ms Pong-watchdog budget, causing the relay to exit and the next write-permit send to fail with `TrySendError::Disconnected`. The failure was therefore a timing flake in the harness test, not evidence that the production relay mishandled a Pong. ## What changed - Run this test with Tokio time paused. - Advance the virtual clock through its two keepalive transitions instead of sleeping in wall-clock time. - Enable Tokio's `test-util` feature only for `codex-exec-server` dev dependencies. No production code or timeout values change. ## Review guide The behavioral change is confined to `noise_relay/harness_tests.rs`; the `Cargo.toml` change only exposes Tokio's paused-clock test APIs. ## Validation - `just test -p codex-exec-server fragmented_writes_yield_to_keepalive_and_queued_pong` - `just fix -p codex-exec-server` - `just bazel-lock-update` (no lockfile changes) --- codex-rs/exec-server/Cargo.toml | 1 + codex-rs/exec-server/src/noise_relay/harness_tests.rs | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index ba9058f0c6..f303ac70ee 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -74,6 +74,7 @@ rustls = { workspace = true } serial_test = { workspace = true } tempfile = { workspace = true } test-case = "3.3.1" +tokio = { workspace = true, features = ["test-util"] } tracing-opentelemetry = { workspace = true } tracing-subscriber = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/exec-server/src/noise_relay/harness_tests.rs b/codex-rs/exec-server/src/noise_relay/harness_tests.rs index ce26a8ca8c..348351e61c 100644 --- a/codex-rs/exec-server/src/noise_relay/harness_tests.rs +++ b/codex-rs/exec-server/src/noise_relay/harness_tests.rs @@ -31,7 +31,7 @@ use crate::noise_channel::PendingResponderHandshake; const ENVIRONMENT_ID: &str = "environment-1"; const EXECUTOR_REGISTRATION_ID: &str = "registration-1"; -#[tokio::test] +#[tokio::test(start_paused = true)] async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> { let (connection, mut control, mut outbound_rx) = connected_controlled_harness().await?; @@ -48,7 +48,7 @@ async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> { .await?; control.wait_for_blocked_write(/*expected*/ 1).await?; - tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; + tokio::time::advance(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; control.grant_writes(/*count*/ 1); let first_data = read_outbound_data(&mut outbound_rx).await?; assert_eq!(first_data.seq, 0); @@ -64,7 +64,7 @@ async fn fragmented_writes_yield_to_keepalive_and_queued_pong() -> Result<()> { control.wait_for_blocked_write(/*expected*/ 3).await?; control.send_inbound(Message::Pong(ping_payload))?; - tokio::time::sleep(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; + tokio::time::advance(WEBSOCKET_KEEPALIVE_INTERVAL + Duration::from_millis(10)).await; control.grant_writes(/*count*/ 1); let second_data = read_outbound_data(&mut outbound_rx).await?; assert_eq!(second_data.seq, 1); From b9b934e99b57aa1908a7cd83adee9faf6d0b8f7d Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 6 Jul 2026 20:41:57 -0700 Subject: [PATCH 34/76] refactor(protocol): map canonical tool items to legacy events (#31296) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR adds legacy `EventMsg` mappings for the `TurnItem` types introduced in [#30282](https://github.com/openai/codex/pull/30282): - `CommandExecution` - `DynamicToolCall` - `CollabAgentToolCall` - `SubAgentActivity` When their producers move to canonical `ItemStarted` / `ItemCompleted`, raw core event consumers can still receive the existing begin/end-style events. The canonical item lifecycle remains the live source of truth. We also record the mapped legacy events in rollout trace so the producer migration preserves the existing tool-runtime trace entries. ## Why This is the compatibility layer for the follow-up producer migrations. Splitting it out first keeps each producer PR small and keeps the legacy mapping in one place. ## What changed - Added `TurnItem` → legacy `EventMsg` mappings in `protocol/src/legacy_events.rs`. - Added the command execution status conversion used by the exec mapping. - Added focused coverage for command execution and dynamic tool mappings. --- codex-rs/core/src/session/mod.rs | 3 + codex-rs/protocol/src/items.rs | 11 + codex-rs/protocol/src/legacy_events.rs | 304 +++++++++++++++++++++++++ codex-rs/protocol/src/protocol.rs | 137 +++++++++++ 4 files changed, 455 insertions(+) diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 91a21ef731..fa240b890b 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -1794,6 +1794,9 @@ impl Session { let show_raw_agent_reasoning = self.show_raw_agent_reasoning(); for legacy in legacy_source.as_legacy_events(show_raw_agent_reasoning) { + self.services + .rollout_thread_trace + .record_tool_call_event(turn_context.sub_id.clone(), &legacy); let legacy_event = Event { id: turn_context.sub_id.clone(), msg: legacy, diff --git a/codex-rs/protocol/src/items.rs b/codex-rs/protocol/src/items.rs index 820c3b774b..2c9d073f0d 100644 --- a/codex-rs/protocol/src/items.rs +++ b/codex-rs/protocol/src/items.rs @@ -13,6 +13,7 @@ use crate::parse_command::ParsedCommand; use crate::protocol::AgentStatus; use crate::protocol::CollabAgentRef; use crate::protocol::ExecCommandSource; +use crate::protocol::ExecCommandStatus; use crate::protocol::FileChange; use crate::protocol::PatchApplyStatus; use crate::protocol::SubAgentActivityKind; @@ -137,6 +138,16 @@ pub enum CommandExecutionStatus { Declined, } +impl From for CommandExecutionStatus { + fn from(value: ExecCommandStatus) -> Self { + match value { + ExecCommandStatus::Completed => Self::Completed, + ExecCommandStatus::Failed => Self::Failed, + ExecCommandStatus::Declined => Self::Declined, + } + } +} + #[derive(Debug, Clone, Deserialize, Serialize, TS, JsonSchema, PartialEq)] pub struct CommandExecutionItem { pub id: String, diff --git a/codex-rs/protocol/src/legacy_events.rs b/codex-rs/protocol/src/legacy_events.rs index f4a511d367..c12a2747a2 100644 --- a/codex-rs/protocol/src/legacy_events.rs +++ b/codex-rs/protocol/src/legacy_events.rs @@ -1,10 +1,20 @@ +use crate::ThreadId; +use crate::dynamic_tools::DynamicToolCallRequest; use crate::items::AgentMessageContent; use crate::items::AgentMessageItem; +use crate::items::CollabAgentTool; +use crate::items::CollabAgentToolCallItem; +use crate::items::CollabAgentToolCallStatus; +use crate::items::CommandExecutionItem; +use crate::items::CommandExecutionStatus; use crate::items::ContextCompactionItem; +use crate::items::DynamicToolCallItem; +use crate::items::DynamicToolCallStatus; use crate::items::FileChangeItem; use crate::items::ImageGenerationItem; use crate::items::McpToolCallItem; use crate::items::ReasoningItem; +use crate::items::SubAgentActivityItem; use crate::items::TurnItem; use crate::items::UserMessageItem; use crate::items::WebSearchItem; @@ -12,8 +22,24 @@ use crate::protocol::AgentMessageContentDeltaEvent; use crate::protocol::AgentMessageEvent; use crate::protocol::AgentReasoningEvent; use crate::protocol::AgentReasoningRawContentEvent; +use crate::protocol::AgentStatus; +use crate::protocol::CollabAgentInteractionBeginEvent; +use crate::protocol::CollabAgentInteractionEndEvent; +use crate::protocol::CollabAgentSpawnBeginEvent; +use crate::protocol::CollabAgentSpawnEndEvent; +use crate::protocol::CollabAgentStatusEntry; +use crate::protocol::CollabCloseBeginEvent; +use crate::protocol::CollabCloseEndEvent; +use crate::protocol::CollabResumeBeginEvent; +use crate::protocol::CollabResumeEndEvent; +use crate::protocol::CollabWaitingBeginEvent; +use crate::protocol::CollabWaitingEndEvent; use crate::protocol::ContextCompactedEvent; +use crate::protocol::DynamicToolCallResponseEvent; use crate::protocol::EventMsg; +use crate::protocol::ExecCommandBeginEvent; +use crate::protocol::ExecCommandEndEvent; +use crate::protocol::ExecCommandStatus; use crate::protocol::ImageGenerationBeginEvent; use crate::protocol::ImageGenerationEndEvent; use crate::protocol::ItemCompletedEvent; @@ -26,6 +52,7 @@ use crate::protocol::PatchApplyEndEvent; use crate::protocol::PatchApplyStatus; use crate::protocol::ReasoningContentDeltaEvent; use crate::protocol::ReasoningRawContentDeltaEvent; +use crate::protocol::SubAgentActivityEvent; use crate::protocol::UserMessageEvent; use crate::protocol::ViewImageToolCallEvent; use crate::protocol::WebSearchBeginEvent; @@ -97,6 +124,258 @@ impl ReasoningItem { } } +impl CommandExecutionItem { + pub(crate) fn as_legacy_begin_event(&self, turn_id: String, started_at_ms: i64) -> EventMsg { + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: self.id.clone(), + process_id: self.process_id.clone(), + turn_id, + started_at_ms, + command: self.command.clone(), + cwd: self.cwd.clone(), + parsed_cmd: self.parsed_cmd.clone(), + source: self.source, + interaction_input: self.interaction_input.clone(), + }) + } + + pub(crate) fn as_legacy_end_event( + &self, + turn_id: String, + completed_at_ms: i64, + ) -> Option { + let status = match self.status { + CommandExecutionStatus::InProgress => return None, + CommandExecutionStatus::Completed => ExecCommandStatus::Completed, + CommandExecutionStatus::Failed => ExecCommandStatus::Failed, + CommandExecutionStatus::Declined => ExecCommandStatus::Declined, + }; + Some(EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: self.id.clone(), + process_id: self.process_id.clone(), + turn_id, + completed_at_ms, + command: self.command.clone(), + cwd: self.cwd.clone(), + parsed_cmd: self.parsed_cmd.clone(), + source: self.source, + interaction_input: self.interaction_input.clone(), + stdout: self.stdout.clone().unwrap_or_default(), + stderr: self.stderr.clone().unwrap_or_default(), + aggregated_output: self.aggregated_output.clone().unwrap_or_default(), + exit_code: self.exit_code.unwrap_or_default(), + duration: self.duration.unwrap_or_default(), + formatted_output: self.formatted_output.clone().unwrap_or_default(), + status, + })) + } +} + +impl DynamicToolCallItem { + pub(crate) fn as_legacy_request_event(&self, turn_id: String, started_at_ms: i64) -> EventMsg { + EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { + call_id: self.id.clone(), + turn_id, + started_at_ms, + namespace: self.namespace.clone(), + tool: self.tool.clone(), + arguments: self.arguments.clone(), + }) + } + + pub(crate) fn as_legacy_response_event( + &self, + turn_id: String, + completed_at_ms: i64, + ) -> Option { + if matches!(self.status, DynamicToolCallStatus::InProgress) { + return None; + } + Some(EventMsg::DynamicToolCallResponse( + DynamicToolCallResponseEvent { + call_id: self.id.clone(), + turn_id, + completed_at_ms, + namespace: self.namespace.clone(), + tool: self.tool.clone(), + arguments: self.arguments.clone(), + content_items: self.content_items.clone().unwrap_or_default(), + success: self.success.unwrap_or(false), + error: self.error.clone(), + duration: self.duration.unwrap_or_default(), + }, + )) + } +} + +impl CollabAgentToolCallItem { + pub(crate) fn as_legacy_begin_event(&self, started_at_ms: i64) -> Option { + let receiver_thread_id = self.receiver_thread_ids.first().copied(); + match self.tool { + CollabAgentTool::SpawnAgent => Some(EventMsg::CollabAgentSpawnBegin( + CollabAgentSpawnBeginEvent { + call_id: self.id.clone(), + started_at_ms, + sender_thread_id: self.sender_thread_id, + prompt: self.prompt.clone().unwrap_or_default(), + model: self.model.clone().unwrap_or_default(), + reasoning_effort: self.reasoning_effort.clone().unwrap_or_default(), + }, + )), + CollabAgentTool::SendInput => receiver_thread_id.map(|receiver_thread_id| { + EventMsg::CollabAgentInteractionBegin(CollabAgentInteractionBeginEvent { + call_id: self.id.clone(), + started_at_ms, + sender_thread_id: self.sender_thread_id, + receiver_thread_id, + prompt: self.prompt.clone().unwrap_or_default(), + }) + }), + CollabAgentTool::ResumeAgent => receiver_thread_id.map(|receiver_thread_id| { + let (receiver_agent_nickname, receiver_agent_role) = + self.receiver_agent_identity(receiver_thread_id); + EventMsg::CollabResumeBegin(CollabResumeBeginEvent { + call_id: self.id.clone(), + started_at_ms, + sender_thread_id: self.sender_thread_id, + receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, + }) + }), + CollabAgentTool::Wait => Some(EventMsg::CollabWaitingBegin(CollabWaitingBeginEvent { + started_at_ms, + sender_thread_id: self.sender_thread_id, + receiver_thread_ids: self.receiver_thread_ids.clone(), + receiver_agents: self.receiver_agents.clone(), + call_id: self.id.clone(), + })), + CollabAgentTool::CloseAgent => receiver_thread_id.map(|receiver_thread_id| { + EventMsg::CollabCloseBegin(CollabCloseBeginEvent { + call_id: self.id.clone(), + started_at_ms, + sender_thread_id: self.sender_thread_id, + receiver_thread_id, + }) + }), + } + } + + pub(crate) fn as_legacy_end_event(&self, completed_at_ms: i64) -> Option { + if matches!(self.status, CollabAgentToolCallStatus::InProgress) { + return None; + } + let receiver_thread_id = self.receiver_thread_ids.first().copied(); + match self.tool { + CollabAgentTool::SpawnAgent => { + let (new_agent_nickname, new_agent_role) = receiver_thread_id + .map(|thread_id| self.receiver_agent_identity(thread_id)) + .unwrap_or_default(); + Some(EventMsg::CollabAgentSpawnEnd(CollabAgentSpawnEndEvent { + call_id: self.id.clone(), + completed_at_ms, + sender_thread_id: self.sender_thread_id, + new_thread_id: receiver_thread_id, + new_agent_nickname, + new_agent_role, + prompt: self.prompt.clone().unwrap_or_default(), + model: self.model.clone().unwrap_or_default(), + reasoning_effort: self.reasoning_effort.clone().unwrap_or_default(), + status: receiver_thread_id + .map(|thread_id| self.agent_status(thread_id)) + .unwrap_or(AgentStatus::NotFound), + })) + } + CollabAgentTool::SendInput => receiver_thread_id.map(|receiver_thread_id| { + let (receiver_agent_nickname, receiver_agent_role) = + self.receiver_agent_identity(receiver_thread_id); + EventMsg::CollabAgentInteractionEnd(CollabAgentInteractionEndEvent { + call_id: self.id.clone(), + completed_at_ms, + sender_thread_id: self.sender_thread_id, + receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, + prompt: self.prompt.clone().unwrap_or_default(), + status: self.agent_status(receiver_thread_id), + }) + }), + CollabAgentTool::ResumeAgent => receiver_thread_id.map(|receiver_thread_id| { + let (receiver_agent_nickname, receiver_agent_role) = + self.receiver_agent_identity(receiver_thread_id); + EventMsg::CollabResumeEnd(CollabResumeEndEvent { + call_id: self.id.clone(), + completed_at_ms, + sender_thread_id: self.sender_thread_id, + receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, + status: self.agent_status(receiver_thread_id), + }) + }), + CollabAgentTool::Wait => Some(EventMsg::CollabWaitingEnd(CollabWaitingEndEvent { + sender_thread_id: self.sender_thread_id, + call_id: self.id.clone(), + completed_at_ms, + agent_statuses: self + .receiver_agents + .iter() + .map(|agent| CollabAgentStatusEntry { + thread_id: agent.thread_id, + agent_nickname: agent.agent_nickname.clone(), + agent_role: agent.agent_role.clone(), + status: self.agent_status(agent.thread_id), + }) + .collect(), + statuses: self.agents_states.clone(), + })), + CollabAgentTool::CloseAgent => receiver_thread_id.map(|receiver_thread_id| { + let (receiver_agent_nickname, receiver_agent_role) = + self.receiver_agent_identity(receiver_thread_id); + EventMsg::CollabCloseEnd(CollabCloseEndEvent { + call_id: self.id.clone(), + completed_at_ms, + sender_thread_id: self.sender_thread_id, + receiver_thread_id, + receiver_agent_nickname, + receiver_agent_role, + status: self.agent_status(receiver_thread_id), + }) + }), + } + } + + fn receiver_agent_identity(&self, thread_id: ThreadId) -> (Option, Option) { + let receiver_agent = self + .receiver_agents + .iter() + .find(|agent| agent.thread_id == thread_id); + ( + receiver_agent.and_then(|agent| agent.agent_nickname.clone()), + receiver_agent.and_then(|agent| agent.agent_role.clone()), + ) + } + + fn agent_status(&self, thread_id: ThreadId) -> AgentStatus { + self.agents_states + .get(&thread_id) + .cloned() + .unwrap_or(AgentStatus::NotFound) + } +} + +impl SubAgentActivityItem { + pub(crate) fn as_legacy_event(&self, occurred_at_ms: i64) -> EventMsg { + EventMsg::SubAgentActivity(SubAgentActivityEvent { + event_id: self.id.clone(), + occurred_at_ms, + agent_thread_id: self.agent_thread_id, + agent_path: self.agent_path.clone(), + kind: self.kind, + }) + } +} + impl WebSearchItem { pub fn as_legacy_event(&self) -> EventMsg { EventMsg::WebSearchEnd(WebSearchEndEvent { @@ -234,6 +513,16 @@ impl HasLegacyEvent for ItemStartedEvent { } TurnItem::FileChange(item) => vec![item.as_legacy_begin_event(self.turn_id.clone())], TurnItem::McpToolCall(item) => vec![item.as_legacy_begin_event()], + TurnItem::CommandExecution(item) => { + vec![item.as_legacy_begin_event(self.turn_id.clone(), self.started_at_ms)] + } + TurnItem::DynamicToolCall(item) => { + vec![item.as_legacy_request_event(self.turn_id.clone(), self.started_at_ms)] + } + TurnItem::CollabAgentToolCall(item) => item + .as_legacy_begin_event(self.started_at_ms) + .into_iter() + .collect(), _ => Vec::new(), } } @@ -246,6 +535,21 @@ impl HasLegacyEvent for ItemCompletedEvent { .as_legacy_end_event(self.turn_id.clone()) .into_iter() .collect(), + TurnItem::CommandExecution(item) => item + .as_legacy_end_event(self.turn_id.clone(), self.completed_at_ms) + .into_iter() + .collect(), + TurnItem::DynamicToolCall(item) => item + .as_legacy_response_event(self.turn_id.clone(), self.completed_at_ms) + .into_iter() + .collect(), + TurnItem::CollabAgentToolCall(item) => item + .as_legacy_end_event(self.completed_at_ms) + .into_iter() + .collect(), + TurnItem::SubAgentActivity(item) => { + vec![item.as_legacy_event(self.completed_at_ms)] + } _ => self.item.as_legacy_events(show_raw_agent_reasoning), } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index ca509a5a17..b1b845fe28 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -4309,6 +4309,10 @@ pub struct CollabResumeEndEvent { #[cfg(test)] mod tests { use super::*; + use crate::items::CommandExecutionItem; + use crate::items::CommandExecutionStatus; + use crate::items::DynamicToolCallItem; + use crate::items::DynamicToolCallStatus; use crate::items::FileChangeItem; use crate::items::ImageGenerationItem; use crate::items::McpToolCallItem; @@ -5290,6 +5294,139 @@ mod tests { } } + #[test] + fn command_execution_item_lifecycle_emits_legacy_exec_events() { + let cwd = PathUri::from_abs_path(&test_path_buf("/tmp").abs()); + let started = ItemStartedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + started_at_ms: 10, + item: TurnItem::CommandExecution(CommandExecutionItem { + id: "exec-1".into(), + process_id: Some("pid-1".into()), + command: vec!["echo".into(), "done".into()], + cwd: cwd.clone(), + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo done".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + status: CommandExecutionStatus::InProgress, + stdout: None, + stderr: None, + aggregated_output: None, + exit_code: None, + duration: None, + formatted_output: None, + }), + }; + let completed = ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + completed_at_ms: 20, + item: TurnItem::CommandExecution(CommandExecutionItem { + id: "exec-1".into(), + process_id: Some("pid-1".into()), + command: vec!["echo".into(), "done".into()], + cwd, + parsed_cmd: vec![ParsedCommand::Unknown { + cmd: "echo done".into(), + }], + source: ExecCommandSource::Agent, + interaction_input: None, + status: CommandExecutionStatus::Completed, + stdout: Some("done\n".into()), + stderr: Some(String::new()), + aggregated_output: Some("done\n".into()), + exit_code: Some(0), + duration: Some(Duration::from_millis(5)), + formatted_output: Some("done\n".into()), + }), + }; + + assert!(matches!( + started.as_legacy_events(/*show_raw_agent_reasoning*/ false).as_slice(), + [EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id, + turn_id, + started_at_ms: 10, + .. + })] if call_id == "exec-1" && turn_id == "turn-1" + )); + assert!(matches!( + completed + .as_legacy_events(/*show_raw_agent_reasoning*/ false) + .as_slice(), + [EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id, + turn_id, + completed_at_ms: 20, + aggregated_output, + .. + })] if call_id == "exec-1" && turn_id == "turn-1" && aggregated_output == "done\n" + )); + } + + #[test] + fn dynamic_tool_call_item_lifecycle_emits_legacy_dynamic_tool_events() { + let started = ItemStartedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + started_at_ms: 10, + item: TurnItem::DynamicToolCall(DynamicToolCallItem { + id: "dynamic-1".into(), + namespace: Some("apps".into()), + tool: "lookup".into(), + arguments: json!({"id": "123"}), + status: DynamicToolCallStatus::InProgress, + content_items: None, + success: None, + error: None, + duration: None, + }), + }; + let completed = ItemCompletedEvent { + thread_id: ThreadId::new(), + turn_id: "turn-1".into(), + completed_at_ms: 20, + item: TurnItem::DynamicToolCall(DynamicToolCallItem { + id: "dynamic-1".into(), + namespace: Some("apps".into()), + tool: "lookup".into(), + arguments: json!({"id": "123"}), + status: DynamicToolCallStatus::Completed, + content_items: Some(vec![DynamicToolCallOutputContentItem::InputText { + text: "ok".into(), + }]), + success: Some(true), + error: None, + duration: Some(Duration::from_millis(5)), + }), + }; + + assert!(matches!( + started.as_legacy_events(/*show_raw_agent_reasoning*/ false).as_slice(), + [EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { + call_id, + turn_id, + started_at_ms: 10, + .. + })] if call_id == "dynamic-1" && turn_id == "turn-1" + )); + assert!(matches!( + completed + .as_legacy_events(/*show_raw_agent_reasoning*/ false) + .as_slice(), + [EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { + call_id, + turn_id, + completed_at_ms: 20, + success: true, + .. + })] if call_id == "dynamic-1" && turn_id == "turn-1" + )); + } + #[test] fn item_started_event_requires_started_at_ms() { let mut value = serde_json::to_value(ItemStartedEvent { From 6afcf26d5d76c2f88b9096caa758931ffa673745 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Mon, 6 Jul 2026 20:49:53 -0700 Subject: [PATCH 35/76] core: route Responses API through system proxy (#31335) ## Why `features.respect_system_proxy` already routes authentication traffic through the OS proxy APIs, but it does not affect the primary inference path. That leaves users behind OS-managed proxies unable to send normal Responses API requests even after login succeeds. This PR is the first product-path migration onto the route-aware transport introduced in #31323 and refined in #31331. It also establishes the construction pattern for later migrations: the effective feature state is resolved once into a required HTTP client factory rather than represented by an optional per-call setting. The scope remains limited to the two HTTP Responses endpoints; WebSockets, model discovery, memories, realtime, and file uploads remain follow-up migrations. ## What changed - Replace the optional proxy marker with an explicit `OutboundProxyPolicy::{ReqwestDefault, RespectSystemProxy}` and a required `HttpClientFactory`. The policy has no default, and the lower-level route-aware reqwest builder is now private. - Have `Config` construct the factory from the effective feature state and require every `ModelClient` constructor to receive it. There is no optional setter or implicit `None` fallback. - Build HTTP clients for `/responses` and `/responses/compact` with `ClientRouteClass::Api`, using the complete destination URL so PAC rules can make URL-specific decisions. - Layer route-aware selection onto Codex's existing default headers, Cloudflare cookie store, custom CA handling, and sandbox no-proxy behavior. - Add an integration test that loads `features.respect_system_proxy` through `config.toml`, creates a real Codex session, and verifies that both a normal Responses turn and remote compaction reach an isolated local proxy. ## Review guide 1. `http-client/src/outbound_proxy.rs` defines the mandatory policy/factory boundary and keeps route resolution private. 2. `core/src/config/mod.rs`, `core/src/session/session.rs`, and `core/src/client.rs` show the compile-time invariant: effective config creates the factory, and `ModelClient` cannot be constructed without one. 3. `login/src/auth/default_client.rs` preserves existing default-client behavior while accepting the required factory for migrated routes. 4. `core/src/client.rs` switches only streaming Responses and remote compaction HTTP transports to the API route class. 5. `core/tests/suite/responses_api_system_proxy.rs` is the behavioral regression boundary. Its Linux subprocess deliberately sets the CGI marker that disables reqwest's implicit environment-proxy handling, so the test fails if session wiring or either Responses call site falls back to the default client. ## Test plan - `cargo check --tests -p codex-http-client -p codex-login -p codex-core` - `just test -p codex-login` - `just test -p codex-core respect_system_proxy_feature_resolves_enabled` - Existing `compact_uses_bearer_after_agent_identity_session_fallback` coverage passes with the new transport construction. - New Linux integration coverage: `responses_and_compact_use_enabled_system_proxy` - `just bazel-lock-check` --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/31335). * #31342 * __->__ #31335 --- codex-rs/Cargo.lock | 1 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/client.rs | 32 +++++++++- codex-rs/core/src/client_tests.rs | 5 ++ codex-rs/core/src/config/config_tests.rs | 4 ++ codex-rs/core/src/config/mod.rs | 12 ++++ codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/session/tests.rs | 5 ++ codex-rs/core/tests/responses_headers.rs | 3 + codex-rs/core/tests/suite/client.rs | 2 + .../core/tests/suite/client_websockets.rs | 1 + codex-rs/http-client/src/lib.rs | 4 +- codex-rs/http-client/src/outbound_proxy.rs | 62 +++++++++++++++---- .../http-client/src/outbound_proxy_tests.rs | 3 +- codex-rs/login/src/auth/default_client.rs | 56 ++++++++++++----- codex-rs/login/src/outbound_proxy.rs | 11 ++-- codex-rs/memories/write/src/runtime.rs | 1 + 17 files changed, 166 insertions(+), 38 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9d89f8eb1f..0d4d3c33bf 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2647,6 +2647,7 @@ dependencies = [ "codex-git-utils", "codex-home", "codex-hooks", + "codex-http-client", "codex-image-generation-extension", "codex-install-context", "codex-login", diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 4435a2eb63..c617684f78 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -49,6 +49,7 @@ codex-shell-command = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } +codex-http-client = { workspace = true } codex-install-context = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 716466b189..0bf33b3993 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -63,10 +63,13 @@ use codex_api::auth_header_telemetry; use codex_api::build_session_headers; use codex_api::create_text_param_for_request; use codex_api::response_create_client_metadata; +use codex_http_client::ClientRouteClass; +use codex_http_client::HttpClientFactory; use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::RefreshTokenError; use codex_login::UnauthorizedRecovery; +use codex_login::default_client::build_default_reqwest_client_for_route; use codex_login::default_client::build_reqwest_client; use codex_otel::SessionTelemetry; use codex_otel::current_span_w3c_trace_context; @@ -250,6 +253,7 @@ pub struct ModelClient { state: Arc, agent_identity_policy: AgentIdentityAuthPolicy, prompt_cache_key_override: Option, + http_client_factory: HttpClientFactory, } /// A turn-scoped streaming session created from a [`ModelClient`]. @@ -402,7 +406,9 @@ impl ModelClient { /// Creates a new session-scoped `ModelClient`. /// /// All arguments are expected to be stable for the lifetime of a Codex session. Per-turn values - /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. + /// are passed to [`ModelClientSession::stream`] (and other turn-scoped methods) explicitly. The + /// HTTP client factory must come from the effective session configuration so every transport + /// observes the resolved outbound proxy policy. pub fn new( auth_manager: Option>, agent_identity_policy: AgentIdentityAuthPolicy, @@ -417,6 +423,7 @@ impl ModelClient { item_ids_enabled: bool, concurrent_reasoning_summaries_enabled: bool, attestation_provider: Option>, + http_client_factory: HttpClientFactory, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); let codex_api_key_env_enabled = model_provider @@ -447,6 +454,7 @@ impl ModelClient { }), agent_identity_policy, prompt_cache_key_override: None, + http_client_factory, } } @@ -540,7 +548,8 @@ impl ModelClient { return Ok(Vec::new()); } let client_setup = self.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = + self.build_responses_transport(&client_setup.api_provider, RESPONSES_COMPACT_ENDPOINT)?; let request_telemetry = Self::build_request_telemetry( session_telemetry, AuthRequestTelemetryContext::new( @@ -952,6 +961,21 @@ impl ModelClient { }) } + fn build_responses_transport( + &self, + api_provider: &ApiProvider, + endpoint: &str, + ) -> Result { + let request_url = api_provider.url_for_path(endpoint); + let client = build_default_reqwest_client_for_route( + &self.http_client_factory, + &request_url, + ClientRouteClass::Api, + ) + .map_err(std::io::Error::from)?; + Ok(ReqwestTransport::new(client)) + } + pub(crate) async fn prewarm_auth(&self) -> Result<()> { self.current_client_setup().await.map(|_| ()) } @@ -1386,7 +1410,9 @@ impl ModelClientSession { let mut pending_retry = PendingUnauthorizedRetry::default(); loop { let client_setup = self.client.current_client_setup().await?; - let transport = ReqwestTransport::new(build_reqwest_client()); + let transport = self + .client + .build_responses_transport(&client_setup.api_provider, RESPONSES_ENDPOINT)?; let request_auth_context = AuthRequestTelemetryContext::new( client_setup.auth.as_ref().map(CodexAuth::auth_mode), client_setup.api_auth.as_ref(), diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index c05f56b713..ddf9c4ebec 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -19,6 +19,8 @@ use codex_api::AgentIdentityTelemetry; use codex_api::ApiError; use codex_api::ResponseEvent; use codex_api::TransportError; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_login::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use codex_login::AuthManager; @@ -105,6 +107,7 @@ fn test_model_client_with_thread_id( /*item_ids_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ) } @@ -150,6 +153,7 @@ async fn compact_uses_bearer_after_agent_identity_session_fallback() -> anyhow:: /*item_ids_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ); let prompt = Prompt { input: vec![ResponseItem::Message { @@ -827,6 +831,7 @@ fn model_client_with_counting_attestation( Some(Arc::new(CountingAttestationProvider { calls: attestation_calls.clone(), })), + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ); (model_client, attestation_calls) } diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 6156b9b9ea..40ebc64a0b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -1590,6 +1590,10 @@ respect_system_proxy = true .await?; assert!(config.respect_system_proxy); + assert_eq!( + config.http_client_factory().outbound_proxy_policy(), + codex_http_client::OutboundProxyPolicy::RespectSystemProxy + ); Ok(()) } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 54fbb97f69..8c0a165564 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -70,6 +70,8 @@ use codex_features::MultiAgentV2ConfigToml; use codex_features::NetworkProxyConfigToml; use codex_features::TokenBudgetConfigToml; use codex_git_utils::resolve_root_git_project_for_trust; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_install_context::InstallContext; use codex_login::AuthManagerConfig; use codex_login::AuthRouteConfig; @@ -1481,6 +1483,16 @@ impl Config { .then(AuthRouteConfig::respect_system_proxy) } + /// Creates the HTTP client factory resolved from the effective feature configuration. + pub fn http_client_factory(&self) -> HttpClientFactory { + let outbound_proxy_policy = if self.respect_system_proxy { + OutboundProxyPolicy::RespectSystemProxy + } else { + OutboundProxyPolicy::ReqwestDefault + }; + HttpClientFactory::new(outbound_proxy_policy) + } + /// Build the plugin-manager input from the effective config. pub fn plugins_config_input(&self) -> PluginsConfigInput { PluginsConfigInput::new( diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 78a7f02588..08abb46377 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1115,6 +1115,7 @@ impl Session { .features .enabled(Feature::ConcurrentReasoningSummaries), attestation_provider, + config.http_client_factory(), ) .with_prompt_cache_key_override( crate::guardian::prompt_cache_key_override_for_review_session( diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1ff469cf4c..5ebce9aa42 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -33,6 +33,8 @@ use codex_core_skills::HostSkillsSnapshot; use core_test_support::test_codex::local_selections; use codex_features::Feature; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; use codex_login::CodexAuth; use codex_login::auth::AgentIdentityAuthPolicy; use codex_model_provider_info::ModelProviderInfo; @@ -492,6 +494,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*item_ids_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), ) .new_session() } @@ -5466,6 +5469,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { .features .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ), code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new( codex_code_mode::InProcessCodeModeSessionProvider, @@ -7596,6 +7600,7 @@ where .features .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ), code_mode_service: crate::tools::code_mode::CodeModeService::new(Arc::new( codex_code_mode::InProcessCodeModeSessionProvider, diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 4c5f906160..b31b6d5682 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -133,6 +133,7 @@ async fn responses_stream_includes_subagent_header_on_review() { /*item_ids_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); let mut client_session = client.new_session(); @@ -268,6 +269,7 @@ async fn responses_stream_includes_subagent_header_on_other() { /*item_ids_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); let mut client_session = client.new_session(); @@ -389,6 +391,7 @@ async fn responses_respects_model_info_overrides_from_config() { /*item_ids_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id, &session_source); let mut client_session = client.new_session(); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 92624bfcb3..4d298998af 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -1339,6 +1339,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth .features .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); let mut client_session = client.new_session(); @@ -3007,6 +3008,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { /*item_ids_enabled*/ false, /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let responses_metadata = test_turn_responses_metadata(&client, thread_id); let mut client_session = client.new_session(); diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index c56a8680ab..05a93807b2 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -2284,6 +2284,7 @@ async fn websocket_harness_with_provider_options( .features .enabled(Feature::ConcurrentReasoningSummaries), /*attestation_provider*/ None, + config.http_client_factory(), ); WebsocketTestHarness { diff --git a/codex-rs/http-client/src/lib.rs b/codex-rs/http-client/src/lib.rs index 5f90435452..07e579ecb3 100644 --- a/codex-rs/http-client/src/lib.rs +++ b/codex-rs/http-client/src/lib.rs @@ -25,9 +25,9 @@ pub use crate::error::StreamError; pub use crate::error::TransportError; pub use crate::outbound_proxy::BuildRouteAwareHttpClientError; pub use crate::outbound_proxy::ClientRouteClass; -pub use crate::outbound_proxy::OutboundProxyConfig; +pub use crate::outbound_proxy::HttpClientFactory; +pub use crate::outbound_proxy::OutboundProxyPolicy; pub use crate::outbound_proxy::RouteFailureClass; -pub use crate::outbound_proxy::build_reqwest_client_for_route; pub use crate::request::EncodedJsonBody; pub use crate::request::PreparedRequestBody; pub use crate::request::Request; diff --git a/codex-rs/http-client/src/outbound_proxy.rs b/codex-rs/http-client/src/outbound_proxy.rs index e643698a8c..5506f3d40b 100644 --- a/codex-rs/http-client/src/outbound_proxy.rs +++ b/codex-rs/http-client/src/outbound_proxy.rs @@ -84,14 +84,54 @@ impl fmt::Display for RouteFailureClass { } } -/// Marker enabling fixed system/PAC/WPAD, environment, then direct routing. -/// Resolved endpoints and platform details remain internal to the client builder. +/// Resolved outbound proxy behavior for HTTP clients. +/// +/// Callers must choose a policy explicitly so omitting feature resolution cannot silently select +/// legacy behavior. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct OutboundProxyConfig; +pub enum OutboundProxyPolicy { + /// Preserve reqwest's built-in proxy behavior. + ReqwestDefault, + /// Resolve system/PAC/WPAD settings, then environment settings, then direct routing. + RespectSystemProxy, +} -impl OutboundProxyConfig { - pub const fn respect_system_proxy() -> Self { - Self +/// Builds route-specific HTTP clients using one resolved outbound proxy policy. +/// +/// Construct this once from the effective application configuration and carry it with the +/// session or component that owns outbound requests. Individual request paths should supply only +/// their destination and route class rather than resolving feature state themselves. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HttpClientFactory { + outbound_proxy_policy: OutboundProxyPolicy, +} + +impl HttpClientFactory { + /// Creates a factory from the outbound proxy policy resolved by the application. + pub const fn new(outbound_proxy_policy: OutboundProxyPolicy) -> Self { + Self { + outbound_proxy_policy, + } + } + + /// Returns the outbound proxy policy used for clients built by this factory. + pub const fn outbound_proxy_policy(&self) -> OutboundProxyPolicy { + self.outbound_proxy_policy + } + + /// Builds a reqwest client for a concrete outbound route. + pub fn build_reqwest_client( + &self, + builder: reqwest::ClientBuilder, + request_url: &str, + route_class: ClientRouteClass, + ) -> Result { + build_reqwest_client_for_route( + builder, + request_url, + route_class, + self.outbound_proxy_policy, + ) } } @@ -120,18 +160,18 @@ impl From for io::Error { /// 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( +fn build_reqwest_client_for_route( builder: reqwest::ClientBuilder, request_url: &str, route_class: ClientRouteClass, - config: Option<&OutboundProxyConfig>, + outbound_proxy_policy: OutboundProxyPolicy, ) -> Result { let builder = configure_proxy_for_route( &ProcessEnv, builder, request_url, route_class, - config, + outbound_proxy_policy, resolve_system_proxy, )?; build_reqwest_client_with_custom_ca(builder).map_err(Into::into) @@ -142,10 +182,10 @@ fn configure_proxy_for_route( builder: reqwest::ClientBuilder, request_url: &str, route_class: ClientRouteClass, - config: Option<&OutboundProxyConfig>, + outbound_proxy_policy: OutboundProxyPolicy, resolve_system_proxy: impl FnOnce(&str, &RequestOrigin) -> SystemProxyDecision, ) -> Result { - if config.is_none() { + if matches!(outbound_proxy_policy, OutboundProxyPolicy::ReqwestDefault) { return Ok(builder); } let origin = RequestOrigin::parse(request_url); diff --git a/codex-rs/http-client/src/outbound_proxy_tests.rs b/codex-rs/http-client/src/outbound_proxy_tests.rs index 60a1d56396..734d5483ec 100644 --- a/codex-rs/http-client/src/outbound_proxy_tests.rs +++ b/codex-rs/http-client/src/outbound_proxy_tests.rs @@ -79,13 +79,12 @@ async fn enabled_environment_proxy_routes_request_through_proxy() { 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), + OutboundProxyPolicy::RespectSystemProxy, |_, _| SystemProxyDecision::Unavailable { failure: RouteFailureClass::ProxyResolutionUnavailable, }, diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index 1146a89d96..089e78340b 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -8,8 +8,9 @@ use codex_http_client::BuildCustomCaTransportError; use codex_http_client::BuildRouteAwareHttpClientError; use codex_http_client::ClientRouteClass; use codex_http_client::HttpClient; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; pub use codex_http_client::RequestBuilder as CodexRequestBuilder; -use codex_http_client::build_reqwest_client_for_route; use codex_http_client::build_reqwest_client_with_custom_ca; use codex_http_client::with_chatgpt_cloudflare_cookie_store; use codex_terminal_detection::user_agent; @@ -235,6 +236,35 @@ pub fn try_build_reqwest_client() -> Result Result { + if matches!( + http_client_factory.outbound_proxy_policy(), + OutboundProxyPolicy::ReqwestDefault + ) { + 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()); + } + + http_client_factory.build_reqwest_client( + default_reqwest_client_builder(), + request_url, + route_class, + ) +} + fn default_reqwest_client_builder() -> reqwest::ClientBuilder { let mut builder = reqwest::Client::builder().default_headers(default_headers()); if is_sandboxed() { @@ -248,11 +278,10 @@ pub(crate) fn build_raw_auth_reqwest_client( endpoint: &str, auth_route_config: Option<&AuthRouteConfig>, ) -> Result { - build_reqwest_client_for_route( + auth_http_client_factory(auth_route_config).build_reqwest_client( reqwest::Client::builder(), endpoint, ClientRouteClass::Auth, - auth_route_config.map(AuthRouteConfig::route_config), ) } @@ -261,20 +290,10 @@ 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(), + build_default_reqwest_client_for_route( + &auth_http_client_factory(auth_route_config), endpoint, ClientRouteClass::Auth, - Some(route_config), ) } @@ -286,6 +305,13 @@ pub(crate) fn create_default_auth_client( build_default_auth_reqwest_client(endpoint, auth_route_config).map(HttpClient::new) } +fn auth_http_client_factory(auth_route_config: Option<&AuthRouteConfig>) -> HttpClientFactory { + auth_route_config.map_or_else( + || HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault), + |config| config.http_client_factory().clone(), + ) +} + pub fn default_headers() -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert("originator", originator().header_value); diff --git a/codex-rs/login/src/outbound_proxy.rs b/codex-rs/login/src/outbound_proxy.rs index 66441e3637..79fcf76de1 100644 --- a/codex-rs/login/src/outbound_proxy.rs +++ b/codex-rs/login/src/outbound_proxy.rs @@ -1,4 +1,5 @@ -use codex_http_client::OutboundProxyConfig; +use codex_http_client::HttpClientFactory; +use codex_http_client::OutboundProxyPolicy; /// Auth-layer adapter around client-owned proxy policy. /// @@ -6,17 +7,17 @@ use codex_http_client::OutboundProxyConfig; /// client layer. #[derive(Debug, Clone, PartialEq, Eq)] pub struct AuthRouteConfig { - route_config: OutboundProxyConfig, + http_client_factory: HttpClientFactory, } impl AuthRouteConfig { pub fn respect_system_proxy() -> Self { Self { - route_config: OutboundProxyConfig::respect_system_proxy(), + http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy), } } - pub(crate) fn route_config(&self) -> &OutboundProxyConfig { - &self.route_config + pub(crate) fn http_client_factory(&self) -> &HttpClientFactory { + &self.http_client_factory } } diff --git a/codex-rs/memories/write/src/runtime.rs b/codex-rs/memories/write/src/runtime.rs index ae0903cfdc..903a1743ff 100644 --- a/codex-rs/memories/write/src/runtime.rs +++ b/codex-rs/memories/write/src/runtime.rs @@ -263,6 +263,7 @@ impl MemoryStartupContext { config.features.enabled(Feature::ItemIds), /*concurrent_reasoning_summaries_enabled*/ false, /*attestation_provider*/ None, + config.http_client_factory(), ); let mut client_session = model_client.new_session(); From 6cf42cf16516ab3a125d853561ae3b8c77d6c13e Mon Sep 17 00:00:00 2001 From: stevenlee-oai Date: Tue, 7 Jul 2026 00:03:27 -0400 Subject: [PATCH 36/76] Serialize shared MCP OAuth credential stores (#30292) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Codex Thread 019edd6d-6f14-74e2-853c-345d1803d4a6](https://codex-thread-link.openai.chatgpt-team.site/thread/019edd6d-6f14-74e2-853c-345d1803d4a6) ## Stack Review and merge in order. Every layer is independently correct and documents its safe stopping point. 1. [openai/codex#30292](https://github.com/openai/codex/pull/30292) — aggregate File/Secrets store locking 2. [openai/codex#30293](https://github.com/openai/codex/pull/30293) — resolve and lifecycle-pin the exact OAuth store 3. [openai/codex#30416](https://github.com/openai/codex/pull/30416) — serialized authoritative refresh transaction 4. [openai/codex#30294](https://github.com/openai/codex/pull/30294) — Codex-owned transport refresh and one-shot 401 recovery 5. [openai/codex#30295](https://github.com/openai/codex/pull/30295) — login/logout transaction serialization 6. [openai/codex#30296](https://github.com/openai/codex/pull/30296) — diagnostic-only Auto store drift reporting **This PR is layer 1.** ## Why MCP OAuth credentials stored in File or Secrets share one aggregate map. Concurrent read-modify-write operations for different MCP servers can both read the same snapshot and let the later write discard the earlier update. That is a correctness problem independent of refresh-token rotation. ## What this PR does - Adds a bounded cross-process lock around aggregate File and Secrets loads, saves, and deletes. - Distinguishes aggregate-lock failures from Secrets backend unavailability, so Auto can fall back only for the latter and cannot bypass serialization by reading or writing File. - Keeps Direct keyring operations outside this lock because they are already per credential. - Releases the Secrets aggregate lock before legacy File cleanup so cross-store cleanup cannot create nested aggregate-lock ordering. - Tests actual contention by waiting for an observed `WouldBlock`, rather than assuming a sleeping worker reached the lock. - Tests load and save with only the Secrets lock path broken while fallback File remains readable and writable. ## Decisions and non-goals - This lock protects aggregate-store read-modify-write integrity only. It does not choose a credential authority or serialize an OAuth refresh transaction. - The lock is scoped to the active `CODEX_HOME`, matching the aggregate files it protects. - Lock waits are bounded, and coordination failures are surfaced rather than treated as evidence that Secrets is unavailable. ## Safe stopping point This PR can merge alone. It prevents lost updates and partial aggregate reads. Auto can still resolve again during a client lifecycle until layer 2, and concurrent refreshes remain possible until layer 3. ## Validation - `just test -p codex-rmcp-client` (96 passed; expected environment skips) - Focused aggregate File/Secrets lock contention and Auto fallback tests --- codex-rs/rmcp-client/src/oauth.rs | 123 +++-- codex-rs/rmcp-client/src/oauth/store_lock.rs | 181 +++++++ .../rmcp-client/src/oauth/test_support.rs | 46 ++ .../src/oauth/tests/store_lock_tests.rs | 480 ++++++++++++++++++ 4 files changed, 775 insertions(+), 55 deletions(-) create mode 100644 codex-rs/rmcp-client/src/oauth/store_lock.rs create mode 100644 codex-rs/rmcp-client/src/oauth/test_support.rs create mode 100644 codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs diff --git a/codex-rs/rmcp-client/src/oauth.rs b/codex-rs/rmcp-client/src/oauth.rs index d0005d20a6..46e6279a16 100644 --- a/codex-rs/rmcp-client/src/oauth.rs +++ b/codex-rs/rmcp-client/src/oauth.rs @@ -16,6 +16,12 @@ //! //! If the keyring is not available or fails, we fall back to CODEX_HOME/.credentials.json which is consistent with other coding CLI agents. +mod store_lock; + +#[cfg(test)] +#[path = "oauth/test_support.rs"] +mod test_support; + use anyhow::Context; use anyhow::Error; use anyhow::Result; @@ -49,6 +55,10 @@ use std::time::SystemTime; use std::time::UNIX_EPOCH; use tracing::warn; +use self::store_lock::OAuthStore; +use self::store_lock::OAuthStoreLock; +use self::store_lock::OAuthStoreLockFailure; + use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; use rmcp::transport::auth::AuthorizationManager; @@ -173,6 +183,11 @@ fn load_oauth_tokens_from_keyring_with_fallback_to_file Ok(Some(tokens)), Ok(None) => load_oauth_tokens_from_file(server_name, url), + // A store lock failure means the configured aggregate authority could be changing, or + // that coordination itself is unavailable. It is not evidence that the keyring backend + // is unavailable, so consulting File here could replay credentials hidden behind a + // newer Secrets entry. This is the load-side counterpart of the save guard below. + Err(error) if error.downcast_ref::().is_some() => Err(error), Err(error) => { warn!("failed to read OAuth tokens from keyring: {error}"); load_oauth_tokens_from_file(server_name, url) @@ -220,6 +235,7 @@ fn load_oauth_tokens_from_secrets_keyring( server_name: &str, url: &str, ) -> Result> { + let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?; let codex_home = find_codex_home()?; let manager = SecretsManager::new_with_keyring_store_and_namespace( codex_home.to_path_buf(), @@ -308,12 +324,39 @@ fn save_oauth_tokens_to_direct_keyring( } } +/// Saves one credential while holding the Secrets aggregate-store lock across the mutation. +/// +/// The Secrets lock is released before fallback File cleanup to preserve aggregate-lock ordering. fn save_oauth_tokens_to_secrets_keyring( keyring_store: &K, server_name: &str, tokens: &StoredOAuthTokens, ) -> Result<()> { let serialized = serde_json::to_string(tokens).context("failed to serialize OAuth tokens")?; + { + let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?; + save_oauth_tokens_to_secrets_keyring_with_lock_held( + keyring_store, + server_name, + tokens, + &serialized, + )?; + } + + let key = compute_store_key(server_name, &tokens.url)?; + if let Err(error) = delete_oauth_tokens_from_file(&key) { + warn!("failed to remove OAuth tokens from fallback storage: {error:?}"); + } + Ok(()) +} + +/// Writes one credential to Secrets. The caller must hold the Secrets aggregate-store lock. +fn save_oauth_tokens_to_secrets_keyring_with_lock_held( + keyring_store: &K, + server_name: &str, + tokens: &StoredOAuthTokens, + serialized: &str, +) -> Result<()> { let codex_home = find_codex_home()?; let manager = SecretsManager::new_with_keyring_store_and_namespace( codex_home.to_path_buf(), @@ -323,14 +366,8 @@ fn save_oauth_tokens_to_secrets_keyring( ); let secret_name = compute_secret_name(server_name, &tokens.url)?; manager - .set(&SecretScope::Global, &secret_name, &serialized) - .context("failed to write OAuth tokens to encrypted storage")?; - - let key = compute_store_key(server_name, &tokens.url)?; - if let Err(error) = delete_oauth_tokens_from_file(&key) { - warn!("failed to remove OAuth tokens from fallback storage: {error:?}"); - } - Ok(()) + .set(&SecretScope::Global, &secret_name, serialized) + .context("failed to write OAuth tokens to encrypted storage") } fn save_oauth_tokens_with_keyring_with_fallback_to_file( @@ -341,6 +378,10 @@ fn save_oauth_tokens_with_keyring_with_fallback_to_file Result<()> { match save_oauth_tokens_with_keyring(keyring_store, keyring_backend_kind, server_name, tokens) { Ok(()) => Ok(()), + // As on load, a store lock failure is a coordination failure rather than evidence that + // the keyring backend is unavailable. Falling back could leave a newer File token hidden + // behind a stale Secrets entry. + Err(error) if error.downcast_ref::().is_some() => Err(error), Err(error) => { let message = error.to_string(); warn!("falling back to file storage for OAuth tokens: {message}"); @@ -430,6 +471,7 @@ fn delete_oauth_tokens_from_secrets_keyring( server_name: &str, url: &str, ) -> Result { + let _store_lock = OAuthStoreLock::acquire(OAuthStore::Secrets)?; let codex_home = find_codex_home()?; let manager = SecretsManager::new_with_keyring_store_and_namespace( codex_home.to_path_buf(), @@ -592,7 +634,8 @@ struct FallbackTokenEntry { } fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result> { - let Some(store) = read_fallback_file()? else { + let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?; + let Some(store) = read_fallback_file_unlocked()? else { return Ok(None); }; @@ -634,9 +677,17 @@ fn load_oauth_tokens_from_file(server_name: &str, url: &str) -> Result Result<()> { + let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?; + save_oauth_tokens_to_file_with_lock_held(tokens) +} + +/// Updates the fallback File. The caller must hold the File aggregate-store lock. +fn save_oauth_tokens_to_file_with_lock_held(tokens: &StoredOAuthTokens) -> Result<()> { let key = compute_store_key(&tokens.server_name, &tokens.url)?; - let mut store = read_fallback_file()?.unwrap_or_default(); + let mut store = read_fallback_file_unlocked()?.unwrap_or_default(); let token_response = &tokens.token_response.0; let expires_at = tokens @@ -664,7 +715,8 @@ fn save_oauth_tokens_to_file(tokens: &StoredOAuthTokens) -> Result<()> { } fn delete_oauth_tokens_from_file(key: &str) -> Result { - let mut store = match read_fallback_file()? { + let _store_lock = OAuthStoreLock::acquire(OAuthStore::File)?; + let mut store = match read_fallback_file_unlocked()? { Some(store) => store, None => return Ok(false), }; @@ -750,7 +802,7 @@ fn fallback_file_path() -> Result { Ok(find_codex_home()?.join(FALLBACK_FILENAME).to_path_buf()) } -fn read_fallback_file() -> Result> { +fn read_fallback_file_unlocked() -> Result> { let path = fallback_file_path()?; let contents = match fs::read_to_string(&path) { Ok(contents) => contents, @@ -814,52 +866,13 @@ fn sha_256_prefix(value: &Value) -> Result { mod tests { use super::*; use anyhow::Result; + use codex_keyring_store::tests::MockKeyringStore; use codex_secrets::compute_keyring_account; use keyring::Error as KeyringError; use pretty_assertions::assert_eq; use std::sync::Arc; - use std::sync::Mutex; - use std::sync::MutexGuard; - use std::sync::OnceLock; - use std::sync::PoisonError; - use tempfile::tempdir; - use codex_keyring_store::tests::MockKeyringStore; - - struct TempCodexHome { - _guard: MutexGuard<'static, ()>, - _dir: tempfile::TempDir, - } - - impl TempCodexHome { - fn new() -> Self { - static LOCK: OnceLock> = OnceLock::new(); - let guard = LOCK - .get_or_init(Mutex::default) - .lock() - .unwrap_or_else(PoisonError::into_inner); - let dir = tempdir().expect("create CODEX_HOME temp dir"); - unsafe { - std::env::set_var("CODEX_HOME", dir.path()); - } - Self { - _guard: guard, - _dir: dir, - } - } - - fn path(&self) -> &std::path::Path { - self._dir.path() - } - } - - impl Drop for TempCodexHome { - fn drop(&mut self) { - unsafe { - std::env::remove_var("CODEX_HOME"); - } - } - } + use super::test_support::TempCodexHome; #[test] fn load_oauth_tokens_reads_from_keyring_when_available() -> Result<()> { @@ -964,7 +977,7 @@ mod tests { let fallback_path = super::fallback_file_path()?; assert!(fallback_path.exists(), "fallback file should be created"); - let saved = super::read_fallback_file()?.expect("fallback file should load"); + let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load"); let key = super::compute_store_key(&tokens.server_name, &tokens.url)?; let entry = saved.get(&key).expect("entry for key"); assert_eq!(entry.server_name, tokens.server_name); @@ -1076,7 +1089,7 @@ mod tests { &tokens, )?; - let saved = super::read_fallback_file()?.expect("fallback file should load"); + let saved = super::read_fallback_file_unlocked()?.expect("fallback file should load"); let key = super::compute_store_key(&tokens.server_name, &tokens.url)?; assert!(saved.contains_key(&key)); Ok(()) diff --git a/codex-rs/rmcp-client/src/oauth/store_lock.rs b/codex-rs/rmcp-client/src/oauth/store_lock.rs new file mode 100644 index 0000000000..5f83ebb8a6 --- /dev/null +++ b/codex-rs/rmcp-client/src/oauth/store_lock.rs @@ -0,0 +1,181 @@ +//! Cross-process serialization for MCP OAuth stores shared by multiple credentials. +//! +//! File and Secrets each keep credentials for multiple MCP servers in one aggregate document. +//! Their lock therefore protects the complete read-modify-write operation. Direct keyring entries +//! are already stored independently per credential and do not use this lock. + +use std::fs; +use std::fs::File; +use std::fs::OpenOptions; +use std::io; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Result; +use codex_utils_home_dir::find_codex_home; + +const OAUTH_LOCK_DIR: &str = "mcp-oauth-locks"; +const STORE_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_secs(60); +const STORE_LOCK_RETRY_SLEEP: Duration = Duration::from_millis(50); +// Tests listen for this event so they prove a contender reached the real WouldBlock branch. +const LOCK_CONTENTION_EVENT_TARGET: &str = "codex_rmcp_client::oauth::store_lock::contention"; + +#[derive(Clone, Copy, Debug)] +pub(super) enum OAuthStore { + File, + Secrets, +} + +impl OAuthStore { + fn lock_filename(self) -> &'static str { + match self { + Self::File => "file-store.lock", + Self::Secrets => "secrets-store.lock", + } + } +} + +impl std::fmt::Display for OAuthStore { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::File => f.write_str("fallback file"), + Self::Secrets => f.write_str("encrypted secrets"), + } + } +} + +/// Serializes one complete operation on an aggregate OAuth credential store. +pub(super) struct OAuthStoreLock { + _file: File, +} + +impl OAuthStoreLock { + pub(super) fn acquire(store: OAuthStore) -> Result { + // This lock intentionally follows the existing local File/Secrets credential-store + // authority. Those stores are CODEX_HOME-backed today: if CODEX_HOME is unset they use + // the default home (`~/.codex`), and if an embedder has no local home/filesystem authority + // those stores already cannot operate. A future provider-backed credential store should + // provide its own matching lock authority instead of using this local path. + let codex_home = find_codex_home() + .map_err(|source| OAuthStoreLockFailure::CodexHome { store, source })?; + Self::acquire_in(&codex_home, store, STORE_LOCK_ACQUIRE_TIMEOUT) + } + + pub(super) fn acquire_in( + codex_home: &Path, + store: OAuthStore, + acquire_timeout: Duration, + ) -> Result { + let path = oauth_store_lock_path(codex_home, store); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|source| OAuthStoreLockFailure::CreateDir { + store, + path: parent.to_path_buf(), + source, + })?; + } + + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path) + .map_err(|source| OAuthStoreLockFailure::Open { + store, + path: path.clone(), + source, + })?; + let started = Instant::now(); + let mut reported_contention = false; + + loop { + match file.try_lock() { + Ok(()) => return Ok(Self { _file: file }), + Err(std::fs::TryLockError::WouldBlock) if started.elapsed() >= acquire_timeout => { + return Err(OAuthStoreLockFailure::Timeout { + store, + path, + acquire_timeout, + } + .into()); + } + Err(std::fs::TryLockError::WouldBlock) => { + if !reported_contention { + tracing::debug!( + target: LOCK_CONTENTION_EVENT_TARGET, + store = %store, + lock_path = %path.display(), + "waiting for another process to finish updating MCP OAuth store state" + ); + reported_contention = true; + } + std::thread::sleep(STORE_LOCK_RETRY_SLEEP.min(acquire_timeout)); + } + Err(error) => { + return Err(OAuthStoreLockFailure::Lock { + store, + path, + source: io::Error::from(error), + } + .into()); + } + } + } + } +} + +/// Marks aggregate-store coordination failures in an [`anyhow::Error`] chain. +/// +/// Auto may fall back when the configured keyring backend is unavailable, but it must surface a +/// lock failure. Falling back while another process owns the aggregate-store lock could leave the +/// newer credential in File while a stale Secrets entry remains preferred. +#[derive(Debug, thiserror::Error)] +pub(super) enum OAuthStoreLockFailure { + #[error("failed to resolve CODEX_HOME for MCP OAuth {store} aggregate-store lock")] + CodexHome { + store: OAuthStore, + #[source] + source: io::Error, + }, + #[error("failed to create MCP OAuth {store} aggregate-store lock directory {}", path.display())] + CreateDir { + store: OAuthStore, + path: PathBuf, + #[source] + source: io::Error, + }, + #[error("failed to open MCP OAuth {store} aggregate-store lock {}", path.display())] + Open { + store: OAuthStore, + path: PathBuf, + #[source] + source: io::Error, + }, + #[error( + "timed out after {acquire_timeout:?} waiting for MCP OAuth {store} aggregate-store lock {}", + path.display() + )] + Timeout { + store: OAuthStore, + path: PathBuf, + acquire_timeout: Duration, + }, + #[error("failed to lock MCP OAuth {store} aggregate-store lock {}", path.display())] + Lock { + store: OAuthStore, + path: PathBuf, + #[source] + source: io::Error, + }, +} + +fn oauth_store_lock_path(codex_home: &Path, store: OAuthStore) -> PathBuf { + codex_home.join(OAUTH_LOCK_DIR).join(store.lock_filename()) +} + +#[cfg(test)] +#[path = "tests/store_lock_tests.rs"] +mod tests; diff --git a/codex-rs/rmcp-client/src/oauth/test_support.rs b/codex-rs/rmcp-client/src/oauth/test_support.rs new file mode 100644 index 0000000000..6ca2f510f5 --- /dev/null +++ b/codex-rs/rmcp-client/src/oauth/test_support.rs @@ -0,0 +1,46 @@ +use std::path::Path; +use std::sync::Mutex; +use std::sync::MutexGuard; +use std::sync::OnceLock; +use std::sync::PoisonError; + +use tempfile::tempdir; + +/// Serializes tests that mutate process-wide CODEX_HOME. +/// +/// Keep OAuth tests on this one guard instead of defining per-module helpers; otherwise +/// concurrently running test modules can point File/Secrets storage at different homes. +pub(super) struct TempCodexHome { + _guard: MutexGuard<'static, ()>, + _dir: tempfile::TempDir, +} + +impl TempCodexHome { + pub(super) fn new() -> Self { + static LOCK: OnceLock> = OnceLock::new(); + let guard = LOCK + .get_or_init(Mutex::default) + .lock() + .unwrap_or_else(PoisonError::into_inner); + let dir = tempdir().expect("create CODEX_HOME temp dir"); + unsafe { + std::env::set_var("CODEX_HOME", dir.path()); + } + Self { + _guard: guard, + _dir: dir, + } + } + + pub(super) fn path(&self) -> &Path { + self._dir.path() + } +} + +impl Drop for TempCodexHome { + fn drop(&mut self) { + unsafe { + std::env::remove_var("CODEX_HOME"); + } + } +} diff --git a/codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs b/codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs new file mode 100644 index 0000000000..aafc362c20 --- /dev/null +++ b/codex-rs/rmcp-client/src/oauth/tests/store_lock_tests.rs @@ -0,0 +1,480 @@ +use std::process::Command; +use std::sync::mpsc; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use codex_config::types::AuthKeyringBackendKind; +use codex_keyring_store::tests::MockKeyringStore; +use oauth2::AccessToken; +use oauth2::RefreshToken; +use oauth2::Scope; +use oauth2::TokenResponse; +use oauth2::basic::BasicTokenType; +use pretty_assertions::assert_eq; +use rmcp::transport::auth::OAuthTokenResponse; +use rmcp::transport::auth::VendorExtraTokenFields; +use tracing::Event; +use tracing::Id; +use tracing::Metadata; +use tracing::Subscriber; +use tracing::span::Attributes; +use tracing::span::Record; +use tracing::subscriber::Interest; + +use super::OAuthStore; +use super::OAuthStoreLock; +use super::OAuthStoreLockFailure; +use crate::oauth::StoredOAuthTokens; +use crate::oauth::WrappedOAuthTokenResponse; +use crate::oauth::fallback_file_path; +use crate::oauth::load_oauth_tokens_from_file; +use crate::oauth::load_oauth_tokens_from_keyring; +use crate::oauth::load_oauth_tokens_from_keyring_with_fallback_to_file; +use crate::oauth::save_oauth_tokens_to_file; +use crate::oauth::save_oauth_tokens_to_file_with_lock_held; +use crate::oauth::save_oauth_tokens_to_secrets_keyring_with_lock_held; +use crate::oauth::save_oauth_tokens_with_keyring; +use crate::oauth::save_oauth_tokens_with_keyring_with_fallback_to_file; +use crate::oauth::test_support::TempCodexHome; + +const STORE_LOCK_CONTENTION_EVENT_TARGET: &str = "codex_rmcp_client::oauth::store_lock::contention"; + +fn assert_tokens_match_without_expiry(actual: &StoredOAuthTokens, expected: &StoredOAuthTokens) { + assert_eq!(actual.server_name, expected.server_name); + assert_eq!(actual.url, expected.url); + assert_eq!(actual.client_id, expected.client_id); + assert_eq!(actual.expires_at, expected.expires_at); + assert_token_response_match_without_expiry(&actual.token_response, &expected.token_response); +} + +fn assert_token_response_match_without_expiry( + actual: &WrappedOAuthTokenResponse, + expected: &WrappedOAuthTokenResponse, +) { + let actual_response = &actual.0; + let expected_response = &expected.0; + + assert_eq!( + actual_response.access_token().secret(), + expected_response.access_token().secret() + ); + assert_eq!(actual_response.token_type(), expected_response.token_type()); + assert_eq!( + actual_response.refresh_token().map(RefreshToken::secret), + expected_response.refresh_token().map(RefreshToken::secret), + ); + assert_eq!(actual_response.scopes(), expected_response.scopes()); + assert_eq!( + actual_response.extra_fields().0, + expected_response.extra_fields().0 + ); + assert_eq!( + actual_response.expires_in().is_some(), + expected_response.expires_in().is_some() + ); +} + +fn sample_tokens() -> StoredOAuthTokens { + let mut response = OAuthTokenResponse::new( + AccessToken::new("access-token".to_string()), + BasicTokenType::Bearer, + VendorExtraTokenFields::default(), + ); + response.set_refresh_token(Some(RefreshToken::new("refresh-token".to_string()))); + response.set_scopes(Some(vec![ + Scope::new("scope-a".to_string()), + Scope::new("scope-b".to_string()), + ])); + let expires_in = Duration::from_secs(3600); + response.set_expires_in(Some(&expires_in)); + let expires_at = crate::oauth::compute_expires_at_millis(&response); + + StoredOAuthTokens { + server_name: "test-server".to_string(), + url: "https://example.test".to_string(), + client_id: "client-id".to_string(), + token_response: WrappedOAuthTokenResponse(response), + expires_at, + } +} + +const LOCK_HOLDER_CHILD_TEST: &str = + "oauth::store_lock::tests::store_lock_is_released_when_holder_process_exits_child"; +const LOCK_HOLDER_READY_PATH_ENV: &str = "CODEX_OAUTH_STORE_LOCK_CHILD_READY_PATH"; + +#[test] +fn store_lock_is_released_when_holder_process_exits() -> Result<()> { + let env = TempCodexHome::new(); + let ready_file = env.path().join("lock-holder-ready"); + let mut child = Command::new(std::env::current_exe()?) + .arg("--exact") + .arg(LOCK_HOLDER_CHILD_TEST) + .arg("--ignored") + .env("CODEX_HOME", env.path()) + .env(LOCK_HOLDER_READY_PATH_ENV, &ready_file) + .spawn() + .context("spawn OAuth store lock holder test process")?; + + let test_result = (|| -> Result<()> { + let started = Instant::now(); + while !ready_file.exists() { + if started.elapsed() > Duration::from_secs(/*secs*/ 5) { + anyhow::bail!("timed out waiting for child process to acquire OAuth store lock"); + } + std::thread::sleep(Duration::from_millis(/*millis*/ 20)); + } + + let error = match OAuthStoreLock::acquire_in( + env.path(), + OAuthStore::File, + Duration::from_millis(/*millis*/ 100), + ) { + Ok(_) => { + anyhow::bail!("live holder process should keep the OAuth store lock unavailable") + } + Err(error) => error, + }; + assert!(matches!( + error.downcast_ref::(), + Some(OAuthStoreLockFailure::Timeout { .. }) + )); + + child + .kill() + .context("kill OAuth store lock holder process")?; + let status = child + .wait() + .context("wait for killed OAuth store lock holder process")?; + assert!(!status.success()); + let _lock = OAuthStoreLock::acquire_in( + env.path(), + OAuthStore::File, + Duration::from_secs(/*secs*/ 1), + )?; + Ok(()) + })(); + + if let Ok(None) = child.try_wait() { + let _ = child.kill(); + let _ = child.wait(); + } + + test_result +} + +#[test] +#[ignore = "child process for store_lock_is_released_when_holder_process_exits"] +fn store_lock_is_released_when_holder_process_exits_child() -> Result<()> { + let ready_file = match std::env::var_os(LOCK_HOLDER_READY_PATH_ENV) { + Some(path) => std::path::PathBuf::from(path), + None => return Ok(()), + }; + let _lock = OAuthStoreLock::acquire(OAuthStore::File)?; + std::fs::write(ready_file, b"ready")?; + loop { + std::thread::sleep(Duration::from_secs(/*secs*/ 60)); + } +} + +#[test] +fn auto_save_secrets_lock_failure_does_not_fall_back_to_file() -> Result<()> { + let env = TempCodexHome::new(); + let lock_dir = env.path().join("mcp-oauth-locks"); + std::fs::create_dir_all(&lock_dir)?; + // Break only the Secrets lock path. The distinct File lock remains usable, so Auto would + // successfully write fallback credentials if it mistook coordination failure for backend + // unavailability. + std::fs::create_dir(lock_dir.join("secrets-store.lock"))?; + let keyring_store = MockKeyringStore::default(); + let tokens = sample_tokens(); + + let error = save_oauth_tokens_with_keyring_with_fallback_to_file( + &keyring_store, + AuthKeyringBackendKind::Secrets, + &tokens.server_name, + &tokens, + ) + .expect_err("aggregate-store lock failure must abort Auto persistence"); + + assert!(error.downcast_ref::().is_some()); + assert!(!fallback_file_path()?.exists()); + save_oauth_tokens_to_file(&tokens)?; + let loaded = load_oauth_tokens_from_file(&tokens.server_name, &tokens.url)? + .expect("fallback File should remain independently writable"); + assert_tokens_match_without_expiry(&loaded, &tokens); + Ok(()) +} + +#[test] +fn auto_load_secrets_lock_failure_does_not_fall_back_to_file() -> Result<()> { + let env = TempCodexHome::new(); + let keyring_store = MockKeyringStore::default(); + let tokens = sample_tokens(); + save_oauth_tokens_to_file(&tokens)?; + + let lock_dir = env.path().join("mcp-oauth-locks"); + std::fs::create_dir(lock_dir.join("secrets-store.lock"))?; + let error = load_oauth_tokens_from_keyring_with_fallback_to_file( + &keyring_store, + AuthKeyringBackendKind::Secrets, + &tokens.server_name, + &tokens.url, + ) + .expect_err("aggregate-store lock failure must abort Auto resolution"); + + assert!(error.downcast_ref::().is_some()); + let loaded = load_oauth_tokens_from_file(&tokens.server_name, &tokens.url)? + .expect("fallback File should remain independently readable"); + assert_tokens_match_without_expiry(&loaded, &tokens); + Ok(()) +} + +struct LockContentionSubscriber { + contended_tx: mpsc::Sender<()>, +} + +impl Subscriber for LockContentionSubscriber { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.target() == STORE_LOCK_CONTENTION_EVENT_TARGET + } + + fn register_callsite(&self, metadata: &'static Metadata<'static>) -> Interest { + if self.enabled(metadata) { + Interest::always() + } else { + Interest::never() + } + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::DEBUG) + } + + fn new_span(&self, _span: &Attributes<'_>) -> Id { + Id::from_u64(/*u*/ 1) + } + + fn record(&self, _span: &Id, _values: &Record<'_>) {} + + fn record_follows_from(&self, _span: &Id, _follows_from: &Id) {} + + fn event(&self, event: &Event<'_>) { + if self.enabled(event.metadata()) { + self.contended_tx + .send(()) + .expect("signal actual OAuth store lock contention"); + } + } + + fn enter(&self, _span: &Id) {} + + fn exit(&self, _span: &Id) {} +} + +fn complete_after_store_lock_contention( + codex_home: &std::path::Path, + store: OAuthStore, + operation: impl FnOnce() -> Result + Send + 'static, +) -> Result +where + T: Send + 'static, +{ + let held_lock = + OAuthStoreLock::acquire_in(codex_home, store, Duration::from_millis(/*millis*/ 100))?; + let (contended_tx, contended_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + tracing::subscriber::with_default(LockContentionSubscriber { contended_tx }, || { + result_tx + .send(operation()) + .expect("send contending OAuth store operation result"); + }); + }); + + // This event is emitted only after `try_lock()` returns WouldBlock, so the test fails if the + // operation stops acquiring the aggregate-store lock. + contended_rx.recv_timeout(Duration::from_secs(/*secs*/ 1))?; + drop(held_lock); + let result = result_rx.recv_timeout(Duration::from_secs(/*secs*/ 10))??; + worker + .join() + .expect("contending OAuth store worker should finish"); + Ok(result) +} + +#[test] +fn file_store_lock_preserves_updates_for_different_servers() -> Result<()> { + let env = TempCodexHome::new(); + let first = sample_tokens(); + let mut second = sample_tokens(); + second.server_name = "second-server".to_string(); + second.url = "https://second.example.test".to_string(); + + let held_lock = OAuthStoreLock::acquire_in( + env.path(), + OAuthStore::File, + Duration::from_millis(/*millis*/ 100), + )?; + let (contended_tx, contended_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let second_for_writer = second.clone(); + let writer = std::thread::spawn(move || { + tracing::subscriber::with_default(LockContentionSubscriber { contended_tx }, || { + result_tx + .send(save_oauth_tokens_to_file(&second_for_writer)) + .expect("send writer result"); + }); + }); + + contended_rx.recv_timeout(Duration::from_secs(/*secs*/ 1))?; + save_oauth_tokens_to_file_with_lock_held(&first)?; + drop(held_lock); + result_rx.recv_timeout(Duration::from_secs(/*secs*/ 10))??; + writer.join().expect("file store writer should finish"); + + let loaded_first = load_oauth_tokens_from_file(&first.server_name, &first.url)? + .expect("first server tokens should remain stored"); + let loaded_second = load_oauth_tokens_from_file(&second.server_name, &second.url)? + .expect("second server tokens should be stored"); + assert_tokens_match_without_expiry(&loaded_first, &first); + assert_tokens_match_without_expiry(&loaded_second, &second); + Ok(()) +} + +#[test] +fn file_store_load_and_delete_observe_aggregate_lock() -> Result<()> { + let env = TempCodexHome::new(); + let tokens = sample_tokens(); + save_oauth_tokens_to_file(&tokens)?; + + let server_name = tokens.server_name.clone(); + let url = tokens.url.clone(); + let loaded = complete_after_store_lock_contention(env.path(), OAuthStore::File, move || { + load_oauth_tokens_from_file(&server_name, &url) + })? + .expect("file credentials should remain readable after contention"); + assert_tokens_match_without_expiry(&loaded, &tokens); + + let key = crate::oauth::compute_store_key(&tokens.server_name, &tokens.url)?; + let removed = complete_after_store_lock_contention(env.path(), OAuthStore::File, move || { + crate::oauth::delete_oauth_tokens_from_file(&key) + })?; + assert!(removed); + assert!(load_oauth_tokens_from_file(&tokens.server_name, &tokens.url)?.is_none()); + Ok(()) +} + +#[test] +fn secrets_store_lock_preserves_updates_for_different_servers() -> Result<()> { + let env = TempCodexHome::new(); + let keyring_store = MockKeyringStore::default(); + let first = sample_tokens(); + let mut second = sample_tokens(); + second.server_name = "second-server".to_string(); + second.url = "https://second.example.test".to_string(); + + let held_lock = OAuthStoreLock::acquire_in( + env.path(), + OAuthStore::Secrets, + Duration::from_millis(/*millis*/ 100), + )?; + let (contended_tx, contended_rx) = mpsc::channel(); + let (result_tx, result_rx) = mpsc::channel(); + let store_for_writer = keyring_store.clone(); + let second_for_writer = second.clone(); + let writer = std::thread::spawn(move || { + tracing::subscriber::with_default(LockContentionSubscriber { contended_tx }, || { + result_tx + .send(save_oauth_tokens_with_keyring( + &store_for_writer, + AuthKeyringBackendKind::Secrets, + &second_for_writer.server_name, + &second_for_writer, + )) + .expect("send writer result"); + }); + }); + + contended_rx.recv_timeout(Duration::from_secs(/*secs*/ 1))?; + let first_serialized = serde_json::to_string(&first)?; + save_oauth_tokens_to_secrets_keyring_with_lock_held( + &keyring_store, + &first.server_name, + &first, + &first_serialized, + )?; + drop(held_lock); + result_rx.recv_timeout(Duration::from_secs(/*secs*/ 10))??; + writer.join().expect("secrets store writer should finish"); + + let loaded_first = load_oauth_tokens_from_keyring( + &keyring_store, + AuthKeyringBackendKind::Secrets, + &first.server_name, + &first.url, + )? + .expect("first server tokens should remain stored"); + let loaded_second = load_oauth_tokens_from_keyring( + &keyring_store, + AuthKeyringBackendKind::Secrets, + &second.server_name, + &second.url, + )? + .expect("second server tokens should be stored"); + assert_tokens_match_without_expiry(&loaded_first, &first); + assert_tokens_match_without_expiry(&loaded_second, &second); + Ok(()) +} + +#[test] +fn secrets_store_load_and_delete_observe_aggregate_lock() -> Result<()> { + let env = TempCodexHome::new(); + let keyring_store = MockKeyringStore::default(); + let tokens = sample_tokens(); + save_oauth_tokens_with_keyring( + &keyring_store, + AuthKeyringBackendKind::Secrets, + &tokens.server_name, + &tokens, + )?; + + let store_for_load = keyring_store.clone(); + let server_name = tokens.server_name.clone(); + let url = tokens.url.clone(); + let loaded = + complete_after_store_lock_contention(env.path(), OAuthStore::Secrets, move || { + load_oauth_tokens_from_keyring( + &store_for_load, + AuthKeyringBackendKind::Secrets, + &server_name, + &url, + ) + })? + .expect("encrypted credentials should remain readable after contention"); + assert_tokens_match_without_expiry(&loaded, &tokens); + + let store_for_delete = keyring_store.clone(); + let server_name = tokens.server_name.clone(); + let url = tokens.url.clone(); + let removed = + complete_after_store_lock_contention(env.path(), OAuthStore::Secrets, move || { + crate::oauth::delete_oauth_tokens_from_secrets_keyring( + &store_for_delete, + &server_name, + &url, + ) + })?; + assert!(removed); + assert!( + load_oauth_tokens_from_keyring( + &keyring_store, + AuthKeyringBackendKind::Secrets, + &tokens.server_name, + &tokens.url, + )? + .is_none() + ); + Ok(()) +} From d7ab20ce62e88699947d7b5efb52bc2cd19e006b Mon Sep 17 00:00:00 2001 From: jay Date: Mon, 6 Jul 2026 21:19:50 -0700 Subject: [PATCH 37/76] [codex-cli] Show reset details in redemption picker (#30488) ## Why Users can see that usage-limit reset credits are available, but not which credits they have, when each one expires, or which credit will be consumed. The TUI should use the supported rate-limit RPC for that information without maintaining a second reset-credit request path. ## What changed - load reset-credit details through the existing `account/rateLimits/read` refresh when the user opens **Redeem usage limit reset** - show available credits sorted by expiry, using the backend title when present and a scope-based fallback otherwise - consume the exact selected credit and preserve its idempotency key across retries - fall back to the existing generic reset action when the RPC returns a positive count without detail rows - remove the dedicated list-RPC event, request, response-combining, and TUI state plumbing - handle a selected credit becoming unavailable without incorrectly caching the user's total count as zero - handle forward-compatible unknown reset types with the existing scope-based fallback label ## TUI preview Rendered from the final standard-width and narrow `insta` snapshots in this PR. pr30488-reset-picker-preview ## Validation - `just test -p codex-tui chatwidget::tests::usage` (32 passed) - `just fix -p codex-tui` - no pending `insta` snapshots Uses the reset-credit details added by #30395. Fixes #29618. --- codex-rs/tui/src/app/background_requests.rs | 33 +- codex-rs/tui/src/app/event_dispatch.rs | 59 +-- codex-rs/tui/src/app_event.rs | 13 +- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui/src/chatwidget/reset_credits.rs | 129 +++++++ ...tests__rate_limit_reset_picker_narrow.snap | 13 + ..._tests__rate_limit_reset_popup_states.snap | 10 +- ...sage_limit_reset_confirmation_monthly.snap | 18 +- codex-rs/tui/src/chatwidget/tests/usage.rs | 362 +++++++++++------- codex-rs/tui/src/chatwidget/usage.rs | 144 ++++--- 10 files changed, 526 insertions(+), 256 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/reset_credits.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 9162b2c868..6e2fd0ecbd 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -83,7 +83,8 @@ impl App { tokio::spawn(async move { let request = fetch_account_rate_limits(request_handle); let result = match origin { - RateLimitRefreshOrigin::ResetConsume { .. } => { + RateLimitRefreshOrigin::ResetConsume { .. } + | RateLimitRefreshOrigin::ResetPicker { .. } => { tokio::time::timeout(RATE_LIMIT_RESET_REQUEST_TIMEOUT, request) .await .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) @@ -118,37 +119,23 @@ impl App { }); } - pub(super) fn refresh_rate_limit_reset_credits( - &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( - RATE_LIMIT_RESET_REQUEST_TIMEOUT, - fetch_account_rate_limits(request_handle), - ) - .await - .map_err(|_| "account/rateLimits/read timed out in TUI".to_string()) - .and_then(|result| result.map_err(|err| err.to_string())); - app_event_tx.send(AppEvent::RateLimitResetCreditsLoaded { request_id, result }); - }); - } - pub(super) fn consume_rate_limit_reset_credit( &mut self, app_server: &AppServerSession, request_id: u64, idempotency_key: String, + credit_id: Option, ) { 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( RATE_LIMIT_RESET_REQUEST_TIMEOUT, - consume_rate_limit_reset_credit_request(request_handle, idempotency_key.clone()), + consume_rate_limit_reset_credit_request( + request_handle, + idempotency_key.clone(), + credit_id.clone(), + ), ) .await .map_err(|_| "account/rateLimitResetCredit/consume timed out in TUI".to_string()) @@ -156,6 +143,7 @@ impl App { app_event_tx.send(AppEvent::RateLimitResetCreditConsumed { request_id, idempotency_key, + credit_id, result, }); }); @@ -793,6 +781,7 @@ pub(super) async fn fetch_account_token_activity( pub(super) async fn consume_rate_limit_reset_credit_request( request_handle: AppServerRequestHandle, idempotency_key: String, + credit_id: Option, ) -> Result { let request_id = RequestId::String(format!("consume-rate-limit-reset-{}", Uuid::new_v4())); request_handle @@ -800,7 +789,7 @@ pub(super) async fn consume_rate_limit_reset_credit_request( request_id, params: ConsumeAccountRateLimitResetCreditParams { idempotency_key, - credit_id: None, + credit_id, }, }) .await diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index afe269195a..8713537fbc 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -865,6 +865,16 @@ impl App { } 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() + }), + ); + } + RateLimitRefreshOrigin::ResetPicker { request_id } => { + self.chat_widget.finish_rate_limit_reset_credits_refresh( request_id, snapshots, rate_limit_reset_credits.ok_or_else(|| { @@ -905,6 +915,13 @@ impl App { Err(err), ); } + RateLimitRefreshOrigin::ResetPicker { request_id } => { + self.chat_widget.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Err(err), + ); + } } } }, @@ -914,38 +931,27 @@ impl App { } AppEvent::OpenRateLimitResetCredits => { let request_id = self.chat_widget.show_rate_limit_reset_loading_popup(); - self.refresh_rate_limit_reset_credits(app_server, request_id); + self.refresh_rate_limits( + app_server, + RateLimitRefreshOrigin::ResetPicker { request_id }, + ); } - 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), - ); - } - }, - AppEvent::ConsumeRateLimitResetCredit { idempotency_key } => { + AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + } => { let request_id = self.chat_widget.show_rate_limit_reset_consuming_popup(); - self.consume_rate_limit_reset_credit(app_server, request_id, idempotency_key); + self.consume_rate_limit_reset_credit( + app_server, + request_id, + idempotency_key, + credit_id, + ); } AppEvent::RateLimitResetCreditConsumed { request_id, idempotency_key, + credit_id, result, } => { if let Err(err) = &result { @@ -956,6 +962,7 @@ impl App { if self.chat_widget.finish_rate_limit_reset_consume( request_id, idempotency_key, + credit_id, result, ) { self.refresh_rate_limits( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 23e42ce8fe..fa2c9f29fc 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -120,7 +120,8 @@ pub(crate) struct PluginRemoteSectionError { /// invocation and must call `finish_status_rate_limit_refresh` when done so the /// 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. +/// restart. A `ResetPicker` refreshes the rate limits and detailed reset-credit +/// rows before showing redemption choices. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum RateLimitRefreshOrigin { /// Eagerly fetched after bootstrap for `/status` data and reset availability. @@ -130,6 +131,8 @@ pub(crate) enum RateLimitRefreshOrigin { StatusCommand { request_id: u64 }, /// User reopened `/usage` while the cached reset-credit count was zero. UsageMenu { request_id: u64 }, + /// User opened the reset-credit picker. + ResetPicker { request_id: u64 }, /// Refresh requested after a reset credit was successfully consumed. ResetConsume { request_id: u64 }, } @@ -323,21 +326,17 @@ pub(crate) enum AppEvent { /// Open the reset-credit flow selected from the `/usage` menu. OpenRateLimitResetCredits, - /// Result of reading the current reset-credit balance. - RateLimitResetCreditsLoaded { - request_id: u64, - result: Result, - }, - /// Consume one reset credit using a stable idempotency key. ConsumeRateLimitResetCredit { idempotency_key: String, + credit_id: Option, }, /// Result of consuming one reset credit. RateLimitResetCreditConsumed { request_id: u64, idempotency_key: String, + credit_id: Option, result: Result, }, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 42903d2e7f..fa745c652e 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -386,6 +386,7 @@ use self::rate_limits::RateLimitWarningState; use self::rate_limits::app_server_rate_limit_error_kind; pub(crate) use self::rate_limits::fallback_limit_label; use self::rate_limits::is_app_server_cyber_policy_error; +mod reset_credits; pub(crate) use self::rate_limits::limit_label_for_window; mod reasoning_shortcuts; mod rendering; diff --git a/codex-rs/tui/src/chatwidget/reset_credits.rs b/codex-rs/tui/src/chatwidget/reset_credits.rs new file mode 100644 index 0000000000..4e58ec3518 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/reset_credits.rs @@ -0,0 +1,129 @@ +use crate::status::RateLimitSnapshotDisplay; +use chrono::DateTime; +use chrono::Local; +use chrono::Utc; +use codex_app_server_protocol::RateLimitResetCreditStatus; +use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitResetType; +use codex_protocol::account::PlanType; +use std::collections::BTreeMap; + +use super::rate_limits::get_limits_duration; + +pub(super) enum RateLimitResetScope { + Monthly, + WeeklyAndFiveHour, + Unknown, +} + +impl RateLimitResetScope { + pub(super) fn picker_label(&self) -> &'static str { + match self { + Self::Monthly => "Full reset (Monthly)", + Self::WeeklyAndFiveHour => "Full reset (Weekly + 5h)", + Self::Unknown => "Full reset", + } + } + + pub(super) fn usage_description(&self) -> &'static str { + match self { + Self::Monthly => "Reset your current monthly usage limit.", + Self::WeeklyAndFiveHour => "Reset your current 5-hour and weekly usage limits.", + Self::Unknown => "Reset your current usage limits.", + } + } +} + +#[derive(Debug, Eq, PartialEq)] +pub(super) struct ResetCreditOption { + pub(super) credit_id: Option, + pub(super) name: String, + pub(super) description: String, +} + +pub(super) fn rate_limit_reset_scope( + rate_limits: &BTreeMap, + plan_type: Option, +) -> RateLimitResetScope { + let window_labels = rate_limits + .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() + .filter_map(|window| window.window_minutes.and_then(get_limits_duration)) + .collect::>(); + + if window_labels.iter().any(|label| label == "monthly") + || matches!(plan_type, Some(PlanType::Free | PlanType::Go)) + { + RateLimitResetScope::Monthly + } else if window_labels + .iter() + .any(|label| label == "5h" || label == "weekly") + { + RateLimitResetScope::WeeklyAndFiveHour + } else { + RateLimitResetScope::Unknown + } +} + +pub(super) fn reset_credit_options( + summary: &RateLimitResetCreditsSummary, + scope: RateLimitResetScope, +) -> Vec { + let available_count = summary.available_count.max(0); + let detail_limit = usize::try_from(available_count).unwrap_or(usize::MAX); + let mut available_credits = summary + .credits + .as_deref() + .unwrap_or_default() + .iter() + .filter(|credit| credit.status == RateLimitResetCreditStatus::Available) + .collect::>(); + available_credits.sort_by_key(|credit| credit.expires_at.unwrap_or(i64::MAX)); + + let mut options = available_credits + .into_iter() + .take(detail_limit) + .map(|credit| { + let expiration = match credit.expires_at { + Some(expires_at) => DateTime::::from_timestamp(expires_at, 0) + .map(|expires_at| { + format!( + "Expires {}", + expires_at + .with_timezone(&Local) + .format("%H:%M on %-d %b %Y") + ) + }) + .unwrap_or_else(|| "Expiration unavailable".to_string()), + None => "Does not expire".to_string(), + }; + let reset_label = credit + .title + .as_deref() + .filter(|title| !title.trim().is_empty()) + .unwrap_or_else(|| match credit.reset_type { + RateLimitResetType::CodexRateLimits | RateLimitResetType::Unknown => { + scope.picker_label() + } + }); + ResetCreditOption { + credit_id: Some(credit.id.clone()), + name: reset_label.to_string(), + description: format!("{expiration}."), + } + }) + .collect::>(); + + if options.is_empty() { + options.push(ResetCreditOption { + credit_id: None, + name: "Use a reset".to_string(), + description: scope.usage_description().to_string(), + }); + } + + options +} diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap new file mode 100644 index 0000000000..d64823547f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_reset_picker_narrow.snap @@ -0,0 +1,13 @@ +--- +source: tui/src/chatwidget/tests/usage.rs +expression: "render_bottom_popup(&chat, 44)" +--- + Usage limit resets + 2 usage limit resets available. + +› 1. Cancel + 2. Full reset Expires 09:39 on 18 Jun + 2026. + 3. Full reset Does not expire. + + Press enter to confirm or esc to go back 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 12b1568f55..b018313adc 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 @@ -10,10 +10,11 @@ expression: "states.join(\"\\n---\\n\")" Press enter to confirm or esc to go back --- Usage limit resets - You have 2 usage limit resets available. + 2 usage limit resets available. - 1. Use a reset Reset your current 5-hour and weekly usage limits. -› 2. Cancel +› 1. Cancel + 2. Full reset (Weekly + 5 hr) Expires 09:39 on 18 Jun 2026. + 3. Full reset (Weekly + 5 hr) Expires 08:59 on 27 Jun 2026. Press enter to confirm or esc to go back --- @@ -27,7 +28,8 @@ expression: "states.join(\"\\n---\\n\")" Usage limit resets Couldn't load usage limit resets. Please try again. -› 1. Close +› 1. Try again + 2. Close 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 index 08a595d4a2..9ace222f46 100644 --- 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 @@ -4,27 +4,27 @@ expression: "states.join(\"\\n---\\n\")" --- Free: Usage limit resets - You have 1 usage limit reset available. + 1 usage limit reset available. - 1. Use a reset Reset your current monthly usage limit. -› 2. Cancel +› 1. Cancel + 2. Full reset (Monthly) Does not expire. Press enter to confirm or esc to go back --- Go: Usage limit resets - You have 1 usage limit reset available. + 1 usage limit reset available. - 1. Use a reset Reset your current monthly usage limit. -› 2. Cancel +› 1. Cancel + 2. Use a reset Reset your current monthly usage limit. Press enter to confirm or esc to go back --- Business with monthly window: Usage limit resets - You have 1 usage limit reset available. + 1 usage limit reset available. - 1. Use a reset Reset your current monthly usage limit. -› 2. Cancel +› 1. Cancel + 2. Full reset (Monthly) Does not expire. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests/usage.rs b/codex-rs/tui/src/chatwidget/tests/usage.rs index 22908b1112..2261110b60 100644 --- a/codex-rs/tui/src/chatwidget/tests/usage.rs +++ b/codex-rs/tui/src/chatwidget/tests/usage.rs @@ -1,11 +1,81 @@ +use super::super::reset_credits::RateLimitResetScope; +use super::super::reset_credits::ResetCreditOption; +use super::super::reset_credits::reset_credit_options; use super::*; +use chrono::TimeZone; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditOutcome; use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; +use codex_app_server_protocol::RateLimitResetCredit; +use codex_app_server_protocol::RateLimitResetCreditStatus; use codex_app_server_protocol::RateLimitResetCreditsSummary; +use codex_app_server_protocol::RateLimitResetType; +use pretty_assertions::assert_eq; use uuid::Uuid; const TEST_OVERLAY_VIEW_ID: &str = "usage-test-overlay"; +fn reset_credits(available_count: i64) -> RateLimitResetCreditsSummary { + RateLimitResetCreditsSummary { + available_count, + credits: None, + } +} + +fn detailed_reset_credits( + available_count: i64, + credits: Vec, +) -> RateLimitResetCreditsSummary { + RateLimitResetCreditsSummary { + available_count, + credits: Some(credits), + } +} + +fn reset_credit(id: &str, expires_at: Option) -> RateLimitResetCredit { + RateLimitResetCredit { + id: id.to_string(), + reset_type: RateLimitResetType::CodexRateLimits, + status: RateLimitResetCreditStatus::Available, + granted_at: 0, + expires_at, + title: None, + description: None, + } +} + +fn reset_credit_with_title(id: &str, expires_at: Option, title: &str) -> RateLimitResetCredit { + RateLimitResetCredit { + title: Some(title.to_string()), + ..reset_credit(id, expires_at) + } +} + +fn expiry_timestamp(day: u32, hour: u32, minute: u32) -> i64 { + chrono::Local + .with_ymd_and_hms(2026, 6, day, hour, minute, 0) + .single() + .expect("valid test timestamp") + .timestamp() +} + +#[test] +fn reset_credit_options_use_scope_label_for_unknown_reset_type() { + let mut credit = reset_credit("future-credit", /*expires_at*/ None); + credit.reset_type = RateLimitResetType::Unknown; + + assert_eq!( + reset_credit_options( + &detailed_reset_credits(/*available_count*/ 1, vec![credit]), + RateLimitResetScope::WeeklyAndFiveHour, + ), + vec![ResetCreditOption { + credit_id: Some("future-credit".to_string()), + name: "Full reset (Weekly + 5h)".to_string(), + description: "Does not expire.".to_string(), + }] + ); +} + #[tokio::test] async fn usage_command_opens_menu_when_reset_is_available_snapshot() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -14,10 +84,7 @@ async fn usage_command_opens_menu_when_reset_is_available_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); chat.dispatch_command(SlashCommand::Usage); @@ -38,10 +105,7 @@ async fn usage_command_disables_reset_after_cached_zero_snapshot() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); @@ -69,10 +133,7 @@ async fn usage_menu_refresh_enables_newly_available_reset() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); @@ -85,10 +146,7 @@ async fn usage_menu_refresh_enables_newly_available_reset() { chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None, - }), + Ok(reset_credits(/*available_count*/ 1)), ); chat.handle_key_event(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -104,10 +162,7 @@ async fn usage_menu_refresh_failure_preserves_disabled_known_zero() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); @@ -137,10 +192,7 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); chat.dispatch_command(SlashCommand::Usage); assert_matches!( @@ -157,10 +209,7 @@ async fn account_update_invalidates_usage_menu_refresh_when_visible_state_is_unc chat.finish_usage_menu_rate_limit_refresh( /*request_id*/ 1, vec![snapshot(/*percent*/ 92.0)], - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None, - }), + Ok(reset_credits(/*available_count*/ 2)), ); assert_eq!(chat.available_rate_limit_reset_credits, None); @@ -206,10 +255,7 @@ async fn usage_menu_rate_limit_reset_entry_opens_reset_flow() { assert!(chat.finish_rate_limit_reset_hint_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); chat.dispatch_command(SlashCommand::Usage); @@ -227,13 +273,38 @@ async fn rate_limit_reset_popup_states_snapshot() { let loading_request_id = chat.show_rate_limit_reset_loading_popup(); record_popup(&chat, &mut states); + let first_expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); + let second_expiry = expiry_timestamp(/*day*/ 27, /*hour*/ 8, /*minute*/ 59); + let mut rate_limit_snapshot = snapshot(/*percent*/ 50.0); + rate_limit_snapshot.limit_id = Some("codex".to_string()); + rate_limit_snapshot + .primary + .as_mut() + .expect("primary window") + .window_duration_mins = Some(5 * 60); + rate_limit_snapshot.secondary = Some(RateLimitWindow { + used_percent: 50, + window_duration_mins: Some(7 * 24 * 60), + resets_at: None, + }); assert!(chat.finish_rate_limit_reset_credits_refresh( loading_request_id, - Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + vec![rate_limit_snapshot], + Ok(detailed_reset_credits( + /*available_count*/ 2, + vec![ + reset_credit_with_title( + "credit-2", + Some(second_expiry), + "Full reset (Weekly + 5 hr)", + ), + reset_credit_with_title( + "credit-1", + Some(first_expiry), + "Full reset (Weekly + 5 hr)", + ), + ], + )), )); record_popup(&chat, &mut states); @@ -242,10 +313,7 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_rate_limit_reset_credits_refresh( empty_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 0, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); record_popup(&chat, &mut states); @@ -264,6 +332,7 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(!chat.finish_rate_limit_reset_consume( consuming_request_id, "redeem-1".to_string(), + /*credit_id*/ None, Err("request timed out".to_string()), )); record_popup(&chat, &mut states); @@ -300,16 +369,36 @@ async fn rate_limit_reset_popup_states_snapshot() { assert!(chat.finish_post_consume_reset_credits_refresh( success_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); record_popup(&chat, &mut states); assert_chatwidget_snapshot!("rate_limit_reset_popup_states", states.join("\n---\n")); } +#[tokio::test] +async fn rate_limit_reset_picker_wraps_expiry_details_snapshot() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + let request_id = chat.show_rate_limit_reset_loading_popup(); + let expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); + assert!(chat.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Ok(detailed_reset_credits( + /*available_count*/ 2, + vec![ + reset_credit("credit-2", /*expires_at*/ None), + reset_credit("credit-1", Some(expiry)), + ], + )), + )); + + assert_chatwidget_snapshot!( + "rate_limit_reset_picker_narrow", + render_bottom_popup(&chat, /*width*/ 44) + ); +} + #[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; @@ -321,10 +410,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( free_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(detailed_reset_credits( + /*available_count*/ 1, + vec![reset_credit("free-credit", /*expires_at*/ None)], + )), )); states.push(format!( "Free:\n{}", @@ -337,10 +426,7 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( go_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); states.push(format!("Go:\n{}", render_bottom_popup(&chat, /*width*/ 80))); @@ -356,10 +442,10 @@ async fn usage_limit_reset_confirmation_uses_monthly_copy_for_monthly_limits_sna assert!(chat.finish_rate_limit_reset_credits_refresh( business_request_id, vec![monthly_business_snapshot], - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(detailed_reset_credits( + /*available_count*/ 1, + vec![reset_credit("business-credit", /*expires_at*/ None)], + )), )); states.push(format!( "Business with monthly window:\n{}", @@ -379,10 +465,7 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); @@ -391,6 +474,49 @@ async fn rate_limit_reset_confirmation_selects_cancel_by_default() { assert!(rx.try_recv().is_err()); } +#[tokio::test] +async fn rate_limit_reset_picker_starts_with_soonest_expiries_and_keeps_all_rows_reachable() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.plan_type = Some(PlanType::Business); + let request_id = chat.show_rate_limit_reset_loading_popup(); + let first_expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39); + let credits = (0..9) + .rev() + .map(|index| { + reset_credit( + &format!("credit-{index}"), + Some(first_expiry + i64::from(index) * 86_400), + ) + }) + .collect(); + assert!(chat.finish_rate_limit_reset_credits_refresh( + request_id, + Vec::new(), + Ok(detailed_reset_credits(/*available_count*/ 9, credits)), + )); + + let rendered = render_bottom_popup(&chat, /*width*/ 80); + assert!( + rendered.contains("Expires 09:39 on 18 Jun 2026."), + "{rendered}" + ); + assert!(!rendered.contains("Full reset ("), "{rendered}"); + + for _ in 0..9 { + 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::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) if Uuid::parse_str(&idempotency_key).is_ok() + && credit_id.as_deref() == Some("credit-8") + ); +} + #[tokio::test] async fn rate_limit_reset_confirmation_can_use_reset() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; @@ -398,19 +524,18 @@ async fn rate_limit_reset_confirmation_can_use_reset() { assert!(chat.finish_rate_limit_reset_credits_refresh( request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); - chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + 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::ConsumeRateLimitResetCredit { idempotency_key }) - if Uuid::parse_str(&idempotency_key).is_ok() + Ok(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) if Uuid::parse_str(&idempotency_key).is_ok() && credit_id.is_none() ); } @@ -421,6 +546,7 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { assert!(!chat.finish_rate_limit_reset_consume( request_id, "stable-redeem-id".to_string(), + Some("credit-1".to_string()), Err("response lost".to_string()), )); @@ -428,8 +554,11 @@ async fn rate_limit_reset_retry_reuses_idempotency_key() { assert_matches!( rx.try_recv(), - Ok(AppEvent::ConsumeRateLimitResetCredit { idempotency_key }) - if idempotency_key == "stable-redeem-id" + Ok(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key, + credit_id, + }) if idempotency_key == "stable-redeem-id" + && credit_id.as_deref() == Some("credit-1") ); } @@ -441,10 +570,7 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 1, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(!finish_reset_consume_outcome( @@ -466,6 +592,23 @@ async fn no_credit_outcome_disables_reset_entry_in_usage_menu() { chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!(rx.try_recv(), Ok(AppEvent::OpenTokenActivity)); + + chat.available_rate_limit_reset_credits = Some(2); + let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); + assert!(!chat.finish_rate_limit_reset_consume( + consume_request_id, + "redeem-selected".to_string(), + Some("stale-credit".to_string()), + Ok(consume_response( + ConsumeAccountRateLimitResetCreditOutcome::NoCredit + )), + )); + assert_eq!(chat.available_rate_limit_reset_credits, None); + assert!( + render_bottom_popup(&chat, /*width*/ 80).contains("That reset is no longer available.") + ); + chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_matches!(rx.try_recv(), Ok(AppEvent::OpenRateLimitResetCredits)); } #[tokio::test] @@ -489,10 +632,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); dismiss_popup(&mut chat); assert!(chat.bottom_pane.no_modal_or_popup_active()); @@ -523,10 +663,7 @@ 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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); assert!( render_bottom_popup(&chat, /*width*/ 80) @@ -542,10 +679,7 @@ async fn failed_post_consume_refresh_does_not_keep_stale_reset_count() { assert!(chat.finish_rate_limit_reset_hint_refresh( startup_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); let consume_request_id = chat.show_rate_limit_reset_consuming_popup(); assert!(finish_reset_consume_outcome( @@ -582,10 +716,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.bottom_pane.no_modal_or_popup_active()); } @@ -599,10 +730,7 @@ async fn clearing_pending_reset_hint_preserves_in_flight_redemption() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); chat.clear_pending_rate_limit_reset_hint(); @@ -625,10 +753,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -636,10 +761,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 usage limit resets available.") - ); + assert!(render_bottom_popup(&chat, /*width*/ 80).contains("2 usage limit resets available.")); } #[tokio::test] @@ -657,10 +779,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 1)), )); assert_eq!( chat.bottom_pane.active_view_id(), @@ -703,10 +822,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); let rendered = lines_to_single_string( &chat @@ -729,10 +845,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.usage_history_insertion_blocked()); @@ -763,10 +876,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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -779,10 +889,7 @@ async fn starting_rate_limit_reset_redemption_clears_deferred_startup_hint() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); @@ -800,10 +907,7 @@ 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, - credits: None - }), + Ok(reset_credits(/*available_count*/ 0)), )); assert!(chat.pending_rate_limit_reset_hint().is_none()); } @@ -818,10 +922,7 @@ async fn startup_check_shows_reset_hint_for_workspace_account_with_credit() { assert!(chat.finish_rate_limit_reset_hint_refresh( hint_request_id, Vec::new(), - Ok(RateLimitResetCreditsSummary { - available_count: 2, - credits: None - }), + Ok(reset_credits(/*available_count*/ 2)), )); assert!(chat.pending_rate_limit_reset_hint().is_some()); assert_eq!(chat.available_rate_limit_reset_credits, Some(2)); @@ -842,6 +943,7 @@ fn finish_reset_consume_outcome( chat.finish_rate_limit_reset_consume( request_id, idempotency_key.to_string(), + /*credit_id*/ None, Ok(consume_response(outcome)), ) } diff --git a/codex-rs/tui/src/chatwidget/usage.rs b/codex-rs/tui/src/chatwidget/usage.rs index 53925e86d9..731c0a994a 100644 --- a/codex-rs/tui/src/chatwidget/usage.rs +++ b/codex-rs/tui/src/chatwidget/usage.rs @@ -3,7 +3,8 @@ use codex_app_server_protocol::ConsumeAccountRateLimitResetCreditResponse; use codex_app_server_protocol::RateLimitResetCreditsSummary; use uuid::Uuid; -use super::rate_limits::get_limits_duration; +use super::reset_credits::rate_limit_reset_scope; +use super::reset_credits::reset_credit_options; use super::*; const USAGE_MENU_VIEW_ID: &str = "usage-menu"; @@ -131,18 +132,20 @@ impl ChatWidget { 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) + let available_count = response.available_count; + let params = if available_count > 0 { + self.rate_limit_reset_confirmation_params(&response) } else { Self::rate_limit_reset_message_params( "You don't have any usage limit resets available.", ) - } + }; + self.available_rate_limit_reset_credits = Some(available_count); + params + } + Err(_) => { + Self::reset_refresh_params("Couldn't load usage limit resets. Please try again.") } - Err(_) => Self::rate_limit_reset_message_params( - "Couldn't load usage limit resets. Please try again.", - ), }; let replaced = self .bottom_pane @@ -153,56 +156,47 @@ impl ChatWidget { replaced } - 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." - }; + fn rate_limit_reset_confirmation_params( + &self, + reset_credits: &RateLimitResetCreditsSummary, + ) -> SelectionViewParams { + let scope = rate_limit_reset_scope(&self.rate_limit_snapshots_by_limit_id, self.plan_type); + let mut items = vec![SelectionItem { + name: "Cancel".to_string(), + dismiss_on_select: true, + ..Default::default() + }]; + items.extend( + reset_credit_options(reset_credits, scope) + .into_iter() + .map(|option| { + let idempotency_key = Uuid::new_v4().to_string(); + let credit_id = option.credit_id; + SelectionItem { + name: option.name, + description: Some(option.description), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ConsumeRateLimitResetCredit { + idempotency_key: idempotency_key.clone(), + credit_id: credit_id.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }), + ); SelectionViewParams { view_id: Some(RATE_LIMIT_RESET_VIEW_ID), title: Some("Usage limit resets".to_string()), subtitle: Some(format!( - "You have {available_count} {} available.", - reset_label(available_count) + "{} {} available.", + reset_credits.available_count, + reset_label(reset_credits.available_count) )), footer_hint: Some(standard_popup_hint_line()), - items: vec![ - SelectionItem { - name: "Use a reset".to_string(), - description: Some(reset_description.to_string()), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::ConsumeRateLimitResetCredit { - idempotency_key: idempotency_key.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Cancel".to_string(), - dismiss_on_select: true, - ..Default::default() - }, - ], - initial_selected_idx: Some(1), + items, + initial_selected_idx: Some(0), ..Default::default() } } @@ -221,6 +215,30 @@ impl ChatWidget { } } + fn reset_refresh_params(message: &str) -> SelectionViewParams { + SelectionViewParams { + view_id: Some(RATE_LIMIT_RESET_VIEW_ID), + title: Some("Usage limit resets".to_string()), + subtitle: Some(message.to_string()), + items: vec![ + SelectionItem { + name: "Try again".to_string(), + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenRateLimitResetCredits); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Close".to_string(), + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + } + } + pub(crate) fn show_rate_limit_reset_consuming_popup(&mut self) -> u64 { self.clear_pending_rate_limit_reset_hint(); let request_id = self.take_next_rate_limit_reset_request_id(); @@ -245,6 +263,7 @@ impl ChatWidget { &mut self, request_id: u64, idempotency_key: String, + credit_id: Option, result: Result, ) -> bool { if self.pending_rate_limit_reset_request_id != Some(request_id) { @@ -269,6 +288,13 @@ impl ChatWidget { ConsumeAccountRateLimitResetCreditOutcome::NothingToReset => { "Your usage does not need a reset right now." } + ConsumeAccountRateLimitResetCreditOutcome::NoCredit if credit_id.is_some() => { + self.available_rate_limit_reset_credits = None; + self.replace_rate_limit_reset_popup(Self::reset_refresh_params( + "That reset is no longer available. Refresh to see your current resets.", + )); + return false; + } ConsumeAccountRateLimitResetCreditOutcome::NoCredit => { self.available_rate_limit_reset_credits = Some(0); "No usage limit resets are available." @@ -291,6 +317,7 @@ impl ChatWidget { actions: vec![Box::new(move |tx| { tx.send(AppEvent::ConsumeRateLimitResetCredit { idempotency_key: idempotency_key.clone(), + credit_id: credit_id.clone(), }); })], dismiss_on_select: true, @@ -325,11 +352,11 @@ impl ChatWidget { let message = match result { Ok(response) => { - self.available_rate_limit_reset_credits = Some(response.available_count); + let available_count = response.available_count; + self.available_rate_limit_reset_credits = Some(available_count); format!( - "Usage reset. You have {} {} left.", - response.available_count, - reset_label(response.available_count) + "Usage reset. You have {available_count} {} left.", + reset_label(available_count) ) } Err(_) => "Usage reset.".to_string(), @@ -386,8 +413,9 @@ impl ChatWidget { return false; } 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); + let available_count = response.available_count; + self.available_rate_limit_reset_credits = Some(available_count); + self.set_rate_limit_reset_available_hint(available_count); } true } From cca16a10878202cb2f6e9666b6b4330329ea7e65 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Mon, 6 Jul 2026 21:22:51 -0700 Subject: [PATCH 38/76] feat(core): emit canonical command execution items (#31297) This PR depends on [#31296](https://github.com/openai/codex/pull/31296) for the canonical-to-legacy event mappings. ## Description This PR makes command execution emit canonical `TurnItem::CommandExecution` lifecycle from both the shell tool path and user `/shell` commands. App-server v2 consumes the canonical command items directly and ignores the mapped `ExecCommandBegin` / `ExecCommandEnd` compatibility events, so clients still receive one command item lifecycle. `UnifiedExecInteraction` stays on the legacy path because `TerminalInteraction` is still the v2 surface for stdin and poll events. Emitting a command item there would render the same wait twice. ## Why This is the first live producer migration after the compatibility mappings in #31296. Keeping command execution separate makes the unified exec exception reviewable without mixing in dynamic tools or multi-agent behavior. ## What changed - Emit canonical command execution items from shell tool events and user shell commands. - Preserve the existing unified exec interaction carveout. - Move app-server command deduplication and completion bookkeeping onto canonical item events. - Update unified exec coverage to assert the completed command item. --- .../app-server/src/bespoke_event_handling.rs | 105 +++++++++--------- codex-rs/core/src/tasks/user_shell.rs | 100 ++++++++--------- codex-rs/core/src/tools/events.rs | 84 +++++++++++--- .../core/src/unified_exec/process_manager.rs | 5 +- .../src/unified_exec/process_manager_tests.rs | 23 ++-- 5 files changed, 181 insertions(+), 136 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index c63103161f..4bd1d6e108 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -90,6 +90,7 @@ use codex_core::ThreadManager; use codex_core::review_format::format_review_findings_block; use codex_core::review_prompts; use codex_protocol::ThreadId; +use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::parse_hook_prompt_message; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::plan_tool::UpdatePlanArgs; @@ -1027,10 +1028,37 @@ pub(crate) async fn apply_bespoke_event_handling( .send_server_notification(ServerNotification::ItemCompleted(completed)) .await; } - msg @ (EventMsg::ItemStarted(_) - | EventMsg::ItemCompleted(_) - | EventMsg::PatchApplyUpdated(_) - | EventMsg::TerminalInteraction(_)) => { + EventMsg::ItemStarted(event) => { + let should_emit = match &event.item { + // Approval and guardian flows can emit the command start notification before core + // emits the canonical item. Reuse the same set to suppress that duplicate. + CoreTurnItem::CommandExecution(item) => thread_state + .lock() + .await + .turn_summary + .command_execution_started + .insert(item.id.clone()), + _ => true, + }; + if should_emit { + let notification = item_event_to_server_notification( + EventMsg::ItemStarted(event), + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + } + EventMsg::ItemCompleted(event) => { + apply_canonical_item_completed_side_effects(&thread_state, &event.item).await; + let notification = item_event_to_server_notification( + EventMsg::ItemCompleted(event), + &conversation_id.to_string(), + &event_turn_id, + ); + outgoing.send_server_notification(notification).await; + } + msg @ (EventMsg::PatchApplyUpdated(_) | EventMsg::TerminalInteraction(_)) => { let notification = item_event_to_server_notification( msg, &conversation_id.to_string(), @@ -1106,32 +1134,10 @@ pub(crate) async fn apply_bespoke_event_handling( // Core still fans out these deprecated events for legacy clients; // v2 clients receive the canonical FileChange item instead. } - EventMsg::ExecCommandBegin(exec_command_begin_event) => { - if matches!( - exec_command_begin_event.source, - codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction - ) { - // TerminalInteraction is the v2 surface for unified exec - // stdin/poll events. Suppress the legacy CommandExecution - // item so clients do not render the same wait twice. - return; - } - let item_id = exec_command_begin_event.call_id.clone(); - let first_start = { - let mut state = thread_state.lock().await; - state - .turn_summary - .command_execution_started - .insert(item_id.clone()) - }; - if first_start { - let notification = item_event_to_server_notification( - EventMsg::ExecCommandBegin(exec_command_begin_event), - &conversation_id.to_string(), - &event_turn_id, - ); - outgoing.send_server_notification(notification).await; - } + EventMsg::ExecCommandBegin(_) | EventMsg::ExecCommandEnd(_) => { + // Deprecated command-execution events are still fanned out for raw-event and rollout + // compatibility consumers. App-server v2 receives the canonical CommandExecution + // item lifecycle instead. } EventMsg::ExecCommandOutputDelta(exec_command_output_delta_event) => { let notification = item_event_to_server_notification( @@ -1141,31 +1147,6 @@ pub(crate) async fn apply_bespoke_event_handling( ); outgoing.send_server_notification(notification).await; } - EventMsg::ExecCommandEnd(exec_command_end_event) => { - let call_id = exec_command_end_event.call_id.clone(); - { - let mut state = thread_state.lock().await; - state - .turn_summary - .command_execution_started - .remove(&call_id); - } - if matches!( - exec_command_end_event.source, - codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction - ) { - // The paired begin event is suppressed above; keep the - // completion out of v2 as well so no orphan legacy item is - // emitted for unified exec interactions. - return; - } - let notification = item_event_to_server_notification( - EventMsg::ExecCommandEnd(exec_command_end_event), - &conversation_id.to_string(), - &event_turn_id, - ); - outgoing.send_server_notification(notification).await; - } // If this is a TurnAborted, reply to any pending interrupt requests. EventMsg::TurnAborted(turn_aborted_event) => { // All per-thread requests are bound to a turn, so abort them. @@ -1369,6 +1350,20 @@ async fn emit_turn_completed_with_status( .await; } +async fn apply_canonical_item_completed_side_effects( + thread_state: &Arc>, + item: &CoreTurnItem, +) { + if let CoreTurnItem::CommandExecution(item) = item { + thread_state + .lock() + .await + .turn_summary + .command_execution_started + .remove(&item.id); + } +} + #[allow(clippy::too_many_arguments)] async fn start_command_execution_item( conversation_id: &ThreadId, diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index d588e5a356..a15c53e16b 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -25,16 +25,15 @@ use crate::tools::runtimes::RuntimePathPrepends; use crate::tools::runtimes::apply_package_path_prepend; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; use crate::tools::runtimes::strip_managed_proxy_env; -use crate::turn_timing::now_unix_timestamp_ms; use crate::user_shell_command::user_shell_command_record_item; use codex_protocol::exec_output::ExecToolCallOutput; use codex_protocol::exec_output::StreamOutput; +use codex_protocol::items::CommandExecutionItem; +use codex_protocol::items::CommandExecutionStatus; +use codex_protocol::items::TurnItem; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::EventMsg; -use codex_protocol::protocol::ExecCommandBeginEvent; -use codex_protocol::protocol::ExecCommandEndEvent; use codex_protocol::protocol::ExecCommandSource; -use codex_protocol::protocol::ExecCommandStatus; use codex_protocol::protocol::TurnStartedEvent; use codex_sandboxing::SandboxType; use codex_shell_command::parse_command::parse_command; @@ -181,18 +180,23 @@ pub(crate) async fn execute_user_shell_command( let parsed_cmd = parse_command(&display_command); session - .send_event( + .emit_turn_item_started( turn_context.as_ref(), - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: call_id.clone(), + &TurnItem::CommandExecution(CommandExecutionItem { + id: call_id.clone(), process_id: None, - turn_id: turn_context.sub_id.clone(), - started_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, + status: CommandExecutionStatus::InProgress, + stdout: None, + stderr: None, + aggregated_output: None, + exit_code: None, + duration: None, + formatted_output: None, }), ) .await; @@ -259,57 +263,53 @@ pub(crate) async fn execute_user_shell_command( ) .await; session - .send_event( + .emit_turn_item_completed( turn_context.as_ref(), - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, + TurnItem::CommandExecution(CommandExecutionItem { + id: call_id, process_id: None, - turn_id: turn_context.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, - stdout: String::new(), - stderr: aborted_message.clone(), - aggregated_output: aborted_message.clone(), - exit_code: -1, - duration: Duration::ZERO, - formatted_output: aborted_message, - status: ExecCommandStatus::Failed, + status: CommandExecutionStatus::Failed, + stdout: Some(String::new()), + stderr: Some(aborted_message.clone()), + aggregated_output: Some(aborted_message.clone()), + exit_code: Some(-1), + duration: Some(Duration::ZERO), + formatted_output: Some(aborted_message), }), ) .await; } Ok(Ok(output)) => { session - .send_event( + .emit_turn_item_completed( turn_context.as_ref(), - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: call_id.clone(), + TurnItem::CommandExecution(CommandExecutionItem { + id: call_id.clone(), process_id: None, - turn_id: turn_context.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: display_command.clone(), cwd: cwd.clone().into(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, - stdout: output.stdout.text.clone(), - stderr: output.stderr.text.clone(), - aggregated_output: output.aggregated_output.text.clone(), - exit_code: output.exit_code, - duration: output.duration, - formatted_output: format_exec_output_str( + status: if output.exit_code == 0 { + CommandExecutionStatus::Completed + } else { + CommandExecutionStatus::Failed + }, + stdout: Some(output.stdout.text.clone()), + stderr: Some(output.stderr.text.clone()), + aggregated_output: Some(output.aggregated_output.text.clone()), + exit_code: Some(output.exit_code), + duration: Some(output.duration), + formatted_output: Some(format_exec_output_str( &output, turn_context.model_info.truncation_policy.into(), - ), - status: if output.exit_code == 0 { - ExecCommandStatus::Completed - } else { - ExecCommandStatus::Failed - }, + )), }), ) .await; @@ -329,28 +329,26 @@ pub(crate) async fn execute_user_shell_command( timed_out: false, }; session - .send_event( + .emit_turn_item_completed( turn_context.as_ref(), - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id, + TurnItem::CommandExecution(CommandExecutionItem { + id: call_id, process_id: None, - turn_id: turn_context.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: display_command, cwd: cwd.into(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, - stdout: exec_output.stdout.text.clone(), - stderr: exec_output.stderr.text.clone(), - aggregated_output: exec_output.aggregated_output.text.clone(), - exit_code: exec_output.exit_code, - duration: exec_output.duration, - formatted_output: format_exec_output_str( + status: CommandExecutionStatus::Failed, + stdout: Some(exec_output.stdout.text.clone()), + stderr: Some(exec_output.stderr.text.clone()), + aggregated_output: Some(exec_output.aggregated_output.text.clone()), + exit_code: Some(exec_output.exit_code), + duration: Some(exec_output.duration), + formatted_output: Some(format_exec_output_str( &exec_output, turn_context.model_info.truncation_policy.into(), - ), - status: ExecCommandStatus::Failed, + )), }), ) .await; diff --git a/codex-rs/core/src/tools/events.rs b/codex-rs/core/src/tools/events.rs index 757125a03d..5b30c6a156 100644 --- a/codex-rs/core/src/tools/events.rs +++ b/codex-rs/core/src/tools/events.rs @@ -8,6 +8,8 @@ use codex_apply_patch::AppliedPatchDelta; use codex_protocol::error::CodexErr; use codex_protocol::error::SandboxErr; use codex_protocol::exec_output::ExecToolCallOutput; +use codex_protocol::items::CommandExecutionItem; +use codex_protocol::items::CommandExecutionStatus; use codex_protocol::items::FileChangeItem; use codex_protocol::items::TurnItem; use codex_protocol::parse_command::ParsedCommand; @@ -102,19 +104,43 @@ pub(crate) async fn emit_exec_command_begin( interaction_input: Option, process_id: Option<&str>, ) { + if matches!(source, ExecCommandSource::UnifiedExecInteraction) { + ctx.session + .send_event( + ctx.turn, + EventMsg::ExecCommandBegin(ExecCommandBeginEvent { + call_id: ctx.call_id.to_string(), + process_id: process_id.map(str::to_owned), + turn_id: ctx.turn.sub_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + command: command.to_vec(), + cwd: cwd.clone(), + parsed_cmd: parsed_cmd.to_vec(), + source, + interaction_input, + }), + ) + .await; + return; + } ctx.session - .send_event( + .emit_turn_item_started( ctx.turn, - EventMsg::ExecCommandBegin(ExecCommandBeginEvent { - call_id: ctx.call_id.to_string(), + &TurnItem::CommandExecution(CommandExecutionItem { + id: ctx.call_id.to_string(), process_id: process_id.map(str::to_owned), - turn_id: ctx.turn.sub_id.clone(), - started_at_ms: now_unix_timestamp_ms(), command: command.to_vec(), cwd: cwd.clone(), parsed_cmd: parsed_cmd.to_vec(), source, interaction_input, + status: CommandExecutionStatus::InProgress, + stdout: None, + stderr: None, + aggregated_output: None, + exit_code: None, + duration: None, + formatted_output: None, }), ) .await; @@ -542,26 +568,50 @@ async fn emit_exec_end( exec_input: ExecCommandInput<'_>, exec_result: ExecCommandResult, ) { + if matches!(exec_input.source, ExecCommandSource::UnifiedExecInteraction) { + ctx.session + .send_event( + ctx.turn, + EventMsg::ExecCommandEnd(ExecCommandEndEvent { + call_id: ctx.call_id.to_string(), + process_id: exec_input.process_id.map(str::to_owned), + turn_id: ctx.turn.sub_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + command: exec_input.command.to_vec(), + cwd: exec_input.cwd.clone(), + parsed_cmd: exec_input.parsed_cmd.to_vec(), + source: exec_input.source, + interaction_input: exec_input.interaction_input.map(str::to_owned), + stdout: exec_result.stdout, + stderr: exec_result.stderr, + aggregated_output: exec_result.aggregated_output, + exit_code: exec_result.exit_code, + duration: exec_result.duration, + formatted_output: exec_result.formatted_output, + status: exec_result.status, + }), + ) + .await; + return; + } ctx.session - .send_event( + .emit_turn_item_completed( ctx.turn, - EventMsg::ExecCommandEnd(ExecCommandEndEvent { - call_id: ctx.call_id.to_string(), + TurnItem::CommandExecution(CommandExecutionItem { + id: ctx.call_id.to_string(), process_id: exec_input.process_id.map(str::to_owned), - turn_id: ctx.turn.sub_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), command: exec_input.command.to_vec(), cwd: exec_input.cwd.clone(), parsed_cmd: exec_input.parsed_cmd.to_vec(), source: exec_input.source, interaction_input: exec_input.interaction_input.map(str::to_owned), - stdout: exec_result.stdout, - stderr: exec_result.stderr, - aggregated_output: exec_result.aggregated_output, - exit_code: exec_result.exit_code, - duration: exec_result.duration, - formatted_output: exec_result.formatted_output, - status: exec_result.status, + status: exec_result.status.into(), + stdout: Some(exec_result.stdout), + stderr: Some(exec_result.stderr), + aggregated_output: Some(exec_result.aggregated_output), + exit_code: Some(exec_result.exit_code), + duration: Some(exec_result.duration), + formatted_output: Some(exec_result.formatted_output), }), ) .await; diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a895708f79..fe3ec45759 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -573,9 +573,8 @@ impl UnifiedExecProcessManager { } } } else { - // Short‑lived command: emit ExecCommandEnd immediately using the - // same helper as the background watcher, so all end events share - // one implementation. + // Short-lived command: emit the completed command item immediately + // using the same helper as the background watcher. let finish_result = finish_deferred_network_approval_after_process_exit_for_session( Some(&context.session), deferred_network_approval.take(), 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 096cbe0a13..20a930b552 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -315,21 +315,24 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { let event = tokio::time::timeout(Duration::from_secs(1), rx_event.recv()) .await - .expect("timed out waiting for failed exec end event") + .expect("timed out waiting for failed command execution item") .expect("event channel closed"); - let codex_protocol::protocol::EventMsg::ExecCommandEnd(end_event) = event.msg else { - panic!("expected ExecCommandEnd event"); + let codex_protocol::protocol::EventMsg::ItemCompleted(completed_event) = event.msg else { + panic!("expected ItemCompleted event"); }; - assert_eq!(end_event.call_id, "call-unified-denied"); + let codex_protocol::items::TurnItem::CommandExecution(item) = completed_event.item else { + panic!("expected CommandExecution item"); + }; + assert_eq!(item.id, "call-unified-denied"); assert_eq!( - end_event.status, - codex_protocol::protocol::ExecCommandStatus::Failed + item.status, + codex_protocol::items::CommandExecutionStatus::Failed ); - assert_eq!(end_event.exit_code, -1); - assert_eq!(end_event.process_id.as_deref(), Some("123")); + assert_eq!(item.exit_code, Some(-1)); + assert_eq!(item.process_id.as_deref(), Some("123")); assert_eq!( - end_event.aggregated_output, - "PRE_DENIAL_MARKER\nNetwork access denied" + item.aggregated_output.as_deref(), + Some("PRE_DENIAL_MARKER\nNetwork access denied") ); } From f659eb12bc8cecb976d92db192d9b2983c8053ff Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Tue, 7 Jul 2026 08:32:14 -0700 Subject: [PATCH 39/76] feat(core): emit canonical dynamic tool call items (#31298) This PR depends on [#31296](https://github.com/openai/codex/pull/31296) for the canonical-to-legacy event mappings. ## Description This PR makes dynamic tools emit canonical `TurnItem::DynamicToolCall` lifecycle instead of `DynamicToolCallRequest` / `DynamicToolCallResponse` directly. App-server v2 now sends the client `DynamicToolCall` request from the canonical item start. It ignores the mapped legacy request/response events, so clients receive one item start and one tool request. ## Why Dynamic tools are a separate migration boundary because their start event also drives a client request. Keeping that routing change isolated makes it easier to verify that the request still happens exactly once. ## What changed - Emit in-progress and completed/failed dynamic tool items from the dynamic tool handler. - Move app-server client request dispatch onto canonical dynamic item starts. - Add focused app-server coverage for the canonical start notification and client request. --- .../app-server/src/bespoke_event_handling.rs | 158 +++++++++++++----- codex-rs/core/src/tools/handlers/dynamic.rs | 74 ++++---- 2 files changed, 157 insertions(+), 75 deletions(-) diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 4bd1d6e108..863aeac79b 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -22,7 +22,6 @@ use codex_app_server_protocol::CommandExecutionSource; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::DeprecationNoticeNotification; use codex_app_server_protocol::DynamicToolCallParams; -use codex_app_server_protocol::DynamicToolCallStatus; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ExecPolicyAmendment as V2ExecPolicyAmendment; use codex_app_server_protocol::FileChangeApprovalDecision; @@ -823,52 +822,16 @@ pub(crate) async fn apply_bespoke_event_handling( on_request_permissions_response(pending_response, conversation, thread_state).await; }); } - EventMsg::DynamicToolCallRequest(request) => { - let call_id = request.call_id; - let turn_id = request.turn_id; - let namespace = request.namespace; - let tool = request.tool; - let arguments = request.arguments; - let item = ThreadItem::DynamicToolCall { - id: call_id.clone(), - namespace: namespace.clone(), - tool: tool.clone(), - arguments: arguments.clone(), - status: DynamicToolCallStatus::InProgress, - content_items: None, - success: None, - duration_ms: None, - }; - let notification = ItemStartedNotification { - thread_id: conversation_id.to_string(), - turn_id: turn_id.clone(), - started_at_ms: request.started_at_ms, - item, - }; - outgoing - .send_server_notification(ServerNotification::ItemStarted(notification)) - .await; - let params = DynamicToolCallParams { - thread_id: conversation_id.to_string(), - turn_id: turn_id.clone(), - call_id: call_id.clone(), - namespace, - tool: tool.clone(), - arguments: arguments.clone(), - }; - let (_pending_request_id, rx) = outgoing - .send_request(ServerRequestPayload::DynamicToolCall(params)) - .await; - tokio::spawn(async move { - crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; - }); + EventMsg::DynamicToolCallRequest(_) | EventMsg::DynamicToolCallResponse(_) => { + // Deprecated dynamic-tool events are still fanned out for raw-event and rollout + // compatibility consumers. App-server v2 receives the canonical DynamicToolCall + // item lifecycle and dispatches client requests from canonical starts instead. } EventMsg::McpToolCallBegin(_) | EventMsg::McpToolCallEnd(_) => { // Deprecated MCP tool-call events are still fanned out for legacy clients. // App-server v2 receives the canonical TurnItem::McpToolCall lifecycle instead. } - msg @ (EventMsg::DynamicToolCallResponse(_) - | EventMsg::CollabAgentSpawnBegin(_) + msg @ (EventMsg::CollabAgentSpawnBegin(_) | EventMsg::CollabAgentSpawnEnd(_) | EventMsg::CollabAgentInteractionBegin(_) | EventMsg::CollabAgentInteractionEnd(_) @@ -1040,6 +1003,17 @@ pub(crate) async fn apply_bespoke_event_handling( .insert(item.id.clone()), _ => true, }; + let dynamic_tool_call_params = match &event.item { + CoreTurnItem::DynamicToolCall(item) => Some(DynamicToolCallParams { + thread_id: conversation_id.to_string(), + turn_id: event.turn_id.clone(), + call_id: item.id.clone(), + namespace: item.namespace.clone(), + tool: item.tool.clone(), + arguments: item.arguments.clone(), + }), + _ => None, + }; if should_emit { let notification = item_event_to_server_notification( EventMsg::ItemStarted(event), @@ -1048,6 +1022,15 @@ pub(crate) async fn apply_bespoke_event_handling( ); outgoing.send_server_notification(notification).await; } + if let Some(params) = dynamic_tool_call_params { + let call_id = params.call_id.clone(); + let (_pending_request_id, rx) = outgoing + .send_request(ServerRequestPayload::DynamicToolCall(params)) + .await; + tokio::spawn(async move { + crate::dynamic_tools::on_call_response(call_id, rx, conversation).await; + }); + } } EventMsg::ItemCompleted(event) => { apply_canonical_item_completed_side_effects(&thread_state, &event.item).await; @@ -2185,10 +2168,14 @@ mod tests { use codex_app_server_protocol::AutoReviewDecisionSource; use codex_app_server_protocol::GuardianApprovalReviewStatus; use codex_app_server_protocol::JSONRPCErrorError; + use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::TurnPlanStepStatus; use codex_login::CodexAuth; use codex_protocol::AgentPath; + use codex_protocol::items::DynamicToolCallItem; + use codex_protocol::items::DynamicToolCallStatus as CoreDynamicToolCallStatus; use codex_protocol::items::HookPromptFragment; + use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::build_hook_prompt_message; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::NetworkPermissions as CoreNetworkPermissions; @@ -2205,6 +2192,7 @@ mod tests { use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentStatus; + use codex_protocol::protocol::ItemStartedEvent; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow; use codex_protocol::protocol::RolloutItem; @@ -3482,6 +3470,92 @@ mod tests { Ok(()) } + #[tokio::test] + async fn canonical_dynamic_tool_start_emits_item_and_requests_client() -> Result<()> { + let codex_home = TempDir::new()?; + let config = load_default_config_for_test(&codex_home).await; + let thread_manager = Arc::new( + codex_core::test_support::thread_manager_with_models_provider_and_home( + CodexAuth::create_dummy_chatgpt_auth_for_testing(), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ), + ); + let codex_core::NewThread { + thread_id: conversation_id, + thread: conversation, + .. + } = thread_manager.start_thread(config).await?; + let (tx, mut rx) = mpsc::channel(CHANNEL_CAPACITY); + let outgoing = Arc::new(OutgoingMessageSender::new( + tx, + codex_analytics::AnalyticsEventsClient::disabled(), + )); + let outgoing = ThreadScopedOutgoingMessageSender::new( + outgoing, + vec![ConnectionId(1)], + conversation_id, + ); + + apply_bespoke_event_handling( + Event { + id: "turn-1".to_string(), + msg: EventMsg::ItemStarted(ItemStartedEvent { + thread_id: conversation_id, + turn_id: "turn-1".to_string(), + item: CoreTurnItem::DynamicToolCall(DynamicToolCallItem { + id: "dynamic-1".to_string(), + namespace: Some("apps".to_string()), + tool: "lookup".to_string(), + arguments: json!({"id": "123"}), + status: CoreDynamicToolCallStatus::InProgress, + content_items: None, + success: None, + error: None, + duration: None, + }), + started_at_ms: 42, + }), + }, + conversation_id, + conversation, + thread_manager, + outgoing, + new_thread_state(), + ThreadWatchManager::new(), + Arc::new(tokio::sync::Semaphore::new(/*permits*/ 1)), + "test-provider".to_string(), + ) + .await; + + let item_started = recv_broadcast_message(&mut rx).await?; + let OutgoingMessage::AppServerNotification(ServerNotification::ItemStarted(payload)) = + item_started + else { + bail!("unexpected message: {item_started:?}"); + }; + assert_eq!(payload.item.id(), "dynamic-1"); + + let request = recv_broadcast_message(&mut rx).await?; + let OutgoingMessage::Request(ServerRequest::DynamicToolCall { params, .. }) = request + else { + bail!("unexpected message: {request:?}"); + }; + assert_eq!( + params, + DynamicToolCallParams { + thread_id: conversation_id.to_string(), + turn_id: "turn-1".to_string(), + call_id: "dynamic-1".to_string(), + namespace: Some("apps".to_string()), + tool: "lookup".to_string(), + arguments: json!({"id": "123"}), + } + ); + Ok(()) + } + #[tokio::test] async fn test_handle_turn_complete_emits_completed_without_error() -> Result<()> { let conversation_id = ThreadId::new(); diff --git a/codex-rs/core/src/tools/handlers/dynamic.rs b/codex-rs/core/src/tools/handlers/dynamic.rs index 28af3434ff..4b8fa03797 100644 --- a/codex-rs/core/src/tools/handlers/dynamic.rs +++ b/codex-rs/core/src/tools/handlers/dynamic.rs @@ -9,14 +9,13 @@ use crate::tools::handlers::parse_arguments; use crate::tools::registry::CoreToolRuntime; use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolExposure; -use crate::turn_timing::now_unix_timestamp_ms; -use codex_protocol::dynamic_tools::DynamicToolCallRequest; use codex_protocol::dynamic_tools::DynamicToolFunctionSpec; use codex_protocol::dynamic_tools::DynamicToolNamespaceSpec; use codex_protocol::dynamic_tools::DynamicToolResponse; +use codex_protocol::items::DynamicToolCallItem; +use codex_protocol::items::DynamicToolCallStatus; +use codex_protocol::items::TurnItem; use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::protocol::DynamicToolCallResponseEvent; -use codex_protocol::protocol::EventMsg; use codex_tools::ResponsesApiNamespace; use codex_tools::ResponsesApiNamespaceTool; use codex_tools::ToolName; @@ -178,7 +177,6 @@ async fn request_dynamic_tool( ) -> Option { let namespace = tool_name.namespace; let tool = tool_name.name; - let turn_id = turn_context.sub_id.clone(); let (tx_response, rx_response) = oneshot::channel(); let event_id = call_id.clone(); let prev_entry = { @@ -196,45 +194,55 @@ async fn request_dynamic_tool( } let started_at = Instant::now(); - let started_at_ms = now_unix_timestamp_ms(); - let event = EventMsg::DynamicToolCallRequest(DynamicToolCallRequest { - call_id: call_id.clone(), - turn_id: turn_id.clone(), - started_at_ms, - namespace: namespace.clone(), - tool: tool.clone(), - arguments: arguments.clone(), - }); - session.send_event(turn_context, event).await; + session + .emit_turn_item_started( + turn_context, + &TurnItem::DynamicToolCall(DynamicToolCallItem { + id: call_id.clone(), + namespace: namespace.clone(), + tool: tool.clone(), + arguments: arguments.clone(), + status: DynamicToolCallStatus::InProgress, + content_items: None, + success: None, + error: None, + duration: None, + }), + ) + .await; let response = rx_response.await.ok(); - let response_event = match &response { - Some(response) => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { - call_id, - turn_id, - completed_at_ms: now_unix_timestamp_ms(), + let item = match &response { + Some(response) => DynamicToolCallItem { + id: call_id, namespace, tool, arguments, - content_items: response.content_items.clone(), - success: response.success, + status: if response.success { + DynamicToolCallStatus::Completed + } else { + DynamicToolCallStatus::Failed + }, + content_items: Some(response.content_items.clone()), + success: Some(response.success), error: None, - duration: started_at.elapsed(), - }), - None => EventMsg::DynamicToolCallResponse(DynamicToolCallResponseEvent { - call_id, - turn_id, - completed_at_ms: now_unix_timestamp_ms(), + duration: Some(started_at.elapsed()), + }, + None => DynamicToolCallItem { + id: call_id, namespace, tool, arguments, - content_items: Vec::new(), - success: false, + status: DynamicToolCallStatus::Failed, + content_items: Some(Vec::new()), + success: Some(false), error: Some("dynamic tool call was cancelled before receiving a response".to_string()), - duration: started_at.elapsed(), - }), + duration: Some(started_at.elapsed()), + }, }; - session.send_event(turn_context, response_event).await; + session + .emit_turn_item_completed(turn_context, TurnItem::DynamicToolCall(item)) + .await; response } From 42156ba007278d9068f1518ac1f627b56c136ef6 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 09:28:52 -0700 Subject: [PATCH 40/76] test(skills): cover plugin namespace loading (#31369) ## Why Before changing plugin namespace loading performance, lock down the existing behavior so the same cases can be validated before and after the optimization. ## What - cover mixed plain and nested plugin skills under one scan root - cover inherited, nested, and invalid-manifest namespace precedence - cover symlinked plugin skill directories, symlinked plain directories, and scan-root ancestor symlinks ## Validation - just test -p codex-core-skills namespace on unmodified main: 12 passed --- codex-rs/core-skills/src/loader_tests.rs | 275 +++++++++++++++++++++++ 1 file changed, 275 insertions(+) diff --git a/codex-rs/core-skills/src/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs index 8cd371c9a7..1bdfe39520 100644 --- a/codex-rs/core-skills/src/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -388,6 +388,41 @@ fn write_skill_interface_at(skill_dir: &Path, contents: &str) -> PathBuf { write_skill_metadata_at(skill_dir, contents) } +fn write_plugin_manifest(plugin_root: &Path, contents: &str) { + let manifest_path = plugin_root.join(".codex-plugin/plugin.json"); + fs::create_dir_all(manifest_path.parent().expect("manifest parent")).unwrap(); + fs::write(manifest_path, contents).unwrap(); +} + +async fn load_user_skills_root(root: &Path) -> SkillLoadOutcome { + load_skills_from_roots( + [SkillRoot { + path: root.abs(), + scope: SkillScope::User, + file_system: Arc::clone(&LOCAL_FS), + plugin_id: None, + plugin_namespace: None, + plugin_root: None, + }], + /*plugin_skill_snapshots*/ None, + ) + .await +} + +fn expected_user_skill(path: &Path, name: &str, description: &str) -> SkillMetadata { + SkillMetadata { + name: name.to_string(), + description: description.to_string(), + short_description: None, + interface: None, + dependencies: None, + policy: None, + path_to_skills_md: normalized(path), + scope: SkillScope::User, + plugin_id: None, + } +} + #[tokio::test] async fn loads_skill_dependencies_metadata_from_yaml() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -1326,6 +1361,246 @@ async fn namespaces_plugin_skills_using_provided_namespace() { ); } +#[tokio::test] +async fn namespaces_nested_plugin_skills_without_namespacing_plain_siblings() { + let root = tempfile::tempdir().expect("tempdir"); + let skills_root = root.path().join("skills"); + let plain_skill_path = + write_skill_at(&skills_root, "plain", "plain-skill", "plain description"); + let plugin_root = skills_root.join("nested-plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"nested"}"#); + let plugin_skill_path = write_skill_at( + &plugin_root.join("skills"), + "search", + "plugin-skill", + "plugin description", + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![ + expected_user_skill( + &plugin_skill_path, + "nested:plugin-skill", + "plugin description" + ), + expected_user_skill(&plain_skill_path, "plain-skill", "plain description"), + ] + ); +} + +#[tokio::test] +async fn inherits_plugin_namespace_from_above_scanned_skills_root() { + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"outer"}"#); + let skills_root = plugin_root.join("skills"); + let skill_path = write_skill_at(&skills_root, "search", "search-skill", "search description"); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "outer:search-skill", + "search description", + )] + ); +} + +#[tokio::test] +async fn nearest_valid_nested_plugin_namespace_overrides_outer_namespace() { + let root = tempfile::tempdir().expect("tempdir"); + let outer_plugin_root = root.path().join("outer-plugin"); + write_plugin_manifest(&outer_plugin_root, r#"{"name":"outer"}"#); + let skills_root = outer_plugin_root.join("skills"); + let nested_plugin_root = skills_root.join("nested-plugin"); + write_plugin_manifest(&nested_plugin_root, r#"{"name":"nested"}"#); + let skill_path = write_skill_at( + &nested_plugin_root.join("skills"), + "search", + "search-skill", + "search description", + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "nested:search-skill", + "search description", + )] + ); +} + +#[tokio::test] +async fn invalid_nested_plugin_manifest_falls_back_to_outer_namespace() { + let root = tempfile::tempdir().expect("tempdir"); + let outer_plugin_root = root.path().join("outer-plugin"); + write_plugin_manifest(&outer_plugin_root, r#"{"name":"outer"}"#); + let skills_root = outer_plugin_root.join("skills"); + let nested_plugin_root = skills_root.join("nested-plugin"); + write_plugin_manifest(&nested_plugin_root, "not json"); + let skill_path = write_skill_at( + &nested_plugin_root.join("skills"), + "search", + "search-skill", + "search description", + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "outer:search-skill", + "search description", + )] + ); +} + +// Directory symlinks on Windows can require Developer Mode or administrator privileges. +#[cfg(unix)] +#[tokio::test] +async fn namespaces_skills_in_symlinked_plugin_skills_dir() { + // skills/ + // └── linked-plugin -> shared-plugin/skills/ + // shared-plugin/ + // ├── .codex-plugin/plugin.json + // └── skills/search/SKILL.md + let root = tempfile::tempdir().expect("tempdir"); + let shared_plugin_root = tempfile::tempdir().expect("tempdir"); + write_plugin_manifest(shared_plugin_root.path(), r#"{"name":"linked"}"#); + let skill_path = write_skill_at( + &shared_plugin_root.path().join("skills"), + "search", + "search-skill", + "search description", + ); + let skills_root = root.path().join("skills"); + fs::create_dir_all(&skills_root).unwrap(); + symlink_dir( + &shared_plugin_root.path().join("skills"), + &skills_root.join("linked-plugin"), + ); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "linked:search-skill", + "search description", + )] + ); +} + +// Directory symlinks on Windows can require Developer Mode or administrator privileges. +#[cfg(unix)] +#[tokio::test] +async fn does_not_inherit_namespace_for_skills_in_symlinked_plain_dir() { + // outer-plugin/ + // ├── .codex-plugin/plugin.json + // └── skills/linked-plain -> plain-root/ + // plain-root/ + // └── search/SKILL.md + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("outer-plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"outer"}"#); + let skills_root = plugin_root.join("skills"); + let plain_root = tempfile::tempdir().expect("tempdir"); + let skill_path = write_skill_at( + plain_root.path(), + "search", + "plain-skill", + "plain description", + ); + fs::create_dir_all(&skills_root).unwrap(); + symlink_dir(plain_root.path(), &skills_root.join("linked-plain")); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "plain-skill", + "plain description", + )] + ); +} + +// Directory symlinks on Windows can require Developer Mode or administrator privileges. +#[cfg(unix)] +#[tokio::test] +async fn keeps_inherited_namespace_when_symlink_target_is_scan_root_ancestor() { + // temp-root/ + // └── a/b/c/d/e/f/outer-plugin/ + // ├── .codex-plugin/plugin.json + // └── skills/ + // ├── root/SKILL.md + // └── link -> temp-root/ + let root = tempfile::tempdir().expect("tempdir"); + let plugin_root = root.path().join("a/b/c/d/e/f/outer-plugin"); + write_plugin_manifest(&plugin_root, r#"{"name":"outer"}"#); + let skills_root = plugin_root.join("skills"); + let skill_path = write_skill_at(&skills_root, "root", "root-skill", "root description"); + symlink_dir(root.path(), &skills_root.join("link")); + + let outcome = load_user_skills_root(&skills_root).await; + + assert!( + outcome.errors.is_empty(), + "unexpected errors: {:?}", + outcome.errors + ); + assert_eq!( + outcome.skills, + vec![expected_user_skill( + &skill_path, + "outer:root-skill", + "root description", + )] + ); +} + #[tokio::test] async fn plugin_skill_name_length_limit_allows_max_qualified_name() { let root = tempfile::tempdir().expect("tempdir"); From f363ed70cc1c6e2cae15fc1b8711f7e0d7b96cf1 Mon Sep 17 00:00:00 2001 From: malsamiri-oai Date: Tue, 7 Jul 2026 09:47:21 -0700 Subject: [PATCH 41/76] fix(release): add missing Intel V8 signing entitlement (#30953) ## Why Intel macOS release binaries crash on the first Code Mode tool call while V8 creates its code range. The x86_64 V8 allocator later makes a non-`MAP_JIT` reservation executable, which Hardened Runtime rejects when the signature contains only `com.apple.security.cs.allow-jit`. Tracks [SE-8006](https://linear.app/openai/issue/SE-8006/intel-macos-codex-cli-crashes-in-v8-startup-on-gpt-56-sol-tool-calls). Fixes #28390. ## What - add an expanded entitlement profile only for x86_64 `codex` and `codex-app-server`, the release binaries that link V8 - keep arm64 and `codex-responses-api-proxy` on the existing narrower profile - share one fail-closed target/binary selector between signing and final verification - verify the expected Mach-O architecture and exact entitlement dictionary for the signed binary, tar.gz, zstd, package, and DMG copies ## Verification - `just test-github-scripts` (34 tests) - `UV_CACHE_DIR=/private/tmp/codex-uv-cache just fmt-check` - `bash -n .github/scripts/macos-signing/select_codex_entitlements.sh` - `plutil -lint` on both entitlement profiles - parsed `rust-release.yml` as YAML - `git diff --check` - ad-hoc Hardened Runtime signing smoke on an x86_64 Mach-O slice: strict `codesign` verification passed; the Codex profile contained exactly both keys and the proxy profile retained exactly `allow-jit` ## Release validation Run a native Intel smoke of the final Developer ID-signed x86_64 Codex binary through V8 isolate creation before shipping. PR #30849 is diagnostic scaffolding, but its non-sandbox release job currently fails in the harness before V8 starts, so it is not counted as coverage here. --- .../codex-app-server.entitlements.plist | 10 +++++ .../codex-code-mode-host.entitlements.plist | 10 +++++ ...dex-responses-api-proxy.entitlements.plist | 8 ++++ .../macos-signing/codex.entitlements.plist | 2 + .../test_macos_signing_entitlements.py | 37 ++++++++++++++++++ .github/workflows/rust-release.yml | 38 ++++++++++++++----- 6 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 .github/scripts/macos-signing/codex-app-server.entitlements.plist create mode 100644 .github/scripts/macos-signing/codex-code-mode-host.entitlements.plist create mode 100644 .github/scripts/macos-signing/codex-responses-api-proxy.entitlements.plist create mode 100644 .github/scripts/test_macos_signing_entitlements.py diff --git a/.github/scripts/macos-signing/codex-app-server.entitlements.plist b/.github/scripts/macos-signing/codex-app-server.entitlements.plist new file mode 100644 index 0000000000..26b12f2886 --- /dev/null +++ b/.github/scripts/macos-signing/codex-app-server.entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/.github/scripts/macos-signing/codex-code-mode-host.entitlements.plist b/.github/scripts/macos-signing/codex-code-mode-host.entitlements.plist new file mode 100644 index 0000000000..26b12f2886 --- /dev/null +++ b/.github/scripts/macos-signing/codex-code-mode-host.entitlements.plist @@ -0,0 +1,10 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + + diff --git a/.github/scripts/macos-signing/codex-responses-api-proxy.entitlements.plist b/.github/scripts/macos-signing/codex-responses-api-proxy.entitlements.plist new file mode 100644 index 0000000000..d35e43ae58 --- /dev/null +++ b/.github/scripts/macos-signing/codex-responses-api-proxy.entitlements.plist @@ -0,0 +1,8 @@ + + + + + com.apple.security.cs.allow-jit + + + diff --git a/.github/scripts/macos-signing/codex.entitlements.plist b/.github/scripts/macos-signing/codex.entitlements.plist index d35e43ae58..26b12f2886 100644 --- a/.github/scripts/macos-signing/codex.entitlements.plist +++ b/.github/scripts/macos-signing/codex.entitlements.plist @@ -4,5 +4,7 @@ com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + diff --git a/.github/scripts/test_macos_signing_entitlements.py b/.github/scripts/test_macos_signing_entitlements.py new file mode 100644 index 0000000000..e56d5f3a1b --- /dev/null +++ b/.github/scripts/test_macos_signing_entitlements.py @@ -0,0 +1,37 @@ +import plistlib +import unittest +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +SIGNING_DIR = ROOT / ".github" / "scripts" / "macos-signing" +ALLOW_JIT = "com.apple.security.cs.allow-jit" +ALLOW_UNSIGNED_EXECUTABLE_MEMORY = ( + "com.apple.security.cs.allow-unsigned-executable-memory" +) + + +class MacosSigningEntitlementsTest(unittest.TestCase): + def load(self, binary: str) -> dict[str, bool]: + path = SIGNING_DIR / f"{binary}.entitlements.plist" + with path.open("rb") as file: + return plistlib.load(file) + + def test_v8_binaries_allow_unsigned_executable_memory(self) -> None: + expected = { + ALLOW_JIT: True, + ALLOW_UNSIGNED_EXECUTABLE_MEMORY: True, + } + for binary in ["codex", "codex-app-server", "codex-code-mode-host"]: + with self.subTest(binary=binary): + self.assertEqual(self.load(binary), expected) + + def test_responses_proxy_keeps_existing_entitlements(self) -> None: + self.assertEqual( + self.load("codex-responses-api-proxy"), + {ALLOW_JIT: True}, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 93f03e63d1..9fdd66637b 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -573,6 +573,12 @@ jobs: zstd -d --stdout "$unsigned_path" >"$signed_path" chmod 0755 "$signed_path" + entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/${binary}.entitlements.plist" + if [[ ! -f "$entitlements" ]]; then + echo "Entitlements file $entitlements not found" + exit 1 + fi + .github/scripts/macos-signing/sign_macos_code.sh \ --target "$signed_path" \ --identity unused \ @@ -580,7 +586,7 @@ jobs: --identifier "$binary" \ --options runtime \ --timestamp true \ - --entitlements .github/scripts/macos-signing/codex.entitlements.plist + --entitlements "$entitlements" mkdir -p "${report_dir}/${binary}" rcodesign print-signature-info "$signed_path" \ @@ -974,15 +980,29 @@ jobs: target="${{ matrix.target }}" packaged_dir="dist/${target}" - expected_entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/codex.entitlements.plist" + case "$target" in + aarch64-apple-darwin) expected_arch="arm64" ;; + x86_64-apple-darwin) expected_arch="x86_64" ;; + *) + echo "Unexpected macOS target: $target" + exit 1 + ;; + esac verify_signed_binary() { local path="$1" - local actual_entitlements normalized_actual normalized_expected + local binary="$2" + local actual_entitlements expected_entitlements normalized_actual normalized_expected chmod 0755 "$path" + lipo "$path" -verify_arch "$expected_arch" codesign --verify --strict --verbose=2 "$path" + expected_entitlements="${GITHUB_WORKSPACE}/.github/scripts/macos-signing/${binary}.entitlements.plist" + if [[ ! -f "$expected_entitlements" ]]; then + echo "Expected entitlements file $expected_entitlements not found" + exit 1 + fi actual_entitlements="$(mktemp)" normalized_actual="$(mktemp)" normalized_expected="$(mktemp)" @@ -995,7 +1015,7 @@ jobs: for binary in ${{ matrix.binaries }}; do binary_path="${RUNNER_TEMP}/signed-binaries/${binary}" - verify_signed_binary "$binary_path" + verify_signed_binary "$binary_path" "$binary" # The app-server package contains the host, but its standalone archives # are omitted above to avoid duplicate release asset names. @@ -1007,11 +1027,11 @@ jobs: rm -rf "$direct_archive_dir" mkdir -p "$direct_archive_dir" tar -xzf "${packaged_dir}/${binary}-${target}.tar.gz" -C "$direct_archive_dir" - verify_signed_binary "${direct_archive_dir}/${binary}-${target}" + verify_signed_binary "${direct_archive_dir}/${binary}-${target}" "$binary" direct_zstd_path="${RUNNER_TEMP}/${binary}-${target}-from-zstd" zstd -d --stdout "${packaged_dir}/${binary}-${target}.zst" >"$direct_zstd_path" - verify_signed_binary "$direct_zstd_path" + verify_signed_binary "$direct_zstd_path" "$binary" done case "${{ matrix.bundle }}" in @@ -1033,8 +1053,8 @@ jobs: rm -rf "$package_dir" mkdir -p "$package_dir" tar -xzf "${packaged_dir}/${package_stem}-${target}.tar.gz" -C "$package_dir" - verify_signed_binary "${package_dir}/bin/${package_entrypoint}" - verify_signed_binary "${package_dir}/bin/codex-code-mode-host" + verify_signed_binary "${package_dir}/bin/${package_entrypoint}" "$package_entrypoint" + verify_signed_binary "${package_dir}/bin/codex-code-mode-host" "codex-code-mode-host" if [[ "${{ matrix.verify_dmg }}" != "true" ]]; then exit 0 @@ -1060,7 +1080,7 @@ jobs: trap cleanup_mount EXIT for binary in ${{ matrix.binaries }}; do - verify_signed_binary "${mount_dir}/${binary}" + verify_signed_binary "${mount_dir}/${binary}" "$binary" done cleanup_mount From a3f8b0b33284054133474e7b1cc5fa7600221d97 Mon Sep 17 00:00:00 2001 From: lt-oai Date: Tue, 7 Jul 2026 09:47:41 -0700 Subject: [PATCH 42/76] refactor: make ExternalAuth return CodexAuth (#31355) ## Summary - refactor `ExternalAuth` to return `CodexAuth` directly - remove the parallel `ExternalAuthTokens` wrapper - preserve existing external bearer and app-server refresh behavior This is a mechanical precursor refactor; it does not add auth capabilities or change recovery behavior. ## Testing - `cargo fmt --all` - `cargo test -q -p codex-login --lib` - `cargo test -q -p codex-app-server --test all external_auth_refreshes_on_unauthorized` --- codex-rs/app-server/src/message_processor.rs | 19 ++- codex-rs/login/src/auth/auth_tests.rs | 13 -- codex-rs/login/src/auth/external_bearer.rs | 24 ++-- codex-rs/login/src/auth/manager.rs | 134 ++++++++----------- codex-rs/login/src/lib.rs | 2 - codex-rs/models-manager/src/manager_tests.rs | 19 +-- 6 files changed, 76 insertions(+), 135 deletions(-) diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 6ae73784ff..29ace7d99f 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -74,10 +74,10 @@ use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; +use codex_login::CodexAuth; use codex_login::auth::ExternalAuth; use codex_login::auth::ExternalAuthRefreshContext; use codex_login::auth::ExternalAuthRefreshReason; -use codex_login::auth::ExternalAuthTokens; use codex_protocol::ThreadId; use codex_protocol::auth::AuthMode as LoginAuthMode; use codex_protocol::protocol::SessionSource; @@ -121,10 +121,7 @@ impl ExternalAuthRefreshBridge { } } - async fn refresh( - &self, - context: ExternalAuthRefreshContext, - ) -> std::io::Result { + async fn refresh(&self, context: ExternalAuthRefreshContext) -> std::io::Result { let params = ChatgptAuthTokensRefreshParams { reason: Self::map_reason(context.reason), previous_account_id: context.previous_account_id, @@ -162,11 +159,11 @@ impl ExternalAuthRefreshBridge { let response: ChatgptAuthTokensRefreshResponse = serde_json::from_value(result).map_err(std::io::Error::other)?; - Ok(ExternalAuthTokens::chatgpt( - response.access_token, - response.chatgpt_account_id, - response.chatgpt_plan_type, - )) + CodexAuth::from_external_chatgpt_tokens( + response.access_token.as_str(), + response.chatgpt_account_id.as_str(), + response.chatgpt_plan_type.as_deref(), + ) } } @@ -178,7 +175,7 @@ impl ExternalAuth for ExternalAuthRefreshBridge { fn refresh( &self, context: ExternalAuthRefreshContext, - ) -> codex_login::ExternalAuthFuture<'_, ExternalAuthTokens> { + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { Box::pin(ExternalAuthRefreshBridge::refresh(self, context)) } } diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 9509cab456..77d8c4fdfb 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -1015,19 +1015,6 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { assert_eq!(manager.refresh_failure_for_auth(&updated_auth), None); } -#[test] -fn external_auth_tokens_without_chatgpt_metadata_cannot_seed_chatgpt_auth() { - let err = AuthDotJson::from_external_tokens(&ExternalAuthTokens::access_token_only( - "test-access-token", - )) - .expect_err("bearer-only external auth should not seed ChatGPT auth"); - - assert_eq!( - err.to_string(), - "external auth tokens are missing ChatGPT metadata" - ); -} - #[tokio::test] async fn external_bearer_only_auth_manager_uses_cached_provider_token() { let script = ProviderAuthScript::new(&["provider-token", "next-token"]).unwrap(); diff --git a/codex-rs/login/src/auth/external_bearer.rs b/codex-rs/login/src/auth/external_bearer.rs index f30d52f434..1dbd2c3eee 100644 --- a/codex-rs/login/src/auth/external_bearer.rs +++ b/codex-rs/login/src/auth/external_bearer.rs @@ -1,7 +1,7 @@ +use super::manager::CodexAuth; use super::manager::ExternalAuth; use super::manager::ExternalAuthFuture; use super::manager::ExternalAuthRefreshContext; -use super::manager::ExternalAuthTokens; use codex_protocol::auth::AuthMode; use codex_protocol::config_types::ModelProviderAuthInfo; use std::fmt; @@ -30,7 +30,7 @@ impl BearerTokenRefresher { clippy::await_holding_invalid_type, reason = "external bearer cache misses intentionally hold cached_token across the provider command to avoid duplicate refreshes" )] - async fn resolve(&self) -> io::Result> { + async fn resolve(&self) -> io::Result> { let access_token = { let mut cached = self.state.cached_token.lock().await; if let Some(cached_token) = cached.as_ref() { @@ -39,8 +39,8 @@ impl BearerTokenRefresher { None => true, }; if should_use_cached_token { - return Ok(Some(ExternalAuthTokens::access_token_only( - cached_token.access_token.clone(), + return Ok(Some(CodexAuth::from_api_key( + cached_token.access_token.as_str(), ))); } } @@ -52,20 +52,17 @@ impl BearerTokenRefresher { }); access_token }; - Ok(Some(ExternalAuthTokens::access_token_only(access_token))) + Ok(Some(CodexAuth::from_api_key(access_token.as_str()))) } - async fn refresh( - &self, - _context: ExternalAuthRefreshContext, - ) -> io::Result { + async fn refresh(&self, _context: ExternalAuthRefreshContext) -> io::Result { let access_token = run_provider_auth_command(&self.state.config).await?; let mut cached = self.state.cached_token.lock().await; *cached = Some(CachedExternalBearerToken { access_token: access_token.clone(), fetched_at: Instant::now(), }); - Ok(ExternalAuthTokens::access_token_only(access_token)) + Ok(CodexAuth::from_api_key(access_token.as_str())) } } @@ -74,14 +71,11 @@ impl ExternalAuth for BearerTokenRefresher { AuthMode::ApiKey } - fn resolve(&self) -> ExternalAuthFuture<'_, Option> { + fn resolve(&self) -> ExternalAuthFuture<'_, Option> { Box::pin(BearerTokenRefresher::resolve(self)) } - fn refresh( - &self, - context: ExternalAuthRefreshContext, - ) -> ExternalAuthFuture<'_, ExternalAuthTokens> { + fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth> { Box::pin(BearerTokenRefresher::refresh(self, context)) } } diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a2d005dfa6..06d125422c 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -198,45 +198,6 @@ pub enum RefreshTokenError { Transient(#[from] std::io::Error), } -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExternalAuthTokens { - pub access_token: String, - pub chatgpt_metadata: Option, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExternalAuthChatgptMetadata { - pub account_id: String, - pub plan_type: Option, -} - -impl ExternalAuthTokens { - pub fn access_token_only(access_token: impl Into) -> Self { - Self { - access_token: access_token.into(), - chatgpt_metadata: None, - } - } - - pub fn chatgpt( - access_token: impl Into, - chatgpt_account_id: impl Into, - chatgpt_plan_type: Option, - ) -> Self { - Self { - access_token: access_token.into(), - chatgpt_metadata: Some(ExternalAuthChatgptMetadata { - account_id: chatgpt_account_id.into(), - plan_type: chatgpt_plan_type, - }), - } - } - - pub fn chatgpt_metadata(&self) -> Option<&ExternalAuthChatgptMetadata> { - self.chatgpt_metadata.as_ref() - } -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ExternalAuthRefreshReason { Unauthorized, @@ -251,22 +212,19 @@ pub struct ExternalAuthRefreshContext { /// Pluggable auth provider used by `AuthManager` for externally managed auth flows. /// /// Implementations may either resolve auth eagerly via `resolve()` or provide refreshed -/// credentials on demand via `refresh()`. +/// auth on demand via `refresh()`. pub trait ExternalAuth: Send + Sync { /// Indicates which top-level auth mode this external provider supplies. fn auth_mode(&self) -> AuthMode; /// Returns cached or immediately available auth, if this provider can resolve it synchronously /// from the caller's perspective. - fn resolve(&self) -> ExternalAuthFuture<'_, Option> { + fn resolve(&self) -> ExternalAuthFuture<'_, Option> { Box::pin(async { Ok(None) }) } /// Refreshes auth in response to a manager-driven refresh attempt. - fn refresh( - &self, - context: ExternalAuthRefreshContext, - ) -> ExternalAuthFuture<'_, ExternalAuthTokens>; + fn refresh(&self, context: ExternalAuthRefreshContext) -> ExternalAuthFuture<'_, CodexAuth>; } pub type ExternalAuthFuture<'a, T> = Pin> + Send + 'a>>; @@ -747,6 +705,24 @@ impl CodexAuth { Self::Chatgpt(ChatgptAuth { state, storage }) } + /// Constructs in-memory ChatGPT auth from externally managed tokens. + pub fn from_external_chatgpt_tokens( + access_token: &str, + chatgpt_account_id: &str, + chatgpt_plan_type: Option<&str>, + ) -> std::io::Result { + let auth_dot_json = AuthDotJson::from_external_access_token( + access_token, + chatgpt_account_id, + chatgpt_plan_type, + )?; + let state = ChatgptAuthState { + auth_dot_json: Arc::new(Mutex::new(Some(auth_dot_json))), + client: create_client(), + }; + Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })) + } + pub fn from_api_key(api_key: &str) -> Self { Self::ApiKey(ApiKeyAuth { api_key: api_key.to_owned(), @@ -1456,26 +1432,23 @@ fn refresh_token_endpoint() -> String { } impl AuthDotJson { - fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result { - let Some(chatgpt_metadata) = external.chatgpt_metadata() else { - return Err(std::io::Error::other( - "external auth tokens are missing ChatGPT metadata", - )); - }; + fn from_external_access_token( + access_token: &str, + chatgpt_account_id: &str, + chatgpt_plan_type: Option<&str>, + ) -> std::io::Result { let mut token_info = - parse_chatgpt_jwt_claims(&external.access_token).map_err(std::io::Error::other)?; - token_info.chatgpt_account_id = Some(chatgpt_metadata.account_id.clone()); - token_info.chatgpt_plan_type = chatgpt_metadata - .plan_type - .as_deref() + parse_chatgpt_jwt_claims(access_token).map_err(std::io::Error::other)?; + token_info.chatgpt_account_id = Some(chatgpt_account_id.to_string()); + token_info.chatgpt_plan_type = chatgpt_plan_type .map(InternalPlanType::from_raw_value) .or(token_info.chatgpt_plan_type) .or(Some(InternalPlanType::Unknown("unknown".to_string()))); let tokens = TokenData { id_token: token_info, - access_token: external.access_token.clone(), + access_token: access_token.to_string(), refresh_token: String::new(), - account_id: Some(chatgpt_metadata.account_id.clone()), + account_id: Some(chatgpt_account_id.to_string()), }; Ok(Self { @@ -1489,19 +1462,6 @@ impl AuthDotJson { }) } - fn from_external_access_token( - access_token: &str, - chatgpt_account_id: &str, - chatgpt_plan_type: Option<&str>, - ) -> std::io::Result { - let external = ExternalAuthTokens::chatgpt( - access_token, - chatgpt_account_id, - chatgpt_plan_type.map(str::to_string), - ); - Self::from_external_tokens(&external) - } - pub(super) fn resolved_mode(&self) -> AuthMode { if let Some(mode) = self.auth_mode { return mode; @@ -2361,7 +2321,14 @@ impl AuthManager { let external_auth = self.external_auth()?; match external_auth.resolve().await { - Ok(Some(tokens)) => Some(CodexAuth::from_api_key(&tokens.access_token)), + Ok(Some(auth)) if auth.api_auth_mode() == AuthMode::ApiKey => Some(auth), + Ok(Some(auth)) => { + tracing::error!( + resolved_mode = ?auth.api_auth_mode(), + "ignoring non-API-key auth from external API key provider" + ); + None + } Ok(None) => None, Err(err) => { tracing::error!("Failed to resolve external API key auth: {err}"); @@ -2567,23 +2534,30 @@ impl AuthManager { if external_auth.auth_mode() == AuthMode::ApiKey { return Ok(()); } - let Some(chatgpt_metadata) = refreshed.chatgpt_metadata() else { + if refreshed.api_auth_mode() != AuthMode::ChatgptAuthTokens { return Err(RefreshTokenError::Transient(std::io::Error::other( - "external auth refresh did not return ChatGPT metadata", + "external auth refresh did not return ChatGPT auth tokens", ))); - }; + } + let account_id = refreshed.get_account_id().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "external ChatGPT auth tokens are missing an account id", + )) + })?; if let Some(expected_workspace_ids) = forced_chatgpt_workspace_id.as_deref() - && !expected_workspace_ids.contains(&chatgpt_metadata.account_id) + && !expected_workspace_ids.contains(&account_id) { return Err(RefreshTokenError::Transient(std::io::Error::other( format!( - "external auth refresh returned workspace {:?}, expected one of {:?}", - chatgpt_metadata.account_id, expected_workspace_ids, + "external auth refresh returned workspace {account_id:?}, expected one of {expected_workspace_ids:?}" ), ))); } - let auth_dot_json = - AuthDotJson::from_external_tokens(&refreshed).map_err(RefreshTokenError::Transient)?; + let auth_dot_json = refreshed.get_current_auth_json().ok_or_else(|| { + RefreshTokenError::Transient(std::io::Error::other( + "external ChatGPT auth tokens are missing auth state", + )) + })?; save_auth( &self.codex_home, &auth_dot_json, diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index 7ea415fcc5..8a08ddeadb 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -30,11 +30,9 @@ pub use auth::CODEX_ACCESS_TOKEN_ENV_VAR; pub use auth::CODEX_API_KEY_ENV_VAR; pub use auth::CodexAuth; pub use auth::ExternalAuth; -pub use auth::ExternalAuthChatgptMetadata; pub use auth::ExternalAuthFuture; pub use auth::ExternalAuthRefreshContext; pub use auth::ExternalAuthRefreshReason; -pub use auth::ExternalAuthTokens; pub use auth::OPENAI_API_KEY_ENV_VAR; pub use auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; pub use auth::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR; diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index e7d1691a3a..2e97dfac43 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -7,7 +7,6 @@ use codex_login::AuthManager; use codex_login::CodexAuth; use codex_login::ExternalAuth; use codex_login::ExternalAuthRefreshContext; -use codex_login::ExternalAuthTokens; use codex_login::TokenData; use codex_protocol::auth::AuthMode; use codex_protocol::openai_models::ModelsResponse; @@ -122,23 +121,15 @@ impl ExternalAuth for TestExternalApiKeyAuth { AuthMode::ApiKey } - fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, Option> { - Box::pin(async { - Ok(Some(ExternalAuthTokens::access_token_only( - "test-external-api-key", - ))) - }) + fn resolve(&self) -> codex_login::ExternalAuthFuture<'_, Option> { + Box::pin(async { Ok(Some(CodexAuth::from_api_key("test-external-api-key"))) }) } fn refresh( &self, _context: ExternalAuthRefreshContext, - ) -> codex_login::ExternalAuthFuture<'_, ExternalAuthTokens> { - Box::pin(async { - Ok(ExternalAuthTokens::access_token_only( - "test-external-api-key", - )) - }) + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { + Box::pin(async { Ok(CodexAuth::from_api_key("test-external-api-key")) }) } } @@ -153,7 +144,7 @@ impl ExternalAuth for TestUnresolvedExternalApiKeyAuth { fn refresh( &self, _context: ExternalAuthRefreshContext, - ) -> codex_login::ExternalAuthFuture<'_, ExternalAuthTokens> { + ) -> codex_login::ExternalAuthFuture<'_, CodexAuth> { Box::pin(async { Err(std::io::Error::other("unresolved test auth")) }) } } From 8841f506c07ccdb773ca012f2271b852c43fbf52 Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 7 Jul 2026 09:55:27 -0700 Subject: [PATCH 43/76] ci: increase Windows Bazel local test jobs (#31352) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Windows Bazel test shards currently cap local test actions at 4. Controlled forced-test-execution measurements in #31339 found that 8 local jobs reduced Bazel elapsed time on every shard by 19–29%, including the slow shard from 835.5s to 609.3s, while 12 jobs regressed. ## What Set `common:ci-windows-cross --local_test_jobs=8` in `.bazelrc`. ## Manual validation - `git diff --check` - `just fmt` - [8-job CI experiment](https://github.com/openai/codex/actions/runs/28838387129) --- .bazelrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.bazelrc b/.bazelrc index de01a87443..93fcb0c164 100644 --- a/.bazelrc +++ b/.bazelrc @@ -190,7 +190,7 @@ common:ci-windows-cross --strategy=TestRunner=local # V8 embeds IsolateData offsets in snapshot builtins; Windows snapshots must be # generated by a Windows mksnapshot binary rather than the Linux RBE host tool. common:ci-windows-cross --strategy=V8Mksnapshot=local -common:ci-windows-cross --local_test_jobs=4 +common:ci-windows-cross --local_test_jobs=8 common:ci-windows-cross --test_env=RUST_TEST_THREADS=1 # Native Windows CI still covers the PowerShell parser-process tests. The # cross-built gnullvm binaries currently hang in those tests when run on the From f6e251c3ac6573a4d6eccddea6ad12587868485f Mon Sep 17 00:00:00 2001 From: Alex Zamoshchin Date: Tue, 7 Jul 2026 13:13:59 -0400 Subject: [PATCH 44/76] [codex-rs] Add writes app approval mode (#30482) ## Summary - Adds `writes` to `AppToolApproval` and exposes it through config and app-server schemas, including `[apps._default].default_tools_approval_mode`. - In `writes`, tools with `readOnlyHint = true` skip approval; all other tools prompt, including non-destructive writes and tools without annotations. - Prevents session or persistent approval choices in this mode so later writes still prompt. ## Why `auto` only prompts for risk-hinted actions, while `prompt` also interrupts reads. Apps need a middle mode that gates writes without prompting for declared read-only actions. ## Validation - `just write-config-schema` - `just write-app-server-schema` - `just fmt` - `just test -p codex-core mcp_turn_metadata` (4 passed) - `just test -p codex-core writes_mode` (2 passed) - `just test -p codex-app-server config_read_includes_apps` (1 passed) - `just test -p codex-app-server-protocol` (251 passed) - `just test -p codex-config` (200 passed) - `just test -p codex-cli` (300 passed) - `just fix -p codex-core -p codex-config -p codex-app-server-protocol -p codex-app-server -p codex-cli` --- .../codex_app_server_protocol.schemas.json | 1 + .../codex_app_server_protocol.v2.schemas.json | 1 + .../schema/json/v2/ConfigReadResponse.json | 1 + .../schema/typescript/v2/AppToolApproval.ts | 2 +- .../src/protocol/v2/config.rs | 1 + codex-rs/app-server/README.md | 4 +- .../app-server/tests/suite/v2/config_rpc.rs | 4 +- codex-rs/cli/src/mcp_cmd.rs | 1 + codex-rs/config/src/mcp_edit.rs | 2 + codex-rs/config/src/mcp_types.rs | 1 + codex-rs/core/config.schema.json | 1 + .../core/src/config/edit/document_helpers.rs | 2 + codex-rs/core/src/mcp_tool_call.rs | 30 +++-- codex-rs/core/src/mcp_tool_call_tests.rs | 46 +++++-- .../core/tests/suite/mcp_turn_metadata.rs | 124 ++++++++++++++++++ 15 files changed, 198 insertions(+), 23 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 2ffff2f96f..53bd7cef4e 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 @@ -6728,6 +6728,7 @@ "enum": [ "auto", "prompt", + "writes", "approve" ], "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 dc6e2c9693..11540d40c7 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 @@ -854,6 +854,7 @@ "enum": [ "auto", "prompt", + "writes", "approve" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 16f8363416..19863dd1f3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -78,6 +78,7 @@ "enum": [ "auto", "prompt", + "writes", "approve" ], "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts index e92cd8e28b..6704ef0fde 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AppToolApproval.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 AppToolApproval = "auto" | "prompt" | "approve"; +export type AppToolApproval = "auto" | "prompt" | "writes" | "approve"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index f615b25d44..24d574a935 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -163,6 +163,7 @@ pub struct AnalyticsConfig { pub enum AppToolApproval { Auto, Prompt, + Writes, Approve, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8300c70d94..d52f1bd168 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1879,7 +1879,9 @@ review when allowed by configuration requirements. Use `apps._default.default_tools_approval_mode` to set the approval mode for tools without a per-app or per-tool override. Supported values are `"auto"`, -`"prompt"`, and `"approve"`. Tool-level `approval_mode` takes precedence over +`"prompt"`, `"writes"`, and `"approve"`. The `"writes"` mode prompts for tools +that do not advertise `readOnlyHint = true` and skips declared read-only tools. +Tool-level `approval_mode` takes precedence over the per-app `default_tools_approval_mode`, which takes precedence over the `apps._default` value. Managed tool requirements take precedence over all of these settings. When none are configured, the mode defaults to `"auto"`. diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 07b8ccc2e8..945ab16d7e 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -400,7 +400,7 @@ async fn config_read_includes_apps() -> Result<()> { r#" [apps._default] approvals_reviewer = "auto_review" -default_tools_approval_mode = "approve" +default_tools_approval_mode = "writes" [apps.app1] enabled = false @@ -440,7 +440,7 @@ default_tools_approval_mode = "prompt" approvals_reviewer: Some(ApprovalsReviewer::AutoReview), destructive_enabled: true, open_world_enabled: true, - default_tools_approval_mode: Some(AppToolApproval::Approve), + default_tools_approval_mode: Some(AppToolApproval::Writes), }), apps: std::collections::HashMap::from([( "app1".to_string(), diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs index fd0b1a0e4f..848180a517 100644 --- a/codex-rs/cli/src/mcp_cmd.rs +++ b/codex-rs/cli/src/mcp_cmd.rs @@ -963,6 +963,7 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re let approval_mode = match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }; println!(" default_tools_approval_mode: {approval_mode}"); diff --git a/codex-rs/config/src/mcp_edit.rs b/codex-rs/config/src/mcp_edit.rs index 16b3904917..ef0d8eae48 100644 --- a/codex-rs/config/src/mcp_edit.rs +++ b/codex-rs/config/src/mcp_edit.rs @@ -198,6 +198,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { entry["default_tools_approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } @@ -242,6 +243,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem { tool_entry["approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } diff --git a/codex-rs/config/src/mcp_types.rs b/codex-rs/config/src/mcp_types.rs index 550de84a0c..7043e0cb69 100644 --- a/codex-rs/config/src/mcp_types.rs +++ b/codex-rs/config/src/mcp_types.rs @@ -22,6 +22,7 @@ pub enum AppToolApproval { #[default] Auto, Prompt, + Writes, Approve, } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0a48882b09..fb4d8740ba 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -150,6 +150,7 @@ "enum": [ "auto", "prompt", + "writes", "approve" ], "type": "string" diff --git a/codex-rs/core/src/config/edit/document_helpers.rs b/codex-rs/core/src/config/edit/document_helpers.rs index 308eb9922f..871ca0e41e 100644 --- a/codex-rs/core/src/config/edit/document_helpers.rs +++ b/codex-rs/core/src/config/edit/document_helpers.rs @@ -121,6 +121,7 @@ fn serialize_mcp_server_table(config: &McpServerConfig) -> TomlTable { entry["default_tools_approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } @@ -173,6 +174,7 @@ fn serialize_mcp_server_tool(config: &McpServerToolConfig) -> TomlItem { entry["approval_mode"] = value(match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }); } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index d03f232d12..63d21504b9 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -1223,8 +1223,7 @@ async fn maybe_request_mcp_tool_approval( } let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); - let approval_required = requires_mcp_tool_approval(annotations); - if !approval_required && approval_mode != AppToolApproval::Prompt { + if !requires_mcp_tool_approval_for_mode(annotations, approval_mode) { return None; } @@ -1927,12 +1926,13 @@ fn normalize_approval_decision_for_mode( decision: McpToolApprovalDecision, approval_mode: AppToolApproval, ) -> McpToolApprovalDecision { - if approval_mode == AppToolApproval::Prompt - && matches!( - decision, - McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember - ) - { + if matches!( + approval_mode, + AppToolApproval::Prompt | AppToolApproval::Writes + ) && matches!( + decision, + McpToolApprovalDecision::AcceptForSession | McpToolApprovalDecision::AcceptAndRemember + ) { McpToolApprovalDecision::Accept } else { decision @@ -2179,6 +2179,20 @@ fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { .unwrap_or(true) } +fn requires_mcp_tool_approval_for_mode( + annotations: Option<&ToolAnnotations>, + approval_mode: AppToolApproval, +) -> bool { + match approval_mode { + AppToolApproval::Auto => requires_mcp_tool_approval(annotations), + AppToolApproval::Prompt => true, + AppToolApproval::Writes => !annotations + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false), + AppToolApproval::Approve => false, + } +} + async fn notify_mcp_tool_call_skip( sess: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index f68d32ff4e..336eecbfdf 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -398,23 +398,47 @@ fn approval_not_required_when_read_only_and_other_hints_are_absent() { } #[test] -fn prompt_mode_does_not_allow_persistent_remember() { +fn writes_mode_requires_approval_for_non_read_only_tools() { + let annotations = annotations(Some(false), Some(false), Some(false)); assert_eq!( - normalize_approval_decision_for_mode( - McpToolApprovalDecision::AcceptForSession, - AppToolApproval::Prompt, - ), - McpToolApprovalDecision::Accept + requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes), + true ); assert_eq!( - normalize_approval_decision_for_mode( - McpToolApprovalDecision::AcceptAndRemember, - AppToolApproval::Prompt, - ), - McpToolApprovalDecision::Accept + requires_mcp_tool_approval_for_mode(/*annotations*/ None, AppToolApproval::Writes), + true ); } +#[test] +fn writes_mode_does_not_require_approval_for_read_only_tools() { + let annotations = annotations(Some(true), Some(true), Some(true)); + assert_eq!( + requires_mcp_tool_approval_for_mode(Some(&annotations), AppToolApproval::Writes), + false + ); +} + +#[test] +fn prompting_modes_do_not_allow_persistent_remember() { + for approval_mode in [AppToolApproval::Prompt, AppToolApproval::Writes] { + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptForSession, + approval_mode, + ), + McpToolApprovalDecision::Accept + ); + assert_eq!( + normalize_approval_decision_for_mode( + McpToolApprovalDecision::AcceptAndRemember, + approval_mode, + ), + McpToolApprovalDecision::Accept + ); + } +} + #[tokio::test] async fn mcp_tool_call_span_records_expected_fields() { let buffer: &'static std::sync::Mutex> = diff --git a/codex-rs/core/tests/suite/mcp_turn_metadata.rs b/codex-rs/core/tests/suite/mcp_turn_metadata.rs index 0d403180d7..0ecaa80274 100644 --- a/codex-rs/core/tests/suite/mcp_turn_metadata.rs +++ b/codex-rs/core/tests/suite/mcp_turn_metadata.rs @@ -20,6 +20,7 @@ use codex_protocol::user_input::UserInput; use core_test_support::PathExt; use core_test_support::apps_test_server::AppsTestServer; 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::recorded_apps_tool_call_by_call_id; use core_test_support::apps_test_server::search_capable_apps_builder; @@ -45,6 +46,7 @@ fn set_calendar_approval_mode(config: &mut Config, approval_mode: AppToolApprova let approval_mode = match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }; let user_config_path = config.codex_home.join("config.toml").abs(); @@ -68,6 +70,7 @@ fn set_default_app_approval_mode_and_reviewer( let approval_mode = match approval_mode { AppToolApproval::Auto => "auto", AppToolApproval::Prompt => "prompt", + AppToolApproval::Writes => "writes", AppToolApproval::Approve => "approve", }; let user_config_path = config.codex_home.join("config.toml").abs(); @@ -333,6 +336,127 @@ async fn apps_default_prompt_with_auto_review_routes_actual_mcp_approval_to_guar Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn apps_default_writes_prompts_for_writes_but_not_reads() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let read_call_id = "calendar-read"; + let write_call_id = "calendar-write"; + let list_args = serde_json::to_string(&json!({}))?; + let create_args = serde_json::to_string(&json!({ + "title": "Lunch", + "starts_at": "2026-03-10T12:00:00Z" + }))?; + let responses = mount_sse_sequence( + &server, + vec![ + sse(vec![ + ev_response_created("resp-read"), + ev_function_call_with_namespace( + read_call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_LIST_TOOL, + &list_args, + ), + ev_completed("resp-read"), + ]), + sse(vec![ + ev_response_created("resp-write"), + ev_function_call_with_namespace( + write_call_id, + SEARCH_CALENDAR_NAMESPACE, + SEARCH_CALENDAR_CREATE_TOOL, + &create_args, + ), + ev_completed("resp-write"), + ]), + sse(vec![ + ev_response_created("resp-done"), + ev_assistant_message("msg-done", "done"), + ev_completed("resp-done"), + ]), + ], + ) + .await; + + let mut builder = search_capable_apps_builder(apps_server.chatgpt_base_url.clone()) + .with_config(|config| { + config + .features + .enable(Feature::ToolCallMcpElicitation) + .expect("test config should allow feature update"); + set_default_app_approval_mode_and_reviewer( + config, + AppToolApproval::Writes, + ApprovalsReviewer::User, + ); + }); + let test = builder.build(&server).await?; + + submit_user_turn( + &test, + "Use [$calendar](app://calendar) to list events, then create one.", + AskForApproval::OnRequest, + /*collaboration_mode*/ None, + ) + .await?; + + let EventMsg::McpToolCallBegin(read_begin) = wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::McpToolCallBegin(_)) + }) + .await + else { + unreachable!("event guard guarantees McpToolCallBegin"); + }; + assert_eq!(read_begin.call_id, read_call_id); + + let next_route = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::McpToolCallBegin(_) | EventMsg::ElicitationRequest(_) + ) + }) + .await; + let EventMsg::McpToolCallBegin(write_begin) = next_route else { + panic!("read-only app action should not prompt in writes mode"); + }; + assert_eq!(write_begin.call_id, write_call_id); + + let EventMsg::ElicitationRequest(request) = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::ElicitationRequest(_) | EventMsg::TurnComplete(_) + ) + }) + .await + else { + panic!("write app action should prompt in writes mode"); + }; + + test.codex + .submit(Op::ResolveElicitation { + server_name: request.server_name, + request_id: request.id, + decision: ElicitationAction::Accept, + content: None, + meta: None, + }) + .await?; + + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + + assert_eq!(responses.requests().len(), 3); + recorded_apps_tool_call_by_call_id(&server, read_call_id).await; + recorded_apps_tool_call_by_call_id(&server, write_call_id).await; + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn mcp_tool_call_metadata_records_prior_request_user_input_tool() -> Result<()> { skip_if_no_network!(Ok(())); From 78df1237d1a7a3b5d9d383acffbf7d3664b5acc7 Mon Sep 17 00:00:00 2001 From: Francis Chalissery Date: Tue, 7 Jul 2026 10:33:42 -0700 Subject: [PATCH 45/76] Handle bio policy errors in Codex (#31439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Treat streamed Responses `bio_policy` failures as terminal invalid requests instead of retryable stream errors. - Recognize the new biology policy code and message in the TUI while preserving the legacy `invalid_prompt` contract. - Keep the existing dedicated biology safety notice and add regression/snapshot coverage for all supported error shapes. ## Why [openai/openai#1068559](https://github.com/openai/openai/pull/1068559) gates a Responses API contract change from `invalid_prompt` and the legacy message to `bio_policy` and new biology copy. Without this compatibility change, streamed blocks are retried as transient failures and the OSS TUI falls back to a generic/raw error instead of the dedicated safety notice. ## Validation - `just test -p codex-api` — 137 passed - `just test -p codex-tui app_server_safety_access_errors_render_dedicated_notice` — passed - `just fix -p codex-api` - `just fix -p codex-tui` - `just fmt` - `just test -p codex-tui` — 2,957 passed; two reproducible failures remain in untouched Guardian feature-flag persistence tests: - `update_feature_flags_disabling_guardian_clears_review_policy_and_restores_default` - `update_feature_flags_disabling_guardian_clears_manual_review_policy_without_history` --- codex-rs/codex-api/src/sse/responses.rs | 54 ++++++++++++------- .../tui/src/chatwidget/tests/app_server.rs | 36 ++++++++++--- ...policy_error_renders_dedicated_notice.snap | 11 ++++ codex-rs/tui/src/chatwidget/turn_runtime.rs | 18 +++++-- 4 files changed, 89 insertions(+), 30 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 265d3ced5c..70f96cb855 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -398,7 +398,8 @@ pub fn process_responses_event( } else if is_cyber_policy_error(&error) { let message = cyber_policy_message(error.message); response_error = ApiError::CyberPolicy { message }; - } else if is_invalid_prompt_error(&error) { + } else if matches!(error.code.as_deref(), Some("invalid_prompt" | "bio_policy")) + { let message = error .message .unwrap_or_else(|| "Invalid request.".to_string()); @@ -632,10 +633,6 @@ fn is_usage_not_included(error: &Error) -> bool { error.code.as_deref() == Some("usage_not_included") } -fn is_invalid_prompt_error(error: &Error) -> bool { - error.code.as_deref() == Some("invalid_prompt") -} - fn is_cyber_policy_error(error: &Error) -> bool { error.code.as_deref() == Some("cyber_policy") } @@ -1076,23 +1073,42 @@ mod tests { } #[tokio::test] - async fn invalid_prompt_without_type_is_invalid_request() { - let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; + async fn content_policy_errors_without_type_are_invalid_requests() { + for (code, expected_message) in [ + ( + "invalid_prompt", + "Invalid prompt: we've limited access to this content for safety reasons.", + ), + ( + "bio_policy", + "This content was flagged for possible biological risk.", + ), + ] { + let raw_error = json!({ + "type": "response.failed", + "sequence_number": 3, + "response": { + "id": "resp_content_policy_no_type", + "object": "response", + "created_at": 1759771628, + "status": "failed", + "background": false, + "error": { "code": code, "message": expected_message }, + "incomplete_details": null, + }, + }) + .to_string(); + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); - let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + let events = collect_events(&[sse1.as_bytes()]).await; - let events = collect_events(&[sse1.as_bytes()]).await; - - assert_eq!(events.len(), 1); - - match &events[0] { - Err(ApiError::InvalidRequest { message }) => { - assert_eq!( - message, - "Invalid prompt: we've limited access to this content for safety reasons." - ); + assert_eq!(events.len(), 1); + match &events[0] { + Err(ApiError::InvalidRequest { message }) => { + assert_eq!(message, expected_message); + } + other => panic!("unexpected event for {code}: {other:?}"), } - other => panic!("unexpected event: {other:?}"), } } diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 952aff9f95..28e3cbae3e 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -1182,11 +1182,26 @@ async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { #[tokio::test] async fn app_server_safety_access_errors_render_dedicated_notice() { - let message = "Invalid prompt: we've limited access to this content for safety reasons."; - for message in [ - message.to_string(), - json!({ "error": { "message": message } }).to_string(), - ] { + let legacy_message = "Invalid prompt: we've limited access to this content for safety reasons."; + let bio_policy_message = "This content was flagged for possible biological risk."; + let cases = [ + ("legacy plain message", legacy_message.to_string()), + ( + "legacy JSON message", + json!({ "error": { "message": legacy_message } }).to_string(), + ), + ("bio policy plain message", bio_policy_message.to_string()), + ( + "bio policy JSON message", + json!({ "error": { "message": bio_policy_message } }).to_string(), + ), + ( + "bio policy code", + json!({ "error": { "code": "bio_policy", "message": "copy may change" } }).to_string(), + ), + ]; + let mut rendered_cases = Vec::new(); + for (case, message) in cases { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_non_retry_error(message, /*codex_error_info*/ None); @@ -1195,8 +1210,17 @@ async fn app_server_safety_access_errors_render_dedicated_notice() { let rendered = lines_to_single_string(&cells[0]); assert!(rendered.contains("This content can't be shown")); assert!(rendered.contains("biological research")); - assert!(!rendered.contains("Invalid prompt:")); + rendered_cases.push((case, rendered)); } + + let canonical = &rendered_cases[0].1; + for (case, rendered) in &rendered_cases[1..] { + assert_eq!(rendered, canonical, "unexpected rendering for {case}"); + } + insta::assert_snapshot!( + "app_server_bio_policy_error_renders_dedicated_notice", + rendered_cases.last().unwrap().1.as_str() + ); } #[tokio::test] diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap new file mode 100644 index 0000000000..3149943b64 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__app_server__app_server_bio_policy_error_renders_dedicated_notice.snap @@ -0,0 +1,11 @@ +--- +source: tui/src/chatwidget/tests/app_server.rs +expression: rendered_cases.last().unwrap().1.as_str() +--- +ⓘ This content can't be shown + We take extra caution with requests involving biological research and + applications that could pose safety risks. Eligible researchers can apply + for Trusted Access. + Trusted Access: https://www.openai.com/form/trusted-access-for-biology- + research/ + Learn more: https://help.openai.com/en/articles/20001326 diff --git a/codex-rs/tui/src/chatwidget/turn_runtime.rs b/codex-rs/tui/src/chatwidget/turn_runtime.rs index fdfad7f6d8..d71a60820e 100644 --- a/codex-rs/tui/src/chatwidget/turn_runtime.rs +++ b/codex-rs/tui/src/chatwidget/turn_runtime.rs @@ -5,8 +5,15 @@ use super::*; -const SAFETY_ACCESS_BLOCK_PREFIX: &str = +const LEGACY_SAFETY_ACCESS_BLOCK_PREFIX: &str = "Invalid prompt: we've limited access to this content for safety reasons."; +const BIO_POLICY_SAFETY_ACCESS_BLOCK_PREFIX: &str = + "This content was flagged for possible biological risk."; + +fn is_safety_access_block_message(message: &str) -> bool { + message.starts_with(LEGACY_SAFETY_ACCESS_BLOCK_PREFIX) + || message.starts_with(BIO_POLICY_SAFETY_ACCESS_BLOCK_PREFIX) +} impl ChatWidget { /// Synchronize the bottom-pane "task running" indicator with the current lifecycles. @@ -430,11 +437,12 @@ impl ChatWidget { .is_some_and(is_app_server_cyber_policy_error) { self.on_cyber_policy_error(); - } else if message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX) + } else if is_safety_access_block_message(&message) || serde_json::from_str::(&message).is_ok_and(|response| { - response["error"]["message"] - .as_str() - .is_some_and(|message| message.starts_with(SAFETY_ACCESS_BLOCK_PREFIX)) + response["error"]["code"].as_str() == Some("bio_policy") + || response["error"]["message"] + .as_str() + .is_some_and(is_safety_access_block_message) }) { self.input_queue.submit_pending_steers_after_interrupt = false; From 1345c16dd79849eafc5b4a826fa7e534df424810 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 7 Jul 2026 10:49:06 -0700 Subject: [PATCH 46/76] [codex] add connector runtime latency metrics (#31319) ## Summary - measure successful legacy `app/list` latency with `codex.apps.installed.duration_ms`, segmented by `path=legacy` and `reload` - measure successful host-owned `codex_apps` startup and explicit refresh latency with `codex.apps.refresh.duration_ms` - add the refresh trigger to successful `codex.mcp.tools.fetch_uncached.duration_ms` samples for `codex_apps` without changing other MCP-server samples ## Why This establishes a small latency baseline for the current connector path before `ConnectorRuntimeManager`, `app/installed`, and `app/read` land. Error-rate and broader runtime-state instrumentation are intentionally deferred. This is telemetry-only and does not change connector behavior. ## Validation - `just test -p codex-mcp` (94 passed) - `just test -p codex-app-server list_apps` (13 passed) - `just fix -p codex-mcp` - `just fix -p codex-app-server` - `just fmt` - `git diff --check` --- .../src/request_processors/apps_processor.rs | 35 ++++++++++++++++--- codex-rs/codex-mcp/src/connection_manager.rs | 21 ++++++----- codex-rs/codex-mcp/src/rmcp_client.rs | 33 +++++++++++++---- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 67d768fa57..5ff7ec40db 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -47,6 +47,8 @@ impl AppsRequestProcessor { request_id: &ConnectionRequestId, params: AppsListParams, ) -> Result, JSONRPCErrorError> { + let installed_start = Instant::now(); + let reload = params.force_refetch; let thread = if let Some(thread_id) = params.thread_id.as_deref() { let (_, loaded_thread) = self.load_thread(thread_id).await?; Some(loaded_thread) @@ -70,20 +72,24 @@ impl AppsRequestProcessor { .features .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) { - return Ok(Some(AppsListResponse { + let response = AppsListResponse { data: Vec::new(), next_cursor: None, - })); + }; + record_legacy_apps_installed_duration(installed_start, reload); + return Ok(Some(response)); } if !self .workspace_codex_plugins_enabled(&config, auth.as_ref()) .await { - return Ok(Some(AppsListResponse { + let response = AppsListResponse { data: Vec::new(), next_cursor: None, - })); + }; + record_legacy_apps_installed_duration(installed_start, reload); + return Ok(Some(response)); } let request = request_id.clone(); @@ -103,6 +109,7 @@ impl AppsRequestProcessor { environment_manager, mcp_manager, plugins_manager, + installed_start, ) => {} } }); @@ -113,6 +120,7 @@ impl AppsRequestProcessor { self.shutdown_token.cancel(); } + #[allow(clippy::too_many_arguments)] async fn apps_list_task( outgoing: Arc, request_id: ConnectionRequestId, @@ -121,7 +129,9 @@ impl AppsRequestProcessor { environment_manager: Arc, mcp_manager: Arc, plugins_manager: Arc, + installed_start: Instant, ) { + let reload = params.force_refetch; let retry_params = params.clone(); let retry_config = config.clone(); let retry_environment_manager = Arc::clone(&environment_manager); @@ -136,6 +146,9 @@ impl AppsRequestProcessor { plugins_manager, ) .await; + if result.is_ok() { + record_legacy_apps_installed_duration(installed_start, reload); + } let should_retry = result .as_ref() .is_ok_and(|(_, codex_apps_ready)| !codex_apps_ready); @@ -363,6 +376,20 @@ impl AppsRequestProcessor { } const APP_LIST_LOAD_TIMEOUT: Duration = Duration::from_secs(90); +// `app/list` is the legacy request-path baseline for the future `app/installed` endpoint; +// `path=legacy` keeps it separate from the new snapshot-backed implementation in dashboards. +const APPS_INSTALLED_DURATION_METRIC: &str = "codex.apps.installed.duration_ms"; + +fn record_legacy_apps_installed_duration(started_at: Instant, reload: bool) { + let reload = if reload { "true" } else { "false" }; + if let Some(metrics) = codex_otel::global() { + let _ = metrics.record_duration( + APPS_INSTALLED_DURATION_METRIC, + started_at.elapsed(), + &[("path", "legacy"), ("reload", reload)], + ); + } +} enum AppListLoadResult { Accessible(Result), diff --git a/codex-rs/codex-mcp/src/connection_manager.rs b/codex-rs/codex-mcp/src/connection_manager.rs index 2143e3b577..223d4296d5 100644 --- a/codex-rs/codex-mcp/src/connection_manager.rs +++ b/codex-rs/codex-mcp/src/connection_manager.rs @@ -23,8 +23,8 @@ use crate::elicitation::ElicitationReviewerHandle; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::ToolPluginProvenance; use crate::rmcp_client::AsyncManagedClient; +use crate::rmcp_client::CODEX_APPS_REFRESH_DURATION_METRIC; use crate::rmcp_client::DEFAULT_STARTUP_TIMEOUT; -use crate::rmcp_client::MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC; use crate::rmcp_client::MCP_TOOLS_LIST_DURATION_METRIC; use crate::rmcp_client::ManagedClient; use crate::rmcp_client::StartupOutcomeError; @@ -530,6 +530,7 @@ impl McpConnectionManager { /// cache is enabled and the latest filtered tools are returned directly to /// the caller. On failure, existing shared cache contents remain unchanged. pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result> { + let refresh_start = Instant::now(); let managed_client = self .clients .get(CODEX_APPS_MCP_SERVER_NAME) @@ -539,7 +540,6 @@ impl McpConnectionManager { .context("failed to get client")?; let list_start = Instant::now(); - let fetch_start = Instant::now(); let fetch_ticket = managed_client .codex_apps_tools_cache_context .as_ref() @@ -547,6 +547,7 @@ impl McpConnectionManager { let tools = list_tools_for_client_uncached( CODEX_APPS_MCP_SERVER_NAME, /*is_codex_apps_mcp_server*/ true, + /*codex_apps_refresh_trigger*/ "explicit", &managed_client.client, managed_client.tool_timeout, managed_client.server_instructions.as_deref(), @@ -555,11 +556,6 @@ impl McpConnectionManager { .with_context(|| { format!("failed to refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}'") })?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); let tools = match ( @@ -582,10 +578,13 @@ impl McpConnectionManager { tool.tool = tool_with_model_visible_input_schema(&tool.tool); self.with_server_metadata(tool) }); - Ok(normalize_tools_for_model_with_prefix( - tools, - self.prefix_mcp_tool_names, - )) + let tools = normalize_tools_for_model_with_prefix(tools, self.prefix_mcp_tool_names); + emit_duration( + CODEX_APPS_REFRESH_DURATION_METRIC, + refresh_start.elapsed(), + &[("path", "legacy"), ("trigger", "explicit")], + ); + Ok(tools) } /// Returns resources from servers selected by `include_server`. Each key diff --git a/codex-rs/codex-mcp/src/rmcp_client.rs b/codex-rs/codex-mcp/src/rmcp_client.rs index 37819203f6..6fe820b4fe 100644 --- a/codex-rs/codex-mcp/src/rmcp_client.rs +++ b/codex-rs/codex-mcp/src/rmcp_client.rs @@ -82,6 +82,7 @@ pub const OPENAI_FORM_CAPABILITY: &str = "openai/form"; pub(crate) const MCP_TOOLS_LIST_DURATION_METRIC: &str = "codex.mcp.tools.list.duration_ms"; pub(crate) const MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC: &str = "codex.mcp.tools.fetch_uncached.duration_ms"; +pub(crate) const CODEX_APPS_REFRESH_DURATION_METRIC: &str = "codex.apps.refresh.duration_ms"; pub(crate) const DEFAULT_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); pub(crate) const DEFAULT_TOOL_TIMEOUT: Duration = Duration::from_secs(300); @@ -304,6 +305,7 @@ impl ManagedClientStartup { .unwrap_or_default(); let cancel_token_for_fut = cancel_token; async move { + let refresh_start = is_codex_apps_mcp_server.then(Instant::now); let outcome = match async { if let Err(error) = validate_mcp_server_name(&server_name) { return Err(error.into()); @@ -349,6 +351,15 @@ impl ManagedClientStartup { Ok(result) => result, Err(CancelErr::Cancelled) => Err(StartupOutcomeError::Cancelled), }; + if outcome.is_ok() + && let Some(refresh_start) = refresh_start + { + emit_duration( + CODEX_APPS_REFRESH_DURATION_METRIC, + refresh_start.elapsed(), + &[("path", "legacy"), ("trigger", "initial")], + ); + } startup_complete.store(true, Ordering::Release); outcome @@ -564,10 +575,12 @@ impl From for StartupOutcomeError { pub(crate) async fn list_tools_for_client_uncached( server_name: &str, is_codex_apps_mcp_server: bool, + codex_apps_refresh_trigger: &'static str, client: &Arc, timeout: Option, server_instructions: Option<&str>, ) -> Result> { + let fetch_start = Instant::now(); let resp = client .list_tools_with_connector_ids(/*params*/ None, timeout) .await?; @@ -583,6 +596,19 @@ pub(crate) async fn list_tools_for_client_uncached( ) }) .collect(); + if is_codex_apps_mcp_server { + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[("trigger", codex_apps_refresh_trigger)], + ); + } else { + emit_duration( + MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, + fetch_start.elapsed(), + &[], + ); + } Ok(tools) } @@ -820,24 +846,19 @@ async fn start_server_task( .and_then(|exp| exp.get(MCP_SANDBOX_STATE_META_CAPABILITY)) .is_some(); let list_start = Instant::now(); - let fetch_start = Instant::now(); let fetch_ticket = codex_apps_tools_cache_context .as_ref() .map(|cache_context| cache_context.begin_fetch(CodexAppsToolsFetchSource::Startup)); let tools = list_tools_for_client_uncached( &server_name, is_codex_apps_mcp_server, + /*codex_apps_refresh_trigger*/ "initial", &client, startup_timeout, initialize_result.instructions.as_deref(), ) .await .map_err(StartupOutcomeError::from)?; - emit_duration( - MCP_TOOLS_FETCH_UNCACHED_DURATION_METRIC, - fetch_start.elapsed(), - &[], - ); let server_info = mcp_server_info_from_implementation(initialize_result.server_info); let tools = match (codex_apps_tools_cache_context.as_ref(), fetch_ticket) { (Some(cache_context), Some(fetch_ticket)) => { From 358575465c65182cf77e6a1e6da395eb00ba51cf Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Tue, 7 Jul 2026 10:52:38 -0700 Subject: [PATCH 47/76] Use model catalog approval messages (#31312) ## Why Approval guidance is currently assembled entirely by the client. Model Messages V2 needs model catalogs to provide model-specific `on_request` guidance for both user-reviewed and auto-reviewed approval flows while retaining the existing generated prompt as a compatibility fallback. ## What changed - add nullable `on_request` and `on_request_auto_review` catalog messages - select the message matching the active approvals reviewer for `on_request` policies - replace the complete legacy approval section when the selected catalog value exists, including support for an empty string that suppresses the section - retain legacy rendering when the object or selected key is absent, and for non-`on_request` policies - preserve approval messages when base-instruction or personality overrides clear instruction templates - refresh permissions instructions when the active model changes - pass catalog messages through initial and incremental permissions construction ## Relationship to reviewer persistence PR #31309 independently persists the approvals reviewer in turn context and refreshes permissions when that reviewer changes. This PR is based directly on `main` and does not duplicate that rollout migration; once both land, reviewer switches will also select and append the new catalog variant. ## Testing - `just test -p codex-protocol` - `just test -p codex-prompts` - `just test -p codex-models-manager` - `just test -p codex-core permissions_messages` --- codex-rs/core/src/context/mod.rs | 1 + .../src/context/permissions_instructions.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 10 +- codex-rs/core/src/session/mod.rs | 10 +- codex-rs/core/src/session/tests.rs | 17 +-- .../core/tests/suite/permissions_messages.rs | 130 +++++++++++++++++- codex-rs/core/tests/suite/personality.rs | 2 + codex-rs/models-manager/src/model_info.rs | 15 +- .../models-manager/src/model_info_tests.rs | 63 +++++++++ codex-rs/prompts/src/lib.rs | 1 + .../prompts/src/permissions_instructions.rs | 33 ++++- .../src/permissions_instructions_tests.rs | 114 ++++++++++++++- codex-rs/protocol/src/openai_models.rs | 42 ++++++ 13 files changed, 419 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index da7db0b373..20b35e7af3 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -62,6 +62,7 @@ pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProce pub(crate) use model_switch_instructions::ModelSwitchInstructions; pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions; pub(crate) use network_rule_saved::NetworkRuleSaved; +pub use permissions_instructions::ApprovalPromptContext; pub use permissions_instructions::PermissionsInstructions; pub(crate) use personality_spec_instructions::PersonalitySpecInstructions; pub(crate) use plugin_instructions::PluginInstructions; diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 73629a3d53..3dd1bbac92 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -1 +1,2 @@ +pub use codex_prompts::ApprovalPromptContext; pub use codex_prompts::PermissionsInstructions; diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index a520e6f595..d4cf65e86d 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -1,3 +1,4 @@ +use crate::context::ApprovalPromptContext; use crate::context::CollaborationModeInstructions; use crate::context::ContextualUserFragment; use crate::context::ModelSwitchInstructions; @@ -30,6 +31,7 @@ fn build_permissions_update_item( let prev = previous?; if prev.permission_profile() == next.permission_profile() && prev.approval_policy == next.approval_policy.value() + && prev.model == next.model_info.slug { return None; } @@ -38,7 +40,13 @@ fn build_permissions_update_item( PermissionsInstructions::from_permission_profile( &next.permission_profile, next.approval_policy.value(), - next.config.approvals_reviewer, + ApprovalPromptContext::new( + next.config.approvals_reviewer, + next.model_info + .model_messages + .as_ref() + .and_then(|messages| messages.approvals.as_ref()), + ), exec_policy, #[allow(deprecated)] &next.cwd, diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index fa240b890b..af2412e5c4 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -21,6 +21,7 @@ use crate::build_available_skills; use crate::compact; use crate::config::ManagedFeatures; use crate::config::resolve_tool_suggest_config_from_layer_stack; +use crate::context::ApprovalPromptContext; use crate::context::ApprovedCommandPrefixSaved; use crate::context::AvailableSkillsInstructions; use crate::context::CollaborationModeInstructions; @@ -3206,7 +3207,14 @@ impl Session { PermissionsInstructions::from_permission_profile( &turn_context.permission_profile, turn_context.approval_policy.value(), - turn_context.config.approvals_reviewer, + ApprovalPromptContext::new( + turn_context.config.approvals_reviewer, + turn_context + .model_info + .model_messages + .as_ref() + .and_then(|messages| messages.approvals.as_ref()), + ), self.services.exec_policy.current().as_ref(), #[allow(deprecated)] &turn_context.cwd, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5ebce9aa42..ac8f9d049e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -9041,18 +9041,13 @@ async fn record_context_updates_and_set_reference_context_item_reinjects_full_co #[tokio::test] async fn record_context_updates_and_set_reference_context_item_persists_baseline_without_emitting_diffs() { - let (mut session, previous_context) = make_session_and_context().await; - let next_model = if previous_context.model_info.slug == "gpt-5.4" { - "gpt-5.2" - } else { - "gpt-5.4" - }; - let turn_context = previous_context - .with_model(next_model.to_string(), &session.services.models_manager) - .await; - let previous_context_item = previous_context.to_turn_context_item(); - let previous_context = Arc::new(previous_context); + let (mut session, turn_context) = make_session_and_context().await; + let previous_context_item = turn_context.to_turn_context_item(); + let previous_context = Arc::new(turn_context); let world_state = build_world_state_from_turn_context(&session, &previous_context).await; + let mut turn_context = Arc::try_unwrap(previous_context) + .unwrap_or_else(|_| panic!("previous turn context should have no remaining references")); + turn_context.sub_id = format!("{}-next", turn_context.sub_id); { let mut state = session.state.lock().await; state.set_reference_context_item(Some(previous_context_item.clone())); diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index e5a69962bc..7f42ba893c 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -2,10 +2,15 @@ use anyhow::Result; use codex_config::ConfigLayerStack; use codex_core::ForkSnapshot; use codex_core::config::Constrained; +use codex_core::context::ApprovalPromptContext; use codex_core::context::ContextualUserFragment; use codex_core::context::PermissionsInstructions; use codex_core::load_exec_policy; +use codex_models_manager::model_info::model_info_from_slug; use codex_protocol::models::PermissionProfile; +use codex_protocol::openai_models::ApprovalMessages; +use codex_protocol::openai_models::ModelMessages; +use codex_protocol::openai_models::ModelsResponse; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; @@ -33,6 +38,129 @@ fn permissions_texts(request: &ResponsesRequest) -> Vec { .collect() } +fn model_with_approval_messages( + slug: &str, + on_request: &str, + on_request_auto_review: &str, +) -> codex_protocol::openai_models::ModelInfo { + let mut model = model_info_from_slug(slug); + model.model_messages = Some(ModelMessages { + instructions_template: None, + instructions_variables: None, + approvals: Some(ApprovalMessages { + on_request: Some(on_request.to_string()), + on_request_auto_review: Some(on_request_auto_review.to_string()), + }), + }); + model +} + +async fn submit_text_turn( + test: &core_test_support::test_codex::TestCodex, + text: &str, +) -> Result<()> { + test.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?; + wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn catalog_approval_message_is_sent_in_initial_permissions() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let req = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let model_slug = "catalog-approvals-model"; + let model = model_with_approval_messages( + model_slug, + "catalog user approval instructions", + "catalog auto-review approval instructions", + ); + let mut builder = test_codex() + .with_model(model_slug) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.model_catalog = Some(ModelsResponse { + models: vec![model], + }); + }); + let test = builder.build(&server).await?; + + submit_text_turn(&test, "hello").await?; + + let permissions = permissions_texts(&req.single_request()); + assert_eq!(permissions.len(), 1); + assert!(permissions[0].contains("catalog user approval instructions")); + assert!(permissions[0].contains("Filesystem sandboxing defines")); + assert!(!permissions[0].contains("How to request escalation")); + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_change_appends_new_catalog_approval_message() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let _req1 = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-1"), ev_completed("resp-1")]), + ) + .await; + let req2 = mount_sse_once( + &server, + sse(vec![ev_response_created("resp-2"), ev_completed("resp-2")]), + ) + .await; + let first_slug = "catalog-approvals-model-a"; + let second_slug = "catalog-approvals-model-b"; + let first = model_with_approval_messages(first_slug, "model A approvals", "model A auto"); + let second = model_with_approval_messages(second_slug, "model B approvals", "model B auto"); + let mut builder = test_codex() + .with_model(first_slug) + .with_config(move |config| { + config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest); + config.model_catalog = Some(ModelsResponse { + models: vec![first, second], + }); + }); + let test = builder.build(&server).await?; + submit_text_turn(&test, "first").await?; + + core_test_support::submit_thread_settings( + &test.codex, + codex_protocol::protocol::ThreadSettingsOverrides { + model: Some(second_slug.to_string()), + ..Default::default() + }, + ) + .await?; + submit_text_turn(&test, "second").await?; + + let permissions = permissions_texts(&req2.single_request()); + assert_eq!(permissions.len(), 2); + assert!( + permissions + .last() + .is_some_and(|text| text.contains("model B approvals")) + ); + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn permissions_message_sent_once_on_start() -> Result<()> { skip_if_no_network!(Ok(())); @@ -563,7 +691,7 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { let expected = PermissionsInstructions::from_permission_profile( &permission_profile, AskForApproval::OnRequest, - test.config.approvals_reviewer, + ApprovalPromptContext::new(test.config.approvals_reviewer, /*messages*/ None), &exec_policy, test.config.cwd.as_path(), /*exec_permission_approvals_enabled*/ false, diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index e74695b59d..88733b4666 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -573,6 +573,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow personality_friendly: Some(friendly_personality_message.to_string()), personality_pragmatic: Some("Pragmatic variant".to_string()), }), + approvals: None, }), include_skills_usage_instructions: false, supports_reasoning_summaries: false, @@ -689,6 +690,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - personality_friendly: Some(remote_friendly_message.to_string()), personality_pragmatic: Some(remote_pragmatic_message.to_string()), }), + approvals: None, }), include_skills_usage_instructions: false, supports_reasoning_summaries: false, diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index 573403b728..75d333efe3 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -54,14 +54,24 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) if let Some(base_instructions) = &config.base_instructions { model.base_instructions = base_instructions.clone(); - model.model_messages = None; + clear_instruction_messages(&mut model); } else if !config.personality_enabled { - model.model_messages = None; + clear_instruction_messages(&mut model); } model } +fn clear_instruction_messages(model: &mut ModelInfo) { + if let Some(model_messages) = &mut model.model_messages { + model_messages.instructions_template = None; + model_messages.instructions_variables = None; + if model_messages.approvals.is_none() { + model.model_messages = None; + } + } +} + /// Build a minimal fallback model descriptor for missing/unknown slugs. pub fn model_info_from_slug(slug: &str) -> ModelInfo { warn!("Unknown model {slug} is used. This will use fallback model metadata."); @@ -119,6 +129,7 @@ fn local_personality_messages_for_slug(slug: &str) -> Option { personality_friendly: Some(LOCAL_FRIENDLY_TEMPLATE.to_string()), personality_pragmatic: Some(LOCAL_PRAGMATIC_TEMPLATE.to_string()), }), + approvals: None, }), _ => None, } diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 70ad3da8df..2240f0a391 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::ModelsManagerConfig; +use codex_protocol::openai_models::ApprovalMessages; use pretty_assertions::assert_eq; #[test] @@ -44,6 +45,68 @@ fn reasoning_summaries_override_false_is_noop_when_model_is_false() { assert_eq!(updated, model); } +#[test] +fn base_instruction_override_preserves_catalog_approval_messages() { + let mut model = model_info_from_slug("unknown-model"); + let approvals = ApprovalMessages { + on_request: Some("user approvals".to_string()), + on_request_auto_review: Some("auto approvals".to_string()), + }; + model.model_messages = Some(ModelMessages { + instructions_template: Some("template".to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: Some("default".to_string()), + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: Some("pragmatic".to_string()), + }), + approvals: Some(approvals.clone()), + }); + let config = ModelsManagerConfig { + base_instructions: Some("override".to_string()), + ..Default::default() + }; + + let updated = with_config_overrides(model, &config); + + assert_eq!( + updated.model_messages, + Some(ModelMessages { + instructions_template: None, + instructions_variables: None, + approvals: Some(approvals), + }) + ); +} + +#[test] +fn disabled_personality_preserves_catalog_approval_messages() { + let mut model = model_info_from_slug("unknown-model"); + let approvals = ApprovalMessages { + on_request: Some("user approvals".to_string()), + on_request_auto_review: None, + }; + model.model_messages = Some(ModelMessages { + instructions_template: Some("template".to_string()), + instructions_variables: None, + approvals: Some(approvals.clone()), + }); + let config = ModelsManagerConfig { + personality_enabled: false, + ..Default::default() + }; + + let updated = with_config_overrides(model, &config); + + assert_eq!( + updated.model_messages, + Some(ModelMessages { + instructions_template: None, + instructions_variables: None, + approvals: Some(approvals), + }) + ); +} + #[test] fn model_context_window_override_clamps_to_max_context_window() { let mut model = model_info_from_slug("unknown-model"); diff --git a/codex-rs/prompts/src/lib.rs b/codex-rs/prompts/src/lib.rs index 5a30358393..c598b2d765 100644 --- a/codex-rs/prompts/src/lib.rs +++ b/codex-rs/prompts/src/lib.rs @@ -12,6 +12,7 @@ pub use compact::SUMMARY_PREFIX; pub use goals::budget_limit_prompt; pub use goals::continuation_prompt; pub use goals::objective_updated_prompt; +pub use permissions_instructions::ApprovalPromptContext; pub use permissions_instructions::PermissionsInstructions; pub use realtime::BACKEND_PROMPT; pub use realtime::END_INSTRUCTIONS; diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index c382b804a0..1c19d008cf 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -4,6 +4,7 @@ use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::SandboxMode; use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; +use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; @@ -47,6 +48,7 @@ static SANDBOX_MODE_READ_ONLY_TEMPLATE: LazyLock