From b364faf4ecc69401ca4c796d398d6646a6da7621 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 14:44:09 -0700 Subject: [PATCH 01/50] Tweak /plugin menu wording (#15676) - Updated `/plugin` UI messaging for clearer wording. - Synced the same copy changes across `tui` and `tui_app_server`. --- codex-rs/tui/src/chatwidget/plugins.rs | 14 +++++--------- ...widget__tests__plugins_popup_loading_state.snap | 2 +- codex-rs/tui_app_server/src/chatwidget/plugins.rs | 14 +++++--------- ...widget__tests__plugins_popup_loading_state.snap | 2 +- 4 files changed, 12 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index dc1dfd5836..079f70e226 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -355,9 +355,7 @@ impl ChatWidget { }; items.push(SelectionItem { name: install_label.to_string(), - description: Some( - "Open the same ChatGPT app management link used by /apps.".to_string(), - ), + description: Some("Open the ChatGPT app management page".to_string()), selected_description: Some("Open the app page in your browser.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenUrlInBrowser { @@ -368,7 +366,7 @@ impl ChatWidget { }); } else { items.push(SelectionItem { - name: "ChatGPT link unavailable".to_string(), + name: "ChatGPT apps link unavailable".to_string(), description: Some("This app did not provide an install/manage URL.".to_string()), is_disabled: true, ..Default::default() @@ -480,7 +478,7 @@ impl ChatWidget { header.push(Line::from("Plugins".bold())); header.push(Line::from("Loading available plugins...".dim())); header.push(Line::from( - "This first pass shows the ChatGPT marketplace only.".dim(), + "Available marketplaces will appear here when ready.".dim(), )); SelectionViewParams { @@ -508,9 +506,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), - description: Some( - "This updates when the plugin detail request finishes.".to_string(), - ), + description: Some("This updates when plugin details load.".to_string()), is_disabled: true, ..Default::default() }], @@ -556,7 +552,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Uninstalling plugin...".to_string(), - description: Some("This updates when plugin removal completes.".to_string()), + description: Some("This updates when the plugin removal completes.".to_string()), is_disabled: true, ..Default::default() }], diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap index 2b0f6837eb..42eff7f81f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - This first pass shows the ChatGPT marketplace only. + Available marketplaces will appear here when ready. › Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index dc1dfd5836..079f70e226 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -355,9 +355,7 @@ impl ChatWidget { }; items.push(SelectionItem { name: install_label.to_string(), - description: Some( - "Open the same ChatGPT app management link used by /apps.".to_string(), - ), + description: Some("Open the ChatGPT app management page".to_string()), selected_description: Some("Open the app page in your browser.".to_string()), actions: vec![Box::new(move |tx| { tx.send(AppEvent::OpenUrlInBrowser { @@ -368,7 +366,7 @@ impl ChatWidget { }); } else { items.push(SelectionItem { - name: "ChatGPT link unavailable".to_string(), + name: "ChatGPT apps link unavailable".to_string(), description: Some("This app did not provide an install/manage URL.".to_string()), is_disabled: true, ..Default::default() @@ -480,7 +478,7 @@ impl ChatWidget { header.push(Line::from("Plugins".bold())); header.push(Line::from("Loading available plugins...".dim())); header.push(Line::from( - "This first pass shows the ChatGPT marketplace only.".dim(), + "Available marketplaces will appear here when ready.".dim(), )); SelectionViewParams { @@ -508,9 +506,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), - description: Some( - "This updates when the plugin detail request finishes.".to_string(), - ), + description: Some("This updates when plugin details load.".to_string()), is_disabled: true, ..Default::default() }], @@ -556,7 +552,7 @@ impl ChatWidget { header: Box::new(header), items: vec![SelectionItem { name: "Uninstalling plugin...".to_string(), - description: Some("This updates when plugin removal completes.".to_string()), + description: Some("This updates when the plugin removal completes.".to_string()), is_disabled: true, ..Default::default() }], diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap index f1f18c1eaf..846c73fe47 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - This first pass shows the ChatGPT marketplace only. + Available marketplaces will appear here when ready. › Loading plugins... This updates when the marketplace list is ready. From 4b91a7b3916ab631043f26f24c92aa4ac2577348 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 14:46:21 -0700 Subject: [PATCH 02/50] Suppress plugin-install MCP OAuth URL console spam (#15666) Switch plugin-install background MCP OAuth to a silent login path so the raw authorization URL is no longer printed in normal success cases. OAuth behavior is otherwise unchanged, with fallback URL output via stderr still shown only if browser launch fails. Before: https://github.com/user-attachments/assets/4bf387af-afa8-4b83-bcd6-4ca6b55da8db --- .../plugin_mcp_oauth.rs | 6 +- codex-rs/rmcp-client/src/lib.rs | 1 + .../rmcp-client/src/perform_oauth_login.rs | 76 +++++++++++++++++-- 3 files changed, 73 insertions(+), 10 deletions(-) diff --git a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs b/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs index 0c13f5ed4c..fb9973e8c5 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugin_mcp_oauth.rs @@ -9,7 +9,7 @@ use codex_core::mcp::auth::McpOAuthLoginSupport; use codex_core::mcp::auth::oauth_login_support; use codex_core::mcp::auth::resolve_oauth_scopes; use codex_core::mcp::auth::should_retry_without_scopes; -use codex_rmcp_client::perform_oauth_login; +use codex_rmcp_client::perform_oauth_login_silent; use tracing::warn; use super::CodexMessageProcessor; @@ -45,7 +45,7 @@ impl CodexMessageProcessor { let notification_name = name.clone(); tokio::spawn(async move { - let first_attempt = perform_oauth_login( + let first_attempt = perform_oauth_login_silent( &name, &oauth_config.url, store_mode, @@ -60,7 +60,7 @@ impl CodexMessageProcessor { let final_result = match first_attempt { Err(err) if should_retry_without_scopes(&resolved_scopes, &err) => { - perform_oauth_login( + perform_oauth_login_silent( &name, &oauth_config.url, store_mode, diff --git a/codex-rs/rmcp-client/src/lib.rs b/codex-rs/rmcp-client/src/lib.rs index 0edd0f1527..627b9f2e71 100644 --- a/codex-rs/rmcp-client/src/lib.rs +++ b/codex-rs/rmcp-client/src/lib.rs @@ -21,6 +21,7 @@ pub use perform_oauth_login::OAuthProviderError; pub use perform_oauth_login::OauthLoginHandle; pub use perform_oauth_login::perform_oauth_login; pub use perform_oauth_login::perform_oauth_login_return_url; +pub use perform_oauth_login::perform_oauth_login_silent; pub use rmcp::model::ElicitationAction; pub use rmcp_client::Elicitation; pub use rmcp_client::ElicitationResponse; diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 938f51b053..821fbf2750 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -80,6 +80,61 @@ pub async fn perform_oauth_login( oauth_resource: Option<&str>, callback_port: Option, callback_url: Option<&str>, +) -> Result<()> { + perform_oauth_login_with_browser_output( + server_name, + server_url, + store_mode, + http_headers, + env_http_headers, + scopes, + oauth_resource, + callback_port, + callback_url, + /*emit_browser_url*/ true, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn perform_oauth_login_silent( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + oauth_resource: Option<&str>, + callback_port: Option, + callback_url: Option<&str>, +) -> Result<()> { + perform_oauth_login_with_browser_output( + server_name, + server_url, + store_mode, + http_headers, + env_http_headers, + scopes, + oauth_resource, + callback_port, + callback_url, + /*emit_browser_url*/ false, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn perform_oauth_login_with_browser_output( + server_name: &str, + server_url: &str, + store_mode: OAuthCredentialsStoreMode, + http_headers: Option>, + env_http_headers: Option>, + scopes: &[String], + oauth_resource: Option<&str>, + callback_port: Option, + callback_url: Option<&str>, + emit_browser_url: bool, ) -> Result<()> { let headers = OauthHeaders { http_headers, @@ -98,7 +153,7 @@ pub async fn perform_oauth_login( /*timeout_secs*/ None, ) .await? - .finish() + .finish(emit_browser_url) .await } @@ -415,16 +470,23 @@ impl OauthLoginFlow { self.auth_url.clone() } - async fn finish(mut self) -> Result<()> { + async fn finish(mut self, emit_browser_url: bool) -> Result<()> { if self.launch_browser { let server_name = &self.server_name; let auth_url = &self.auth_url; - println!( - "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" - ); + if emit_browser_url { + println!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + } if webbrowser::open(auth_url).is_err() { - println!("(Browser launch failed; please copy the URL above manually.)"); + if !emit_browser_url { + eprintln!( + "Authorize `{server_name}` by opening this URL in your browser:\n{auth_url}\n" + ); + } + eprintln!("(Browser launch failed; please copy the URL above manually.)"); } } @@ -477,7 +539,7 @@ impl OauthLoginFlow { let (tx, rx) = oneshot::channel(); tokio::spawn(async move { - let result = self.finish().await; + let result = self.finish(/*emit_browser_url*/ false).await; if let Err(err) = &result { eprintln!( From b32d921cd95dc74f69a367e9266a5da886926b8b Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 15:10:00 -0700 Subject: [PATCH 03/50] [plugins] Additional gating for tool suggest and apps. (#15573) - [x] Additional gating for tool suggest and apps. --- codex-rs/core/src/apps/render.rs | 66 ++++++++++++++++++++++++++- codex-rs/core/src/codex.rs | 11 ++++- codex-rs/core/src/connectors.rs | 13 ++++++ codex-rs/core/src/tools/spec.rs | 5 +- codex-rs/core/src/tools/spec_tests.rs | 48 +++++++++++++++++++ 5 files changed, 139 insertions(+), 4 deletions(-) diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 7cc07c0747..3657a23255 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -1,10 +1,72 @@ use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; +use codex_app_server_protocol::AppInfo; use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG; -pub(crate) fn render_apps_section() -> String { +pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option { + if !connectors + .iter() + .any(|connector| connector.is_accessible && connector.is_enabled) + { + return None; + } + let body = format!( "## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps." ); - format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}") + Some(format!( + "{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo { + AppInfo { + id: id.to_string(), + name: id.to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible, + is_enabled, + plugin_display_names: Vec::new(), + } + } + + #[test] + fn omits_apps_section_without_accessible_and_enabled_apps() { + assert_eq!(render_apps_section(&[]), None); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ false + )]), + None + ); + assert_eq!( + render_apps_section(&[connector( + "calendar", /*is_accessible*/ false, /*is_enabled*/ true + )]), + None + ); + } + + #[test] + fn renders_apps_section_with_an_accessible_and_enabled_app() { + let rendered = render_apps_section(&[connector( + "calendar", /*is_accessible*/ true, /*is_enabled*/ true, + )]) + .expect("expected apps section"); + + assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG)); + assert!(rendered.contains("## Apps (Connectors)")); + assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG)); + } } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index da5f6e0d5a..b299bec369 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3537,7 +3537,16 @@ impl Session { } } if turn_context.apps_enabled() { - developer_sections.push(render_apps_section()); + let mcp_connection_manager = self.services.mcp_connection_manager.read().await; + let accessible_and_enabled_connectors = + connectors::list_accessible_and_enabled_connectors_from_manager( + &mcp_connection_manager, + &turn_context.config, + ) + .await; + if let Some(apps_section) = render_apps_section(&accessible_and_enabled_connectors) { + developer_sections.push(apps_section); + } } let implicit_skills = turn_context .turn_skills diff --git a/codex-rs/core/src/connectors.rs b/codex-rs/core/src/connectors.rs index 600ba9c6f9..fdb0f43222 100644 --- a/codex-rs/core/src/connectors.rs +++ b/codex-rs/core/src/connectors.rs @@ -103,6 +103,19 @@ 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() +} + pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth( config: &Config, auth: Option<&CodexAuth>, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d6b167ed9b..77c2954067 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -389,7 +389,10 @@ impl ToolsConfig { let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = model_info.supports_search_tool; - let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest); + let include_tool_suggest = include_search_tool + && features.enabled(Feature::ToolSuggest) + && features.enabled(Feature::Apps) + && features.enabled(Feature::Plugins); let include_original_image_detail = can_request_original_image_detail(features, model_info); let include_artifact_tools = features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime(); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 7550495c77..dd806f887b 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2031,6 +2031,7 @@ fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::Plugins); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2061,6 +2062,52 @@ fn tool_suggest_is_not_registered_without_feature_flag() { ); } +#[test] +fn tool_suggest_requires_apps_and_plugins_features() { + let model_info = search_capable_model_info(); + let discoverable_tools = Some(vec![discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + )]); + let available_models = Vec::new(); + + for disabled_feature in [Feature::Apps, Feature::Plugins] { + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSuggest); + for feature in [Feature::Apps, Feature::Plugins] { + if feature != disabled_feature { + features.enable(feature); + } + } + + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + discoverable_tools.clone(), + &[], + ) + .build(); + + assert!( + !tools + .iter() + .any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME), + "tool_suggest should be absent when {disabled_feature:?} is disabled" + ); + } +} + #[test] fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); @@ -2205,6 +2252,7 @@ fn tool_suggest_description_lists_discoverable_tools() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::Plugins); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { From 92e529a8ec14183575a506e96616e64cb6a857dc Mon Sep 17 00:00:00 2001 From: Roy Han Date: Tue, 24 Mar 2026 15:10:10 -0700 Subject: [PATCH 04/50] thread event start --- codex-rs/core/src/analytics_client.rs | 158 +++++++++++++++++++ codex-rs/core/src/analytics_client_tests.rs | 68 ++++++++ codex-rs/core/src/codex.rs | 78 +++++++++ codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/plugins.rs | 22 ++- codex-rs/core/tests/suite/thread_metadata.rs | 93 +++++++++++ 6 files changed, 412 insertions(+), 8 deletions(-) create mode 100644 codex-rs/core/tests/suite/thread_metadata.rs diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index bfb90bc4da..86f96e62de 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -4,6 +4,15 @@ use crate::default_client::create_client; use crate::git_info::collect_git_info; use crate::git_info::get_git_repo_root; use crate::plugins::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; use serde::Serialize; use sha1::Digest; @@ -23,6 +32,35 @@ pub(crate) struct TrackEventsContext { pub(crate) turn_id: String, } +#[derive(Clone)] +pub(crate) struct CodexThreadStartedEvent { + pub(crate) thread_id: String, + pub(crate) model: String, + pub(crate) model_provider: String, + pub(crate) reasoning_effort: Option, + pub(crate) reasoning_summary: Option, + pub(crate) service_tier: Option, + pub(crate) approval_policy: AskForApproval, + pub(crate) approvals_reviewer: ApprovalsReviewer, + pub(crate) sandbox_policy: SandboxPolicy, + pub(crate) sandbox_network_access: bool, + pub(crate) collaboration_mode: ModeKind, + pub(crate) personality: Option, + pub(crate) ephemeral: bool, + pub(crate) session_source: SessionSource, + pub(crate) parent_thread_id: Option, + pub(crate) create_source: ThreadCreateSource, + pub(crate) created_at: u64, +} + +#[derive(Clone, Copy, Debug, Serialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum ThreadCreateSource { + ThreadStart, + ThreadFork, + DetachedReview, +} + pub(crate) fn build_track_events_context( model_slug: String, thread_id: String, @@ -78,6 +116,9 @@ impl AnalyticsEventsQueue { TrackEventsJob::SkillInvocations(job) => { send_track_skill_invocations(&auth_manager, job).await; } + TrackEventsJob::ThreadStarted(job) => { + send_track_thread_started(&auth_manager, job).await; + } TrackEventsJob::AppMentioned(job) => { send_track_app_mentioned(&auth_manager, job).await; } @@ -167,6 +208,10 @@ impl AnalyticsEventsClient { ); } + pub(crate) fn track_thread_started(&self, thread_event: CodexThreadStartedEvent) { + track_thread_started(&self.queue, Arc::clone(&self.config), thread_event); + } + pub(crate) fn track_app_mentioned( &self, tracking: TrackEventsContext, @@ -236,6 +281,7 @@ impl AnalyticsEventsClient { enum TrackEventsJob { SkillInvocations(TrackSkillInvocationsJob), + ThreadStarted(TrackThreadStartedJob), AppMentioned(TrackAppMentionedJob), AppUsed(TrackAppUsedJob), PluginUsed(TrackPluginUsedJob), @@ -251,6 +297,11 @@ struct TrackSkillInvocationsJob { invocations: Vec, } +struct TrackThreadStartedJob { + config: Arc, + thread_event: CodexThreadStartedEvent, +} + struct TrackAppMentionedJob { config: Arc, tracking: TrackEventsContext, @@ -295,6 +346,7 @@ struct TrackEventsRequest { #[serde(untagged)] enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), + ThreadStarted(CodexThreadStartedEventRequest), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), PluginUsed(CodexPluginUsedEventRequest), @@ -322,6 +374,34 @@ struct SkillInvocationEventParams { model_slug: Option, } +#[derive(Serialize)] +struct CodexThreadStartedEventParams { + thread_id: String, + product_client_id: String, + model: String, + model_provider: String, + reasoning_effort: Option, + reasoning_summary: Option, + service_tier: String, + approval_policy: String, + approvals_reviewer: String, + sandbox_policy: &'static str, + sandbox_network_access: bool, + collaboration_mode: &'static str, + personality: Option, + ephemeral: bool, + session_source: SessionSource, + parent_thread_id: Option, + create_source: ThreadCreateSource, + created_at: u64, +} + +#[derive(Serialize)] +struct CodexThreadStartedEventRequest { + event_type: &'static str, + event_params: CodexThreadStartedEventParams, +} + #[derive(Serialize)] struct CodexAppMetadata { connector_id: Option, @@ -400,6 +480,21 @@ pub(crate) fn track_skill_invocations( queue.try_send(job); } +pub(crate) fn track_thread_started( + queue: &AnalyticsEventsQueue, + config: Arc, + thread_event: CodexThreadStartedEvent, +) { + if config.analytics_enabled == Some(false) { + return; + } + let job = TrackEventsJob::ThreadStarted(TrackThreadStartedJob { + config, + thread_event, + }); + queue.try_send(job); +} + pub(crate) fn track_app_mentioned( queue: &AnalyticsEventsQueue, config: Arc, @@ -536,6 +631,21 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil send_track_events(auth_manager, config, events).await; } +async fn send_track_thread_started(auth_manager: &AuthManager, job: TrackThreadStartedJob) { + let TrackThreadStartedJob { + config, + thread_event, + } = job; + let events = vec![TrackEventRequest::ThreadStarted( + CodexThreadStartedEventRequest { + event_type: "codex_thread_started", + event_params: codex_thread_started_event_params(thread_event), + }, + )]; + + send_track_events(auth_manager, config, events).await; +} + async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMentionedJob) { let TrackAppMentionedJob { config, @@ -635,6 +745,36 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code } } +fn codex_thread_started_event_params( + thread_event: CodexThreadStartedEvent, +) -> CodexThreadStartedEventParams { + CodexThreadStartedEventParams { + thread_id: thread_event.thread_id, + product_client_id: crate::default_client::originator().value, + model: thread_event.model, + model_provider: thread_event.model_provider, + reasoning_effort: thread_event.reasoning_effort.map(|value| value.to_string()), + reasoning_summary: thread_event + .reasoning_summary + .map(|value| value.to_string()), + service_tier: thread_event + .service_tier + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()), + approval_policy: thread_event.approval_policy.to_string(), + approvals_reviewer: thread_event.approvals_reviewer.to_string(), + sandbox_policy: sandbox_policy_mode(&thread_event.sandbox_policy), + sandbox_network_access: thread_event.sandbox_network_access, + collaboration_mode: collaboration_mode_mode(thread_event.collaboration_mode), + personality: thread_event.personality.map(|value| value.to_string()), + ephemeral: thread_event.ephemeral, + session_source: thread_event.session_source, + parent_thread_id: thread_event.parent_thread_id, + create_source: thread_event.create_source, + created_at: thread_event.created_at, + } +} + fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata { let capability_summary = plugin.capability_summary; CodexPluginMetadata { @@ -670,6 +810,24 @@ fn codex_plugin_used_metadata( } } +fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str { + match sandbox_policy { + SandboxPolicy::DangerFullAccess => "full_access", + SandboxPolicy::ReadOnly { .. } => "read_only", + SandboxPolicy::WorkspaceWrite { .. } => "workspace_write", + SandboxPolicy::ExternalSandbox { .. } => "external_sandbox", + } +} + +fn collaboration_mode_mode(mode: ModeKind) -> &'static str { + match mode { + ModeKind::Plan => "plan", + ModeKind::Default => "default", + ModeKind::PairProgramming => "pair_programming", + ModeKind::Execute => "execute", + } +} + async fn send_track_events( auth_manager: &AuthManager, config: Arc, diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/core/src/analytics_client_tests.rs index a038aae047..60266937f3 100644 --- a/codex-rs/core/src/analytics_client_tests.rs +++ b/codex-rs/core/src/analytics_client_tests.rs @@ -4,17 +4,30 @@ use super::CodexAppMentionedEventRequest; use super::CodexAppUsedEventRequest; use super::CodexPluginEventRequest; use super::CodexPluginUsedEventRequest; +use super::CodexThreadStartedEvent; +use super::CodexThreadStartedEventRequest; use super::InvocationType; +use super::ThreadCreateSource; use super::TrackEventRequest; use super::TrackEventsContext; use super::codex_app_metadata; use super::codex_plugin_metadata; use super::codex_plugin_used_metadata; +use super::codex_thread_started_event_params; use super::normalize_path_for_skill_id; use crate::plugins::AppConnectorId; use crate::plugins::PluginCapabilitySummary; use crate::plugins::PluginId; use crate::plugins::PluginTelemetryMetadata; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::ModeKind; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use codex_protocol::protocol::SessionSource; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -185,6 +198,61 @@ fn app_used_dedupe_is_keyed_by_turn_and_connector() { assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true); } +#[test] +fn thread_started_event_serializes_expected_shape() { + let event = TrackEventRequest::ThreadStarted(CodexThreadStartedEventRequest { + event_type: "codex_thread_started", + event_params: codex_thread_started_event_params(CodexThreadStartedEvent { + thread_id: "thread-0".to_string(), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + reasoning_effort: Some(ReasoningEffort::High), + reasoning_summary: Some(ReasoningSummary::Detailed), + service_tier: Some(ServiceTier::Flex), + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::GuardianSubagent, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_network_access: false, + collaboration_mode: ModeKind::Plan, + personality: Some(Personality::Friendly), + ephemeral: true, + session_source: SessionSource::Exec, + parent_thread_id: None, + create_source: ThreadCreateSource::ThreadStart, + created_at: 1_716_000_000, + }), + }); + + let payload = serde_json::to_value(&event).expect("serialize thread started event"); + + assert_eq!( + payload, + json!({ + "event_type": "codex_thread_started", + "event_params": { + "thread_id": "thread-0", + "product_client_id": crate::default_client::originator().value, + "model": "gpt-5", + "model_provider": "openai", + "reasoning_effort": "high", + "reasoning_summary": "detailed", + "service_tier": "flex", + "approval_policy": "on-request", + "approvals_reviewer": "guardian_subagent", + "sandbox_policy": "read_only", + "sandbox_network_access": false, + "collaboration_mode": "plan", + "personality": "friendly", + "ephemeral": true, + "session_source": "exec", + "parent_thread_id": null, + "create_source": "thread_start", + "created_at": 1716000000 + } + }) + ); +} + #[test] fn plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 5e428362f2..ed36649f8d 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -5,6 +5,8 @@ use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicU64; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; use crate::AuthManager; use crate::CodexAuth; @@ -14,7 +16,9 @@ use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::AppInvocation; +use crate::analytics_client::CodexThreadStartedEvent; use crate::analytics_client::InvocationType; +use crate::analytics_client::ThreadCreateSource; use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::auth_env_telemetry::collect_auth_env_telemetry; @@ -627,6 +631,13 @@ impl Codex { user_shell_override, }; + let thread_create_source = classify_thread_create_source( + &conversation_history, + &session_configuration.session_source, + ); + let thread_session_source = session_configuration.session_source.clone(); + let thread_started_configuration = session_configuration.clone(); + // Generate a unique ID for the lifetime of this Codex session. let session_source_clone = session_configuration.session_source.clone(); let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit); @@ -654,6 +665,46 @@ impl Codex { })?; let thread_id = session.conversation_id; + if let Some(create_source) = thread_create_source { + session + .services + .analytics_events_client + .track_thread_started(CodexThreadStartedEvent { + thread_id: thread_id.to_string(), + model: thread_started_configuration + .collaboration_mode + .model() + .to_string(), + model_provider: thread_started_configuration + .original_config_do_not_use + .model_provider_id + .clone(), + reasoning_effort: thread_started_configuration + .collaboration_mode + .reasoning_effort(), + reasoning_summary: thread_started_configuration.model_reasoning_summary, + service_tier: thread_started_configuration.service_tier, + approval_policy: thread_started_configuration.approval_policy.value(), + approvals_reviewer: thread_started_configuration.approvals_reviewer, + sandbox_policy: thread_started_configuration.sandbox_policy.get().clone(), + sandbox_network_access: thread_started_configuration + .network_sandbox_policy + .is_enabled(), + collaboration_mode: thread_started_configuration.collaboration_mode.mode, + personality: thread_started_configuration.personality, + ephemeral: thread_started_configuration + .original_config_do_not_use + .ephemeral, + parent_thread_id: session_source_parent_thread_id(&thread_session_source), + session_source: thread_session_source, + create_source, + created_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }); + } + // This task will run until Op::Shutdown is received. let session_for_loop = Arc::clone(&session); let session_loop_handle = tokio::spawn(async move { @@ -768,6 +819,33 @@ impl Codex { } } +fn classify_thread_create_source( + conversation_history: &InitialHistory, + session_source: &SessionSource, +) -> Option { + if matches!( + session_source, + SessionSource::SubAgent(SubAgentSource::Review) + ) { + return Some(ThreadCreateSource::DetachedReview); + } + + match conversation_history { + InitialHistory::New => Some(ThreadCreateSource::ThreadStart), + InitialHistory::Forked(_) => Some(ThreadCreateSource::ThreadFork), + InitialHistory::Resumed(_) => None, + } +} + +fn session_source_parent_thread_id(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id, .. + }) => Some(parent_thread_id.to_string()), + _ => None, + } +} + #[cfg(test)] pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination { futures::future::ready(()).boxed().shared() diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..d9a60c6075 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -130,6 +130,7 @@ mod stream_error_allows_next_turn; mod stream_no_completed; mod subagent_notifications; mod text_encoding_fix; +mod thread_metadata; mod tool_harness; mod tool_parallelism; mod tools; diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index 78df34652a..5448e49161 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -365,13 +365,22 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let deadline = Instant::now() + Duration::from_secs(10); - let analytics_request = loop { + let plugin_event = loop { let requests = server.received_requests().await.unwrap_or_default(); - if let Some(request) = requests + if let Some(event) = requests .into_iter() - .find(|request| request.url.path() == "/codex/analytics-events/events") + .filter(|request| request.url.path() == "/codex/analytics-events/events") + .find_map(|request| { + let payload: serde_json::Value = serde_json::from_slice(&request.body).ok()?; + payload["events"].as_array().and_then(|events| { + events + .iter() + .find(|event| event["event_type"] == "codex_plugin_used") + .cloned() + }) + }) { - break request; + break event; } if Instant::now() >= deadline { panic!("timed out waiting for plugin analytics request"); @@ -379,10 +388,7 @@ async fn explicit_plugin_mentions_track_plugin_used_analytics() -> Result<()> { tokio::time::sleep(Duration::from_millis(50)).await; }; - let payload: serde_json::Value = - serde_json::from_slice(&analytics_request.body).expect("analytics payload"); - let event = &payload["events"][0]; - assert_eq!(event["event_type"], "codex_plugin_used"); + let event = plugin_event; assert_eq!(event["event_params"]["plugin_id"], "sample@test"); assert_eq!(event["event_params"]["plugin_name"], "sample"); assert_eq!(event["event_params"]["marketplace_name"], "test"); diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/thread_metadata.rs new file mode 100644 index 0000000000..874a486bf2 --- /dev/null +++ b/codex-rs/core/tests/suite/thread_metadata.rs @@ -0,0 +1,93 @@ +use anyhow::Result; +use codex_core::CodexAuth; +use codex_protocol::config_types::ApprovalsReviewer; +use codex_protocol::config_types::Personality; +use codex_protocol::config_types::ReasoningSummary; +use codex_protocol::config_types::ServiceTier; +use codex_protocol::openai_models::ReasoningEffort; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::test_codex; +use std::time::Duration; +use std::time::Instant; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn thread_start_tracks_thread_started_analytics() -> Result<()> { + let server = start_mock_server().await; + let chatgpt_base_url = server.uri(); + + let test = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.chatgpt_base_url = chatgpt_base_url; + config.model = Some("gpt-5".to_string()); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_reasoning_summary = Some(ReasoningSummary::Detailed); + config.service_tier = Some(ServiceTier::Flex); + config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.personality = Some(Personality::Friendly); + config.ephemeral = true; + }) + .build(&server) + .await?; + + let deadline = Instant::now() + Duration::from_secs(10); + let analytics_request = loop { + let requests = server.received_requests().await.unwrap_or_default(); + if let Some(request) = requests + .into_iter() + .find(|request| request.url.path() == "/codex/analytics-events/events") + { + break request; + } + if Instant::now() >= deadline { + panic!("timed out waiting for thread analytics request"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + }; + + let payload: serde_json::Value = + serde_json::from_slice(&analytics_request.body).expect("analytics payload"); + let event = payload["events"] + .as_array() + .expect("events array") + .iter() + .find(|event| event["event_type"] == "codex_thread_started") + .expect("codex_thread_started event should be present"); + + assert_eq!( + event["event_params"]["thread_id"], + serde_json::json!(test.session_configured.session_id.to_string()) + ); + assert_eq!( + event["event_params"]["product_client_id"], + serde_json::json!(codex_core::default_client::originator().value) + ); + assert_eq!(event["event_params"]["model"], "gpt-5"); + assert_eq!(event["event_params"]["model_provider"], "openai"); + assert_eq!(event["event_params"]["reasoning_effort"], "high"); + assert_eq!(event["event_params"]["reasoning_summary"], "detailed"); + assert_eq!(event["event_params"]["service_tier"], "flex"); + assert_eq!(event["event_params"]["approval_policy"], "on-request"); + assert_eq!( + event["event_params"]["approvals_reviewer"], + "guardian_subagent" + ); + assert_eq!(event["event_params"]["sandbox_policy"], "workspace_write"); + assert_eq!(event["event_params"]["sandbox_network_access"], false); + assert_eq!(event["event_params"]["collaboration_mode"], "default"); + assert_eq!(event["event_params"]["personality"], "friendly"); + assert_eq!(event["event_params"]["ephemeral"], true); + assert_eq!(event["event_params"]["session_source"], "exec"); + assert_eq!( + event["event_params"]["parent_thread_id"], + serde_json::Value::Null + ); + assert_eq!(event["event_params"]["create_source"], "thread_start"); + assert!( + event["event_params"]["created_at"] + .as_u64() + .is_some_and(|timestamp| timestamp > 0) + ); + + Ok(()) +} From ed9e59ceb03b50980ade7aa522e202f4b0cc1519 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Tue, 24 Mar 2026 15:26:32 -0700 Subject: [PATCH 05/50] restructure history and source --- codex-rs/core/src/analytics_client.rs | 44 ++++++++++++++++---- codex-rs/core/src/analytics_client_tests.rs | 40 ++++++++++++++++-- codex-rs/core/src/codex.rs | 39 ++++++++--------- codex-rs/core/tests/suite/thread_metadata.rs | 6 ++- 4 files changed, 95 insertions(+), 34 deletions(-) diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index 287838b4cf..a6b2d20fc2 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -14,6 +14,7 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SkillScope; +use codex_protocol::protocol::SubAgentSource; use serde::Serialize; use sha1::Digest; use sha1::Sha1; @@ -48,17 +49,18 @@ pub(crate) struct CodexThreadStartedEvent { pub(crate) personality: Option, pub(crate) ephemeral: bool, pub(crate) session_source: SessionSource, + pub(crate) initial_history_type: InitialHistoryType, + pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, - pub(crate) create_source: ThreadCreateSource, pub(crate) created_at: u64, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum ThreadCreateSource { - ThreadStart, - ThreadFork, - DetachedReview, +pub(crate) enum InitialHistoryType { + New, + Forked, + Resumed, } pub(crate) fn build_track_events_context( @@ -390,9 +392,10 @@ struct CodexThreadStartedEventParams { collaboration_mode: &'static str, personality: Option, ephemeral: bool, - session_source: SessionSource, + session_source: String, + initial_history_type: InitialHistoryType, + subagent_source: Option, parent_thread_id: Option, - create_source: ThreadCreateSource, created_at: u64, } @@ -768,9 +771,10 @@ fn codex_thread_started_event_params( collaboration_mode: collaboration_mode_mode(thread_event.collaboration_mode), personality: thread_event.personality.map(|value| value.to_string()), ephemeral: thread_event.ephemeral, - session_source: thread_event.session_source, + session_source: session_source_name(&thread_event.session_source), + initial_history_type: thread_event.initial_history_type, + subagent_source: thread_event.subagent_source.map(subagent_source_name), parent_thread_id: thread_event.parent_thread_id, - create_source: thread_event.create_source, created_at: thread_event.created_at, } } @@ -828,6 +832,28 @@ fn collaboration_mode_mode(mode: ModeKind) -> &'static str { } } +fn session_source_name(session_source: &SessionSource) -> String { + match session_source { + SessionSource::Cli => "cli".to_string(), + SessionSource::VSCode => "vscode".to_string(), + SessionSource::Exec => "exec".to_string(), + SessionSource::Mcp => "mcp".to_string(), + SessionSource::Custom(source) => source.clone(), + SessionSource::SubAgent(_) => "subagent".to_string(), + SessionSource::Unknown => "unknown".to_string(), + } +} + +fn subagent_source_name(subagent_source: SubAgentSource) -> String { + match subagent_source { + SubAgentSource::Review => "review".to_string(), + SubAgentSource::Compact => "compact".to_string(), + SubAgentSource::ThreadSpawn { .. } => "thread_spawn".to_string(), + SubAgentSource::MemoryConsolidation => "memory_consolidation".to_string(), + SubAgentSource::Other(other) => other, + } +} + async fn send_track_events( auth_manager: &AuthManager, config: Arc, diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/core/src/analytics_client_tests.rs index 60266937f3..587f12a726 100644 --- a/codex-rs/core/src/analytics_client_tests.rs +++ b/codex-rs/core/src/analytics_client_tests.rs @@ -6,8 +6,8 @@ use super::CodexPluginEventRequest; use super::CodexPluginUsedEventRequest; use super::CodexThreadStartedEvent; use super::CodexThreadStartedEventRequest; +use super::InitialHistoryType; use super::InvocationType; -use super::ThreadCreateSource; use super::TrackEventRequest; use super::TrackEventsContext; use super::codex_app_metadata; @@ -28,6 +28,7 @@ use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::SessionSource; +use codex_protocol::protocol::SubAgentSource; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -217,8 +218,9 @@ fn thread_started_event_serializes_expected_shape() { personality: Some(Personality::Friendly), ephemeral: true, session_source: SessionSource::Exec, + initial_history_type: InitialHistoryType::New, + subagent_source: None, parent_thread_id: None, - create_source: ThreadCreateSource::ThreadStart, created_at: 1_716_000_000, }), }); @@ -245,14 +247,46 @@ fn thread_started_event_serializes_expected_shape() { "personality": "friendly", "ephemeral": true, "session_source": "exec", + "initial_history_type": "new", + "subagent_source": null, "parent_thread_id": null, - "create_source": "thread_start", "created_at": 1716000000 } }) ); } +#[test] +fn thread_started_event_serializes_subagent_source() { + let event = TrackEventRequest::ThreadStarted(CodexThreadStartedEventRequest { + event_type: "codex_thread_started", + event_params: codex_thread_started_event_params(CodexThreadStartedEvent { + thread_id: "thread-1".to_string(), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_network_access: false, + collaboration_mode: ModeKind::Default, + personality: None, + ephemeral: false, + session_source: SessionSource::SubAgent(SubAgentSource::Review), + initial_history_type: InitialHistoryType::New, + subagent_source: Some(SubAgentSource::Review), + parent_thread_id: None, + created_at: 1, + }), + }); + + let payload = serde_json::to_value(&event).expect("serialize subagent thread started event"); + assert_eq!(payload["event_params"]["session_source"], "subagent"); + assert_eq!(payload["event_params"]["subagent_source"], "review"); +} + #[test] fn plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index a7696f2052..4a3b752ae5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -17,8 +17,8 @@ use crate::agent::agent_status_from_event; use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::AppInvocation; use crate::analytics_client::CodexThreadStartedEvent; +use crate::analytics_client::InitialHistoryType; use crate::analytics_client::InvocationType; -use crate::analytics_client::ThreadCreateSource; use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::auth_env_telemetry::collect_auth_env_telemetry; @@ -631,10 +631,9 @@ impl Codex { user_shell_override, }; - let thread_create_source = classify_thread_create_source( - &conversation_history, - &session_configuration.session_source, - ); + let should_emit_thread_started = + !matches!(conversation_history, InitialHistory::Resumed(_)); + let initial_history_type = initial_history_type(&conversation_history); let thread_session_source = session_configuration.session_source.clone(); let thread_started_configuration = session_configuration.clone(); @@ -665,7 +664,7 @@ impl Codex { })?; let thread_id = session.conversation_id; - if let Some(create_source) = thread_create_source { + if should_emit_thread_started { session .services .analytics_events_client @@ -695,9 +694,10 @@ impl Codex { ephemeral: thread_started_configuration .original_config_do_not_use .ephemeral, + initial_history_type, + subagent_source: session_source_subagent_source(&thread_session_source), parent_thread_id: session_source_parent_thread_id(&thread_session_source), session_source: thread_session_source, - create_source, created_at: SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default() @@ -819,21 +819,18 @@ impl Codex { } } -fn classify_thread_create_source( - conversation_history: &InitialHistory, - session_source: &SessionSource, -) -> Option { - if matches!( - session_source, - SessionSource::SubAgent(SubAgentSource::Review) - ) { - return Some(ThreadCreateSource::DetachedReview); - } - +fn initial_history_type(conversation_history: &InitialHistory) -> InitialHistoryType { match conversation_history { - InitialHistory::New => Some(ThreadCreateSource::ThreadStart), - InitialHistory::Forked(_) => Some(ThreadCreateSource::ThreadFork), - InitialHistory::Resumed(_) => None, + InitialHistory::New => InitialHistoryType::New, + InitialHistory::Forked(_) => InitialHistoryType::Forked, + InitialHistory::Resumed(_) => InitialHistoryType::Resumed, + } +} + +fn session_source_subagent_source(session_source: &SessionSource) -> Option { + match session_source { + SessionSource::SubAgent(subagent_source) => Some(subagent_source.clone()), + _ => None, } } diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/thread_metadata.rs index 874a486bf2..94ae3b6241 100644 --- a/codex-rs/core/tests/suite/thread_metadata.rs +++ b/codex-rs/core/tests/suite/thread_metadata.rs @@ -78,11 +78,15 @@ async fn thread_start_tracks_thread_started_analytics() -> Result<()> { assert_eq!(event["event_params"]["personality"], "friendly"); assert_eq!(event["event_params"]["ephemeral"], true); assert_eq!(event["event_params"]["session_source"], "exec"); + assert_eq!(event["event_params"]["initial_history_type"], "new"); + assert_eq!( + event["event_params"]["subagent_source"], + serde_json::Value::Null + ); assert_eq!( event["event_params"]["parent_thread_id"], serde_json::Value::Null ); - assert_eq!(event["event_params"]["create_source"], "thread_start"); assert!( event["event_params"]["created_at"] .as_u64() From 0b619afc87ae51df852e61ab9f10c3403d3288fc Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Tue, 24 Mar 2026 15:42:45 -0700 Subject: [PATCH 06/50] Drop sandbox_permissions from sandbox exec requests (#15665) ## Summary - drop `sandbox_permissions` from the sandboxing `ExecOptions` and `ExecRequest` adapter types - remove the now-unused plumbing from shell, unified exec, JS REPL, and apply-patch runtime call sites - default reconstructed `ExecParams` to `SandboxPermissions::UseDefault` where the lower-level API still requires the field ## Testing - `just fmt` - `just argument-comment-lint` - `cargo test -p codex-core` (still running locally; first failures observed in `suite::cli_stream::responses_mode_stream_cli`, `suite::cli_stream::responses_mode_stream_cli_supports_openai_base_url_config_override`, and `suite::cli_stream::responses_mode_stream_cli_supports_openai_base_url_env_fallback`) --- codex-rs/app-server/src/command_exec.rs | 4 ---- codex-rs/core/src/exec.rs | 12 ++++-------- codex-rs/core/src/sandboxing/mod.rs | 8 -------- codex-rs/core/src/tasks/user_shell.rs | 3 --- .../core/src/tools/handlers/apply_patch.rs | 3 --- codex-rs/core/src/tools/js_repl/mod.rs | 3 --- .../core/src/tools/runtimes/apply_patch.rs | 4 ---- .../src/tools/runtimes/apply_patch_tests.rs | 1 - codex-rs/core/src/tools/runtimes/shell.rs | 2 -- .../tools/runtimes/shell/unix_escalation.rs | 19 ------------------- .../runtimes/shell/unix_escalation_tests.rs | 6 ------ .../core/src/tools/runtimes/unified_exec.rs | 4 ---- 12 files changed, 4 insertions(+), 65 deletions(-) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 5bb9a2719c..8f92cd0ba2 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -744,11 +744,9 @@ mod tests { sandbox: SandboxType::WindowsRestrictedToken, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, - sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - justification: None, arg0: None, } } @@ -858,11 +856,9 @@ mod tests { sandbox: SandboxType::None, windows_sandbox_level: WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, - sandbox_permissions: codex_core::sandboxing::SandboxPermissions::UseDefault, sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - justification: None, arg0: None, }, started_network_proxy: None, diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 7f4664858a..0229e1dd57 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -241,10 +241,10 @@ pub fn build_exec_request( expiration, capture_policy, network, - sandbox_permissions, + sandbox_permissions: _, windows_sandbox_level, windows_sandbox_private_desktop, - justification, + justification: _, arg0: _, } = params; if let Some(network) = network.as_ref() { @@ -268,8 +268,6 @@ pub fn build_exec_request( let options = ExecOptions { expiration, capture_policy, - sandbox_permissions, - justification, }; let exec_req = manager .transform(SandboxTransformRequest { @@ -309,11 +307,9 @@ pub(crate) async fn execute_exec_request( sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - sandbox_permissions, sandbox_policy: _sandbox_policy_from_env, file_system_sandbox_policy, network_sandbox_policy, - justification, arg0, } = exec_request; let _ = _sandbox_policy_from_env; @@ -325,10 +321,10 @@ pub(crate) async fn execute_exec_request( capture_policy, env, network: network.clone(), - sandbox_permissions, + sandbox_permissions: SandboxPermissions::UseDefault, windows_sandbox_level, windows_sandbox_private_desktop, - justification, + justification: None, arg0, }; diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 2b2f3c2364..81f2a0af2c 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -30,8 +30,6 @@ use std::path::PathBuf; pub(crate) struct ExecOptions { pub(crate) expiration: ExecExpiration, pub(crate) capture_policy: ExecCapturePolicy, - pub(crate) sandbox_permissions: SandboxPermissions, - pub(crate) justification: Option, } #[derive(Debug)] @@ -45,11 +43,9 @@ pub struct ExecRequest { pub sandbox: SandboxType, pub windows_sandbox_level: WindowsSandboxLevel, pub windows_sandbox_private_desktop: bool, - pub sandbox_permissions: SandboxPermissions, pub sandbox_policy: SandboxPolicy, pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, - pub justification: Option, pub arg0: Option, } @@ -74,8 +70,6 @@ impl ExecRequest { let ExecOptions { expiration, capture_policy, - sandbox_permissions, - justification, } = options; if !network_sandbox_policy.is_enabled() { env.insert( @@ -97,11 +91,9 @@ impl ExecRequest { sandbox, windows_sandbox_level, windows_sandbox_private_desktop, - sandbox_permissions, sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - justification, arg0, } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 33a7c682b4..682902f2d6 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -25,7 +25,6 @@ use crate::protocol::ExecCommandStatus; use crate::protocol::SandboxPolicy; use crate::protocol::TurnStartedEvent; use crate::sandboxing::ExecRequest; -use crate::sandboxing::SandboxPermissions; use crate::state::TaskKind; use crate::tools::format_exec_output_str; use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot; @@ -173,11 +172,9 @@ pub(crate) async fn execute_user_shell_command( .config .permissions .windows_sandbox_private_desktop, - sandbox_permissions: SandboxPermissions::UseDefault, sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - justification: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index bbe3572c26..62ebde48b8 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -200,8 +200,6 @@ impl ToolHandler for ApplyPatchHandler { file_paths, changes, exec_approval_requirement: apply.exec_approval_requirement, - sandbox_permissions: effective_additional_permissions - .sandbox_permissions, additional_permissions: effective_additional_permissions .additional_permissions, permissions_preapproved: effective_additional_permissions @@ -305,7 +303,6 @@ pub(crate) async fn intercept_apply_patch( file_paths: approval_keys, changes, exec_approval_requirement: apply.exec_approval_requirement, - sandbox_permissions: effective_additional_permissions.sandbox_permissions, additional_permissions: effective_additional_permissions .additional_permissions, permissions_preapproved: effective_additional_permissions diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 73c0b34ead..27d8cb9e65 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -40,7 +40,6 @@ use crate::exec_env::create_env; use crate::function_tool::FunctionCallError; use crate::original_image_detail::normalize_output_image_detail; use crate::sandboxing::ExecOptions; -use crate::sandboxing::SandboxPermissions; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; use crate::truncate::TruncationPolicy; @@ -1058,8 +1057,6 @@ impl JsReplManager { let options = ExecOptions { expiration: ExecExpiration::DefaultTimeout, capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, }; let exec_env = sandbox .transform(SandboxTransformRequest { diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index d1a6efe001..7f5686e47d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -10,7 +10,6 @@ use crate::guardian::GuardianApprovalRequest; use crate::guardian::review_approval_request; use crate::guardian::routes_approval_to_guardian; use crate::sandboxing::ExecOptions; -use crate::sandboxing::SandboxPermissions; use crate::sandboxing::execute_env; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -40,7 +39,6 @@ pub struct ApplyPatchRequest { pub file_paths: Vec, pub changes: std::collections::HashMap, pub exec_approval_requirement: ExecApprovalRequirement, - pub sandbox_permissions: SandboxPermissions, pub additional_permissions: Option, pub permissions_preapproved: bool, pub timeout_ms: Option, @@ -206,8 +204,6 @@ impl ToolRuntime for ApplyPatchRuntime { let options = ExecOptions { expiration: req.timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: req.sandbox_permissions, - justification: None, }; let env = attempt .env_for(command, options, /*network*/ None) 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 d2812b5ecf..79e35a9c2d 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -48,7 +48,6 @@ fn guardian_review_request_includes_patch_context() { reason: None, proposed_execpolicy_amendment: None, }, - sandbox_permissions: SandboxPermissions::UseDefault, additional_permissions: None, permissions_preapproved: false, timeout_ms: None, diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index 6582557f85..ecb5822124 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -254,8 +254,6 @@ impl ToolRuntime for ShellRuntime { let options = ExecOptions { expiration: req.timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: req.sandbox_permissions, - justification: req.justification.clone(), }; let env = attempt .env_for(command, options, req.network.as_ref()) diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 0131616c34..8c4f24ed05 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -120,8 +120,6 @@ pub(super) async fn try_run_zsh_fork( let options = ExecOptions { expiration: req.timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: req.sandbox_permissions, - justification: req.justification.clone(), }; let sandbox_exec_request = attempt .env_for(command, options, req.network.as_ref()) @@ -136,11 +134,9 @@ pub(super) async fn try_run_zsh_fork( sandbox, windows_sandbox_level, windows_sandbox_private_desktop: _windows_sandbox_private_desktop, - sandbox_permissions, sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, - justification, arg0, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; @@ -161,8 +157,6 @@ pub(super) async fn try_run_zsh_fork( env: sandbox_env, network: sandbox_network, windows_sandbox_level, - sandbox_permissions, - justification, arg0, sandbox_policy_cwd: ctx.turn.cwd.clone(), macos_seatbelt_profile_extensions: ctx @@ -267,8 +261,6 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( env: exec_request.env.clone(), network: exec_request.network.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, - sandbox_permissions: exec_request.sandbox_permissions, - justification: exec_request.justification.clone(), arg0: exec_request.arg0.clone(), sandbox_policy_cwd: ctx.turn.cwd.clone(), macos_seatbelt_profile_extensions: ctx @@ -864,8 +856,6 @@ struct CoreShellCommandExecutor { env: HashMap, network: Option, windows_sandbox_level: WindowsSandboxLevel, - sandbox_permissions: SandboxPermissions, - justification: Option, arg0: Option, sandbox_policy_cwd: PathBuf, #[cfg_attr(not(target_os = "macos"), allow(dead_code))] @@ -916,11 +906,9 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { sandbox: self.sandbox, windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: false, - sandbox_permissions: self.sandbox_permissions, sandbox_policy: self.sandbox_policy.clone(), file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), network_sandbox_policy: self.network_sandbox_policy, - justification: self.justification.clone(), arg0: self.arg0.clone(), }, /*stdout_stream*/ None, @@ -1044,11 +1032,6 @@ impl CoreShellCommandExecutor { self.windows_sandbox_level, self.network.is_some(), ); - let sandbox_permissions = if additional_permissions.is_some() { - SandboxPermissions::WithAdditionalPermissions - } else { - SandboxPermissions::UseDefault - }; let command = SandboxCommand { program: program.clone(), args: args.to_vec(), @@ -1059,8 +1042,6 @@ impl CoreShellCommandExecutor { let options = ExecOptions { expiration: ExecExpiration::DefaultTimeout, capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions, - justification: self.justification.clone(), }; let exec_request = sandbox_manager.transform(SandboxTransformRequest { command, diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 6373697c3b..25cbb71c02 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -660,8 +660,6 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions file_system_sandbox_policy: read_only_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::Restricted, windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, arg0: None, sandbox_policy_cwd: cwd.to_path_buf(), macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { @@ -712,8 +710,6 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions() file_system_sandbox_policy: unrestricted_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::Enabled, windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, arg0: None, sandbox_policy_cwd: cwd.to_path_buf(), macos_seatbelt_profile_extensions: None, @@ -787,8 +783,6 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac file_system_sandbox_policy: read_only_file_system_sandbox_policy(), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), windows_sandbox_level: WindowsSandboxLevel::Disabled, - sandbox_permissions: SandboxPermissions::UseDefault, - justification: None, arg0: None, sandbox_policy_cwd: cwd.to_path_buf(), macos_seatbelt_profile_extensions: Some(MacOsSeatbeltProfileExtensions { diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index f59d38556b..3f8ab5f6ce 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -218,8 +218,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt let options = ExecOptions { expiration: ExecExpiration::DefaultTimeout, capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: req.sandbox_permissions, - justification: req.justification.clone(), }; let exec_env = attempt .env_for(command, options, req.network.as_ref()) @@ -265,8 +263,6 @@ impl<'a> ToolRuntime for UnifiedExecRunt let options = ExecOptions { expiration: ExecExpiration::DefaultTimeout, capture_policy: ExecCapturePolicy::ShellTool, - sandbox_permissions: req.sandbox_permissions, - justification: req.justification.clone(), }; let exec_env = attempt .env_for(command, options, req.network.as_ref()) From 062fa7a2bb96b7b6f5fd0bc1edb7c48d75024114 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 24 Mar 2026 15:45:40 -0700 Subject: [PATCH 07/50] Move string truncation helpers into codex-utils-string (#15572) - move the shared byte-based middle truncation logic from `core` into `codex-utils-string` - keep token-specific truncation in `codex-core` so rollout can reuse the shared helper in the next stacked PR --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 11 + codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 4 +- .../src/codex/rollout_reconstruction_tests.rs | 16 +- codex-rs/core/src/codex_tests.rs | 2 +- codex-rs/core/src/compact.rs | 6 +- codex-rs/core/src/context_manager/history.rs | 12 +- .../core/src/context_manager/history_tests.rs | 6 +- codex-rs/core/src/error.rs | 4 +- codex-rs/core/src/guardian/prompt.rs | 6 +- codex-rs/core/src/lib.rs | 1 - codex-rs/core/src/memories/prompts.rs | 4 +- .../core/src/models_manager/model_info.rs | 2 +- codex-rs/core/src/realtime_context.rs | 4 +- codex-rs/core/src/rollout/list.rs | 2 +- codex-rs/core/src/rollout/recorder.rs | 8 +- codex-rs/core/src/rollout/recorder_tests.rs | 2 +- codex-rs/core/src/rollout/tests.rs | 2 +- codex-rs/core/src/state/session.rs | 2 +- codex-rs/core/src/tools/code_mode/mod.rs | 6 +- codex-rs/core/src/tools/context.rs | 4 +- codex-rs/core/src/tools/js_repl/mod.rs | 4 +- codex-rs/core/src/tools/mod.rs | 6 +- codex-rs/core/src/truncate.rs | 363 ------------------ codex-rs/core/src/unified_exec/process.rs | 4 +- .../core/src/unified_exec/process_manager.rs | 2 +- codex-rs/protocol/Cargo.toml | 1 + codex-rs/protocol/src/protocol.rs | 46 +++ codex-rs/utils/output-truncation/BUILD.bazel | 6 + codex-rs/utils/output-truncation/Cargo.toml | 15 + codex-rs/utils/output-truncation/src/lib.rs | 142 +++++++ .../output-truncation}/src/truncate_tests.rs | 106 ++--- codex-rs/utils/string/src/lib.rs | 9 + codex-rs/utils/string/src/truncate.rs | 156 ++++++++ codex-rs/utils/string/src/truncate/tests.rs | 71 ++++ 36 files changed, 551 insertions(+), 487 deletions(-) delete mode 100644 codex-rs/core/src/truncate.rs create mode 100644 codex-rs/utils/output-truncation/BUILD.bazel create mode 100644 codex-rs/utils/output-truncation/Cargo.toml create mode 100644 codex-rs/utils/output-truncation/src/lib.rs rename codex-rs/{core => utils/output-truncation}/src/truncate_tests.rs (77%) create mode 100644 codex-rs/utils/string/src/truncate.rs create mode 100644 codex-rs/utils/string/src/truncate/tests.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 935fc413a5..697b8f5d32 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1902,6 +1902,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-home-dir", "codex-utils-image", + "codex-utils-output-truncation", "codex-utils-pty", "codex-utils-readiness", "codex-utils-stream-parser", @@ -2399,6 +2400,7 @@ dependencies = [ "codex-git-utils", "codex-utils-absolute-path", "codex-utils-image", + "codex-utils-string", "icu_decimal", "icu_locale_core", "icu_provider", @@ -2891,6 +2893,15 @@ dependencies = [ "codex-ollama", ] +[[package]] +name = "codex-utils-output-truncation" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "codex-utils-string", + "pretty_assertions", +] + [[package]] name = "codex-utils-pty" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index e071ec807e..ebd88c945f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -65,6 +65,7 @@ members = [ "utils/sleep-inhibitor", "utils/approval-presets", "utils/oss", + "utils/output-truncation", "utils/fuzzy-match", "utils/stream-parser", "codex-client", @@ -154,6 +155,7 @@ codex-utils-home-dir = { path = "utils/home-dir" } codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-oss = { path = "utils/oss" } +codex-utils-output-truncation = { path = "utils/output-truncation" } codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 82ea260055..51dd305b35 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -55,6 +55,7 @@ codex-utils-absolute-path = { workspace = true } codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } +codex-utils-output-truncation = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index b299bec369..2f8c2877f3 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -46,7 +46,6 @@ use crate::stream_events_utils::handle_output_item_done; use crate::stream_events_utils::last_assistant_message_from_item; use crate::stream_events_utils::raw_assistant_output_text_from_item; use crate::stream_events_utils::record_completed_response_item; -use crate::truncate::TruncationPolicy; use crate::turn_metadata::TurnMetadataState; use crate::util::error_or_panic; use async_channel::Receiver; @@ -117,6 +116,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationResponse; use codex_rmcp_client::OAuthCredentialsStoreMode; use codex_terminal_detection::user_agent; +use codex_utils_output_truncation::TruncationPolicy; use codex_utils_stream_parser::AssistantTextChunk; use codex_utils_stream_parser::AssistantTextStreamParser; use codex_utils_stream_parser::ProposedPlanSegment; @@ -1007,7 +1007,7 @@ impl TurnContext { user_instructions: self.user_instructions.clone(), developer_instructions: self.developer_instructions.clone(), final_output_json_schema: self.final_output_json_schema.clone(), - truncation_policy: Some(self.truncation_policy.into()), + truncation_policy: Some(self.truncation_policy), } } diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 906c72c92a..1ed668d56f 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -77,7 +77,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)]; @@ -116,7 +116,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let turn_id = previous_context_item .turn_id @@ -866,7 +866,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id @@ -938,7 +938,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), })) .expect("serialize expected reference context item") ); @@ -967,7 +967,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id @@ -1073,7 +1073,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let rollout_items = vec![ @@ -1175,7 +1175,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id @@ -1319,7 +1319,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let previous_turn_id = previous_context_item .turn_id diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 8846f0a643..0ba0a1beb0 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1287,7 +1287,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { user_instructions: None, developer_instructions: None, final_output_json_schema: None, - truncation_policy: Some(turn_context.truncation_policy.into()), + truncation_policy: Some(turn_context.truncation_policy), }; let turn_id = previous_context_item .turn_id diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 7686c5b65c..82fd5c15e3 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -15,9 +15,6 @@ use crate::protocol::CompactedItem; use crate::protocol::EventMsg; use crate::protocol::TurnStartedEvent; use crate::protocol::WarningEvent; -use crate::truncate::TruncationPolicy; -use crate::truncate::approx_token_count; -use crate::truncate::truncate_text; use crate::util::backoff; use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; @@ -25,6 +22,9 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseInputItem; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::truncate_text; use futures::prelude::*; use tracing::error; diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index b518d8ce4f..ec2df30d3b 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -3,12 +3,6 @@ use crate::context_manager::normalize; use crate::event_mapping::has_non_contextual_dev_message_content; use crate::event_mapping::is_contextual_dev_message_content; use crate::event_mapping::is_contextual_user_message_content; -use crate::truncate::TruncationPolicy; -use crate::truncate::approx_bytes_for_tokens; -use crate::truncate::approx_token_count; -use crate::truncate::approx_tokens_from_byte_count_i64; -use crate::truncate::truncate_function_output_items_with_policy; -use crate::truncate::truncate_text; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_protocol::models::BaseInstructions; @@ -25,6 +19,12 @@ use codex_protocol::protocol::TokenUsageInfo; use codex_protocol::protocol::TurnContextItem; use codex_utils_cache::BlockingLruCache; use codex_utils_cache::sha1_digest; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::approx_bytes_for_tokens; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::approx_tokens_from_byte_count_i64; +use codex_utils_output_truncation::truncate_function_output_items_with_policy; +use codex_utils_output_truncation::truncate_text; use std::num::NonZeroUsize; use std::ops::Deref; use std::sync::LazyLock; diff --git a/codex-rs/core/src/context_manager/history_tests.rs b/codex-rs/core/src/context_manager/history_tests.rs index 0c92edcfb6..3c508e05ff 100644 --- a/codex-rs/core/src/context_manager/history_tests.rs +++ b/codex-rs/core/src/context_manager/history_tests.rs @@ -1,6 +1,4 @@ use super::*; -use crate::truncate; -use crate::truncate::TruncationPolicy; use base64::Engine; use base64::engine::general_purpose::STANDARD as BASE64_STANDARD; use codex_git_utils::GhostCommit; @@ -23,6 +21,8 @@ use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use image::ImageBuffer; use image::ImageFormat; use image::Rgba; @@ -179,7 +179,7 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem { } fn truncate_exec_output(content: &str) -> String { - truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS)) + truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS)) } fn approx_token_count_for_text(text: &str) -> i64 { diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index b84180d160..b0c55a55c6 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -2,8 +2,6 @@ use crate::exec::ExecToolCallOutput; use crate::network_policy_decision::NetworkPolicyDecisionPayload; use crate::token_data::KnownPlan; use crate::token_data::PlanType; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use chrono::DateTime; use chrono::Datelike; use chrono::Local; @@ -15,6 +13,8 @@ use codex_protocol::ThreadId; use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::RateLimitSnapshot; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use reqwest::StatusCode; use serde_json; use std::io; diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index be02916487..5741a65fc0 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -7,9 +7,9 @@ use serde_json::Value; use crate::codex::Session; use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; -use crate::truncate::approx_bytes_for_tokens; -use crate::truncate::approx_token_count; -use crate::truncate::approx_tokens_from_byte_count; +use codex_utils_output_truncation::approx_bytes_for_tokens; +use codex_utils_output_truncation::approx_token_count; +use codex_utils_output_truncation::approx_tokens_from_byte_count; use super::GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS; use super::GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index decebc84e0..d4a8ae93aa 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -78,7 +78,6 @@ mod stream_events_utils; pub mod test_support; mod text_encoding; pub use codex_login::token_data; -mod truncate; mod unified_exec; pub mod windows_sandbox; pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER; diff --git a/codex-rs/core/src/memories/prompts.rs b/codex-rs/core/src/memories/prompts.rs index 1659e1c1c7..851179426f 100644 --- a/codex-rs/core/src/memories/prompts.rs +++ b/codex-rs/core/src/memories/prompts.rs @@ -1,13 +1,13 @@ use crate::memories::memory_root; use crate::memories::phase_one; use crate::memories::storage::rollout_summary_file_stem_from_parts; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use askama::Template; use codex_protocol::openai_models::ModelInfo; use codex_state::Phase2InputSelection; use codex_state::Stage1Output; use codex_state::Stage1OutputRef; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use std::path::Path; use tokio::fs; use tracing::warn; diff --git a/codex-rs/core/src/models_manager/model_info.rs b/codex-rs/core/src/models_manager/model_info.rs index 7d3c6e9d10..055a6459d4 100644 --- a/codex-rs/core/src/models_manager/model_info.rs +++ b/codex-rs/core/src/models_manager/model_info.rs @@ -10,8 +10,8 @@ use codex_protocol::openai_models::WebSearchToolType; use codex_protocol::openai_models::default_input_modalities; use crate::config::Config; -use crate::truncate::approx_bytes_for_tokens; use codex_features::Feature; +use codex_utils_output_truncation::approx_bytes_for_tokens; use tracing::warn; pub const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md"); diff --git a/codex-rs/core/src/realtime_context.rs b/codex-rs/core/src/realtime_context.rs index bcb24c5673..1b845db1e9 100644 --- a/codex-rs/core/src/realtime_context.rs +++ b/codex-rs/core/src/realtime_context.rs @@ -1,13 +1,13 @@ use crate::codex::Session; use crate::compact::content_items_to_text; use crate::event_mapping::is_contextual_user_message_content; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use chrono::Utc; use codex_git_utils::resolve_root_git_project_for_trust; use codex_protocol::models::ResponseItem; use codex_state::SortKey; use codex_state::ThreadMetadata; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; use dirs::home_dir; use std::cmp::Reverse; use std::collections::HashMap; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index bf32e23c8d..6ae5908d8a 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use std::cmp::Reverse; use std::ffi::OsStr; -use std::io::{self}; +use std::io; use std::num::NonZero; use std::ops::ControlFlow; use std::path::Path; diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 25e7ab2034..30b0734df1 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -1,7 +1,7 @@ //! Persist Codex session rollouts (.jsonl) so sessions can be replayed or inspected later. +use std::fs; use std::fs::File; -use std::fs::{self}; use std::io::Error as IoError; use std::path::Path; use std::path::PathBuf; @@ -17,8 +17,8 @@ use time::format_description::FormatItem; use time::format_description::well_known::Rfc3339; use time::macros::format_description; use tokio::io::AsyncWriteExt; +use tokio::sync::mpsc; use tokio::sync::mpsc::Sender; -use tokio::sync::mpsc::{self}; use tokio::sync::oneshot; use tracing::info; use tracing::trace; @@ -44,8 +44,6 @@ use crate::default_client::originator; use crate::path_utils; use crate::state_db; use crate::state_db::StateDbHandle; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use codex_git_utils::collect_git_info; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GitInfo as ProtocolGitInfo; @@ -58,6 +56,8 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; /// Records all [`ResponseItem`]s for a session and flushes them to disk after /// every update. diff --git a/codex-rs/core/src/rollout/recorder_tests.rs b/codex-rs/core/src/rollout/recorder_tests.rs index 8ca7b58a6b..9443c877b4 100644 --- a/codex-rs/core/src/rollout/recorder_tests.rs +++ b/codex-rs/core/src/rollout/recorder_tests.rs @@ -10,8 +10,8 @@ use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnContextItem; use codex_protocol::protocol::UserMessageEvent; use pretty_assertions::assert_eq; +use std::fs; use std::fs::File; -use std::fs::{self}; use std::io::Write; use std::path::Path; use std::path::PathBuf; diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/core/src/rollout/tests.rs index 44e536e50e..17c1792d32 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/core/src/rollout/tests.rs @@ -1,9 +1,9 @@ #![allow(clippy::unwrap_used, clippy::expect_used)] use std::ffi::OsStr; +use std::fs; use std::fs::File; use std::fs::FileTimes; -use std::fs::{self}; use std::io::Write; use std::path::Path; diff --git a/codex-rs/core/src/state/session.rs b/codex-rs/core/src/state/session.rs index 81978160a6..1a14236163 100644 --- a/codex-rs/core/src/state/session.rs +++ b/codex-rs/core/src/state/session.rs @@ -13,8 +13,8 @@ use crate::protocol::RateLimitSnapshot; use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; -use crate::truncate::TruncationPolicy; use codex_protocol::protocol::TurnContextItem; +use codex_utils_output_truncation::TruncationPolicy; /// Persistent, session-scoped state previously stored directly on `Session`. pub(crate) struct SessionState { diff --git a/codex-rs/core/src/tools/code_mode/mod.rs b/codex-rs/core/src/tools/code_mode/mod.rs index a4838d2463..a1f21a6227 100644 --- a/codex-rs/core/src/tools/code_mode/mod.rs +++ b/codex-rs/core/src/tools/code_mode/mod.rs @@ -27,11 +27,11 @@ use crate::tools::parallel::ToolCallRuntime; use crate::tools::router::ToolCall; use crate::tools::router::ToolCallSource; use crate::tools::router::ToolRouterParams; -use crate::truncate::TruncationPolicy; -use crate::truncate::formatted_truncate_text_content_items_with_policy; -use crate::truncate::truncate_function_output_items_with_policy; use crate::unified_exec::resolve_max_tokens; use codex_features::Feature; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::formatted_truncate_text_content_items_with_policy; +use codex_utils_output_truncation::truncate_function_output_items_with_policy; pub(crate) use execute_handler::CodeModeExecuteHandler; use response_adapter::into_function_call_output_content_items; diff --git a/codex-rs/core/src/tools/context.rs b/codex-rs/core/src/tools/context.rs index 74efb0989b..12db451027 100644 --- a/codex-rs/core/src/tools/context.rs +++ b/codex-rs/core/src/tools/context.rs @@ -4,8 +4,6 @@ use crate::codex::TurnContext; use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES; use crate::tools::TELEMETRY_PREVIEW_MAX_LINES; use crate::tools::TELEMETRY_PREVIEW_TRUNCATION_NOTICE; -use crate::truncate::TruncationPolicy; -use crate::truncate::formatted_truncate_text; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::resolve_max_tokens; use codex_protocol::mcp::CallToolResult; @@ -16,6 +14,8 @@ use codex_protocol::models::ResponseInputItem; use codex_protocol::models::SearchToolCallParams; use codex_protocol::models::ShellToolCallParams; use codex_protocol::models::function_call_output_content_items_to_text; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_string::take_bytes_at_char_boundary; use serde::Serialize; use serde_json::Value as JsonValue; diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 27d8cb9e65..0a29fb1200 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -42,12 +42,12 @@ use crate::original_image_detail::normalize_output_image_detail; use crate::sandboxing::ExecOptions; use crate::tools::ToolRouter; use crate::tools::context::SharedTurnDiffTracker; -use crate::truncate::TruncationPolicy; -use crate::truncate::truncate_text; use codex_sandboxing::SandboxCommand; use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxablePreference; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::truncate_text; pub(crate) const JS_REPL_PRAGMA_PREFIX: &str = "// codex-js-repl:"; const KERNEL_SOURCE: &str = include_str!("kernel.js"); diff --git a/codex-rs/core/src/tools/mod.rs b/codex-rs/core/src/tools/mod.rs index 4e495190ec..87a01c8e3b 100644 --- a/codex-rs/core/src/tools/mod.rs +++ b/codex-rs/core/src/tools/mod.rs @@ -15,9 +15,9 @@ pub mod sandboxing; pub mod spec; use crate::exec::ExecToolCallOutput; -use crate::truncate::TruncationPolicy; -use crate::truncate::formatted_truncate_text; -use crate::truncate::truncate_text; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::formatted_truncate_text; +use codex_utils_output_truncation::truncate_text; pub use router::ToolRouter; use serde::Serialize; diff --git a/codex-rs/core/src/truncate.rs b/codex-rs/core/src/truncate.rs deleted file mode 100644 index 707fbe22ef..0000000000 --- a/codex-rs/core/src/truncate.rs +++ /dev/null @@ -1,363 +0,0 @@ -//! Utilities for truncating large chunks of output while preserving a prefix -//! and suffix on UTF-8 boundaries, and helpers for line/token‑based truncation -//! used across the core crate. - -use codex_protocol::models::FunctionCallOutputContentItem; -use codex_protocol::openai_models::TruncationMode; -use codex_protocol::openai_models::TruncationPolicyConfig; -use codex_protocol::protocol::TruncationPolicy as ProtocolTruncationPolicy; - -const APPROX_BYTES_PER_TOKEN: usize = 4; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum TruncationPolicy { - Bytes(usize), - Tokens(usize), -} - -impl From for ProtocolTruncationPolicy { - fn from(value: TruncationPolicy) -> Self { - match value { - TruncationPolicy::Bytes(bytes) => Self::Bytes(bytes), - TruncationPolicy::Tokens(tokens) => Self::Tokens(tokens), - } - } -} - -impl From for TruncationPolicy { - fn from(config: TruncationPolicyConfig) -> Self { - match config.mode { - TruncationMode::Bytes => Self::Bytes(config.limit as usize), - TruncationMode::Tokens => Self::Tokens(config.limit as usize), - } - } -} - -impl TruncationPolicy { - /// Returns a token budget derived from this policy. - /// - /// - For `Tokens`, this is the explicit token limit. - /// - For `Bytes`, this is an approximate token budget using the global - /// bytes-per-token heuristic. - pub fn token_budget(&self) -> usize { - match self { - TruncationPolicy::Bytes(bytes) => { - usize::try_from(approx_tokens_from_byte_count(*bytes)).unwrap_or(usize::MAX) - } - TruncationPolicy::Tokens(tokens) => *tokens, - } - } - - /// Returns a byte budget derived from this policy. - /// - /// - For `Bytes`, this is the explicit byte limit. - /// - For `Tokens`, this is an approximate byte budget using the global - /// bytes-per-token heuristic. - pub fn byte_budget(&self) -> usize { - match self { - TruncationPolicy::Bytes(bytes) => *bytes, - TruncationPolicy::Tokens(tokens) => approx_bytes_for_tokens(*tokens), - } - } -} - -impl std::ops::Mul for TruncationPolicy { - type Output = Self; - - fn mul(self, multiplier: f64) -> Self::Output { - match self { - TruncationPolicy::Bytes(bytes) => { - TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) - } - TruncationPolicy::Tokens(tokens) => { - TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) - } - } - } -} - -pub(crate) fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> String { - if content.len() <= policy.byte_budget() { - return content.to_string(); - } - let total_lines = content.lines().count(); - let result = truncate_text(content, policy); - format!("Total output lines: {total_lines}\n\n{result}") -} - -pub(crate) fn truncate_text(content: &str, policy: TruncationPolicy) -> String { - match policy { - TruncationPolicy::Bytes(_) => truncate_with_byte_estimate(content, policy), - TruncationPolicy::Tokens(_) => { - let (truncated, _) = truncate_with_token_budget(content, policy); - truncated - } - } -} - -pub(crate) fn formatted_truncate_text_content_items_with_policy( - items: &[FunctionCallOutputContentItem], - policy: TruncationPolicy, -) -> (Vec, Option) { - let text_segments = items - .iter() - .filter_map(|item| match item { - FunctionCallOutputContentItem::InputText { text } => Some(text.as_str()), - FunctionCallOutputContentItem::InputImage { .. } => None, - }) - .collect::>(); - - if text_segments.is_empty() { - return (items.to_vec(), None); - } - - let mut combined = String::new(); - for text in &text_segments { - if !combined.is_empty() { - combined.push('\n'); - } - combined.push_str(text); - } - - if combined.len() <= policy.byte_budget() { - return (items.to_vec(), None); - } - - let mut out = vec![FunctionCallOutputContentItem::InputText { - text: formatted_truncate_text(&combined, policy), - }]; - out.extend(items.iter().filter_map(|item| match item { - FunctionCallOutputContentItem::InputImage { image_url, detail } => { - Some(FunctionCallOutputContentItem::InputImage { - image_url: image_url.clone(), - detail: *detail, - }) - } - FunctionCallOutputContentItem::InputText { .. } => None, - })); - - (out, Some(approx_token_count(&combined))) -} - -/// Globally truncate function output items to fit within the given -/// truncation policy's budget, preserving as many text/image items as -/// possible and appending a summary for any omitted text items. -pub(crate) fn truncate_function_output_items_with_policy( - items: &[FunctionCallOutputContentItem], - policy: TruncationPolicy, -) -> Vec { - let mut out: Vec = Vec::with_capacity(items.len()); - let mut remaining_budget = match policy { - TruncationPolicy::Bytes(_) => policy.byte_budget(), - TruncationPolicy::Tokens(_) => policy.token_budget(), - }; - let mut omitted_text_items = 0usize; - - for it in items { - match it { - FunctionCallOutputContentItem::InputText { text } => { - if remaining_budget == 0 { - omitted_text_items += 1; - continue; - } - - let cost = match policy { - TruncationPolicy::Bytes(_) => text.len(), - TruncationPolicy::Tokens(_) => approx_token_count(text), - }; - - if cost <= remaining_budget { - out.push(FunctionCallOutputContentItem::InputText { text: text.clone() }); - remaining_budget = remaining_budget.saturating_sub(cost); - } else { - let snippet_policy = match policy { - TruncationPolicy::Bytes(_) => TruncationPolicy::Bytes(remaining_budget), - TruncationPolicy::Tokens(_) => TruncationPolicy::Tokens(remaining_budget), - }; - let snippet = truncate_text(text, snippet_policy); - if snippet.is_empty() { - omitted_text_items += 1; - } else { - out.push(FunctionCallOutputContentItem::InputText { text: snippet }); - } - remaining_budget = 0; - } - } - FunctionCallOutputContentItem::InputImage { image_url, detail } => { - out.push(FunctionCallOutputContentItem::InputImage { - image_url: image_url.clone(), - detail: *detail, - }); - } - } - } - - if omitted_text_items > 0 { - out.push(FunctionCallOutputContentItem::InputText { - text: format!("[omitted {omitted_text_items} text items ...]"), - }); - } - - out -} - -/// Truncate the middle of a UTF-8 string to at most `max_tokens` tokens, -/// preserving the beginning and the end. Returns the possibly truncated string -/// and `Some(original_token_count)` if truncation occurred; otherwise returns -/// the original string and `None`. -fn truncate_with_token_budget(s: &str, policy: TruncationPolicy) -> (String, Option) { - if s.is_empty() { - return (String::new(), None); - } - let max_tokens = policy.token_budget(); - - let byte_len = s.len(); - if max_tokens > 0 && byte_len <= approx_bytes_for_tokens(max_tokens) { - return (s.to_string(), None); - } - - let truncated = truncate_with_byte_estimate(s, policy); - let approx_total_usize = approx_token_count(s); - let approx_total = u64::try_from(approx_total_usize).unwrap_or(u64::MAX); - if truncated == s { - (truncated, None) - } else { - (truncated, Some(approx_total)) - } -} - -/// Truncate a string using a byte budget derived from the token budget, without -/// performing any real tokenization. This keeps the logic purely byte-based and -/// uses a bytes placeholder in the truncated output. -fn truncate_with_byte_estimate(s: &str, policy: TruncationPolicy) -> String { - if s.is_empty() { - return String::new(); - } - - let total_chars = s.chars().count(); - let max_bytes = policy.byte_budget(); - - if max_bytes == 0 { - // No budget to show content; just report that everything was truncated. - let marker = format_truncation_marker( - policy, - removed_units_for_source(policy, s.len(), total_chars), - ); - return marker; - } - - if s.len() <= max_bytes { - return s.to_string(); - } - - let total_bytes = s.len(); - - let (left_budget, right_budget) = split_budget(max_bytes); - - let (removed_chars, left, right) = split_string(s, left_budget, right_budget); - - let marker = format_truncation_marker( - policy, - removed_units_for_source(policy, total_bytes.saturating_sub(max_bytes), removed_chars), - ); - - assemble_truncated_output(left, right, &marker) -} - -fn split_string(s: &str, beginning_bytes: usize, end_bytes: usize) -> (usize, &str, &str) { - if s.is_empty() { - return (0, "", ""); - } - - let len = s.len(); - let tail_start_target = len.saturating_sub(end_bytes); - let mut prefix_end = 0usize; - let mut suffix_start = len; - let mut removed_chars = 0usize; - let mut suffix_started = false; - - for (idx, ch) in s.char_indices() { - let char_end = idx + ch.len_utf8(); - if char_end <= beginning_bytes { - prefix_end = char_end; - continue; - } - - if idx >= tail_start_target { - if !suffix_started { - suffix_start = idx; - suffix_started = true; - } - continue; - } - - removed_chars = removed_chars.saturating_add(1); - } - - if suffix_start < prefix_end { - suffix_start = prefix_end; - } - - let before = &s[..prefix_end]; - let after = &s[suffix_start..]; - - (removed_chars, before, after) -} - -fn format_truncation_marker(policy: TruncationPolicy, removed_count: u64) -> String { - match policy { - TruncationPolicy::Tokens(_) => format!("…{removed_count} tokens truncated…"), - TruncationPolicy::Bytes(_) => format!("…{removed_count} chars truncated…"), - } -} - -fn split_budget(budget: usize) -> (usize, usize) { - let left = budget / 2; - (left, budget - left) -} - -fn removed_units_for_source( - policy: TruncationPolicy, - removed_bytes: usize, - removed_chars: usize, -) -> u64 { - match policy { - TruncationPolicy::Tokens(_) => approx_tokens_from_byte_count(removed_bytes), - TruncationPolicy::Bytes(_) => u64::try_from(removed_chars).unwrap_or(u64::MAX), - } -} - -fn assemble_truncated_output(prefix: &str, suffix: &str, marker: &str) -> String { - let mut out = String::with_capacity(prefix.len() + marker.len() + suffix.len() + 1); - out.push_str(prefix); - out.push_str(marker); - out.push_str(suffix); - out -} - -pub(crate) fn approx_token_count(text: &str) -> usize { - let len = text.len(); - len.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN -} - -pub(crate) fn approx_bytes_for_tokens(tokens: usize) -> usize { - tokens.saturating_mul(APPROX_BYTES_PER_TOKEN) -} - -pub(crate) fn approx_tokens_from_byte_count(bytes: usize) -> u64 { - let bytes_u64 = bytes as u64; - bytes_u64.saturating_add((APPROX_BYTES_PER_TOKEN as u64).saturating_sub(1)) - / (APPROX_BYTES_PER_TOKEN as u64) -} - -pub(crate) fn approx_tokens_from_byte_count_i64(bytes: i64) -> i64 { - if bytes <= 0 { - return 0; - } - let bytes = usize::try_from(bytes).unwrap_or(usize::MAX); - i64::try_from(approx_tokens_from_byte_count(bytes)).unwrap_or(i64::MAX) -} - -#[cfg(test)] -#[path = "truncate_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/unified_exec/process.rs b/codex-rs/core/src/unified_exec/process.rs index 58faed27de..68cf18bce1 100644 --- a/codex-rs/core/src/unified_exec/process.rs +++ b/codex-rs/core/src/unified_exec/process.rs @@ -15,9 +15,9 @@ use tokio_util::sync::CancellationToken; use crate::exec::ExecToolCallOutput; use crate::exec::StreamOutput; use crate::exec::is_likely_sandbox_denied; -use crate::truncate::TruncationPolicy; -use crate::truncate::formatted_truncate_text; use codex_sandboxing::SandboxType; +use codex_utils_output_truncation::TruncationPolicy; +use codex_utils_output_truncation::formatted_truncate_text; use codex_utils_pty::ExecCommandSession; use codex_utils_pty::SpawnedPty; diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index 52d668c000..a760249d61 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -27,7 +27,6 @@ use crate::tools::orchestrator::ToolOrchestrator; use crate::tools::runtimes::unified_exec::UnifiedExecRequest as UnifiedExecToolRequest; use crate::tools::runtimes::unified_exec::UnifiedExecRuntime; use crate::tools::sandboxing::ToolCtx; -use crate::truncate::approx_token_count; use crate::unified_exec::ExecCommandRequest; use crate::unified_exec::MAX_UNIFIED_EXEC_PROCESSES; use crate::unified_exec::MAX_YIELD_TIME_MS; @@ -50,6 +49,7 @@ use crate::unified_exec::process::OutputBuffer; use crate::unified_exec::process::OutputHandles; use crate::unified_exec::process::SpawnLifecycleHandle; use crate::unified_exec::process::UnifiedExecProcess; +use codex_utils_output_truncation::approx_token_count; const UNIFIED_EXEC_ENV: [(&str, &str); 10] = [ ("NO_COLOR", "1"), diff --git a/codex-rs/protocol/Cargo.toml b/codex-rs/protocol/Cargo.toml index eaaf791bb8..ebfdaddabb 100644 --- a/codex-rs/protocol/Cargo.toml +++ b/codex-rs/protocol/Cargo.toml @@ -16,6 +16,7 @@ codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-image = { workspace = true } +codex-utils-string = { workspace = true } icu_decimal = { workspace = true } icu_locale_core = { workspace = true } icu_provider = { workspace = true, features = ["sync"] } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index c40514e3db..6856f19417 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -7,6 +7,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; use std::fmt; +use std::ops::Mul; use std::path::Path; use std::path::PathBuf; use std::str::FromStr; @@ -2629,6 +2630,51 @@ pub enum TruncationPolicy { Tokens(usize), } +impl From for TruncationPolicy { + fn from(config: crate::openai_models::TruncationPolicyConfig) -> Self { + match config.mode { + crate::openai_models::TruncationMode::Bytes => Self::Bytes(config.limit as usize), + crate::openai_models::TruncationMode::Tokens => Self::Tokens(config.limit as usize), + } + } +} + +impl TruncationPolicy { + pub fn token_budget(&self) -> usize { + match self { + TruncationPolicy::Bytes(bytes) => { + usize::try_from(codex_utils_string::approx_tokens_from_byte_count(*bytes)) + .unwrap_or(usize::MAX) + } + TruncationPolicy::Tokens(tokens) => *tokens, + } + } + + pub fn byte_budget(&self) -> usize { + match self { + TruncationPolicy::Bytes(bytes) => *bytes, + TruncationPolicy::Tokens(tokens) => { + codex_utils_string::approx_bytes_for_tokens(*tokens) + } + } + } +} + +impl Mul for TruncationPolicy { + type Output = Self; + + fn mul(self, multiplier: f64) -> Self::Output { + match self { + TruncationPolicy::Bytes(bytes) => { + TruncationPolicy::Bytes((bytes as f64 * multiplier).ceil() as usize) + } + TruncationPolicy::Tokens(tokens) => { + TruncationPolicy::Tokens((tokens as f64 * multiplier).ceil() as usize) + } + } + } +} + #[derive(Serialize, Deserialize, Clone, JsonSchema)] pub struct RolloutLine { pub timestamp: String, diff --git a/codex-rs/utils/output-truncation/BUILD.bazel b/codex-rs/utils/output-truncation/BUILD.bazel new file mode 100644 index 0000000000..eefd22ddb2 --- /dev/null +++ b/codex-rs/utils/output-truncation/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "output-truncation", + crate_name = "codex_utils_output_truncation", +) diff --git a/codex-rs/utils/output-truncation/Cargo.toml b/codex-rs/utils/output-truncation/Cargo.toml new file mode 100644 index 0000000000..7ad0ccfd46 --- /dev/null +++ b/codex-rs/utils/output-truncation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-utils-output-truncation" +version.workspace = true + +[lints] +workspace = true + +[dependencies] +codex-protocol = { workspace = true } +codex-utils-string = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/utils/output-truncation/src/lib.rs b/codex-rs/utils/output-truncation/src/lib.rs new file mode 100644 index 0000000000..24b1630da1 --- /dev/null +++ b/codex-rs/utils/output-truncation/src/lib.rs @@ -0,0 +1,142 @@ +//! Helpers for truncating tool and exec output using [`TruncationPolicy`](codex_protocol::protocol::TruncationPolicy). + +use codex_protocol::models::FunctionCallOutputContentItem; +pub use codex_utils_string::approx_bytes_for_tokens; +pub use codex_utils_string::approx_token_count; +pub use codex_utils_string::approx_tokens_from_byte_count; +use codex_utils_string::truncate_middle_chars; +use codex_utils_string::truncate_middle_with_token_budget; + +pub use codex_protocol::protocol::TruncationPolicy; + +pub fn formatted_truncate_text(content: &str, policy: TruncationPolicy) -> String { + if content.len() <= policy.byte_budget() { + return content.to_string(); + } + + let total_lines = content.lines().count(); + let result = truncate_text(content, policy); + format!("Total output lines: {total_lines}\n\n{result}") +} + +pub fn truncate_text(content: &str, policy: TruncationPolicy) -> String { + match policy { + TruncationPolicy::Bytes(bytes) => truncate_middle_chars(content, bytes), + TruncationPolicy::Tokens(tokens) => truncate_middle_with_token_budget(content, tokens).0, + } +} + +pub fn formatted_truncate_text_content_items_with_policy( + items: &[FunctionCallOutputContentItem], + policy: TruncationPolicy, +) -> (Vec, Option) { + let text_segments = items + .iter() + .filter_map(|item| match item { + FunctionCallOutputContentItem::InputText { text } => Some(text.as_str()), + FunctionCallOutputContentItem::InputImage { .. } => None, + }) + .collect::>(); + + if text_segments.is_empty() { + return (items.to_vec(), None); + } + + let mut combined = String::new(); + for text in &text_segments { + if !combined.is_empty() { + combined.push('\n'); + } + combined.push_str(text); + } + + if combined.len() <= policy.byte_budget() { + return (items.to_vec(), None); + } + + let mut out = vec![FunctionCallOutputContentItem::InputText { + text: formatted_truncate_text(&combined, policy), + }]; + out.extend(items.iter().filter_map(|item| match item { + FunctionCallOutputContentItem::InputImage { image_url, detail } => { + Some(FunctionCallOutputContentItem::InputImage { + image_url: image_url.clone(), + detail: *detail, + }) + } + FunctionCallOutputContentItem::InputText { .. } => None, + })); + + (out, Some(approx_token_count(&combined))) +} + +pub fn truncate_function_output_items_with_policy( + items: &[FunctionCallOutputContentItem], + policy: TruncationPolicy, +) -> Vec { + let mut out: Vec = Vec::with_capacity(items.len()); + let mut remaining_budget = match policy { + TruncationPolicy::Bytes(_) => policy.byte_budget(), + TruncationPolicy::Tokens(_) => policy.token_budget(), + }; + let mut omitted_text_items = 0usize; + + for item in items { + match item { + FunctionCallOutputContentItem::InputText { text } => { + if remaining_budget == 0 { + omitted_text_items += 1; + continue; + } + + let cost = match policy { + TruncationPolicy::Bytes(_) => text.len(), + TruncationPolicy::Tokens(_) => approx_token_count(text), + }; + + if cost <= remaining_budget { + out.push(FunctionCallOutputContentItem::InputText { text: text.clone() }); + remaining_budget = remaining_budget.saturating_sub(cost); + } else { + let snippet_policy = match policy { + TruncationPolicy::Bytes(_) => TruncationPolicy::Bytes(remaining_budget), + TruncationPolicy::Tokens(_) => TruncationPolicy::Tokens(remaining_budget), + }; + let snippet = truncate_text(text, snippet_policy); + if snippet.is_empty() { + omitted_text_items += 1; + } else { + out.push(FunctionCallOutputContentItem::InputText { text: snippet }); + } + remaining_budget = 0; + } + } + FunctionCallOutputContentItem::InputImage { image_url, detail } => { + out.push(FunctionCallOutputContentItem::InputImage { + image_url: image_url.clone(), + detail: *detail, + }); + } + } + } + + if omitted_text_items > 0 { + out.push(FunctionCallOutputContentItem::InputText { + text: format!("[omitted {omitted_text_items} text items ...]"), + }); + } + + out +} + +pub fn approx_tokens_from_byte_count_i64(bytes: i64) -> i64 { + if bytes <= 0 { + return 0; + } + + let bytes = usize::try_from(bytes).unwrap_or(usize::MAX); + i64::try_from(approx_tokens_from_byte_count(bytes)).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod truncate_tests; diff --git a/codex-rs/core/src/truncate_tests.rs b/codex-rs/utils/output-truncation/src/truncate_tests.rs similarity index 77% rename from codex-rs/core/src/truncate_tests.rs rename to codex-rs/utils/output-truncation/src/truncate_tests.rs index 5a61a9a26d..f159a6b62f 100644 --- a/codex-rs/core/src/truncate_tests.rs +++ b/codex-rs/utils/output-truncation/src/truncate_tests.rs @@ -1,49 +1,13 @@ -use super::TruncationPolicy; -use super::approx_token_count; -use super::formatted_truncate_text; -use super::formatted_truncate_text_content_items_with_policy; -use super::split_string; -use super::truncate_function_output_items_with_policy; -use super::truncate_text; -use super::truncate_with_token_budget; +use crate::TruncationPolicy; +use crate::approx_token_count; +use crate::approx_tokens_from_byte_count_i64; +use crate::formatted_truncate_text; +use crate::formatted_truncate_text_content_items_with_policy; +use crate::truncate_function_output_items_with_policy; +use crate::truncate_text; use codex_protocol::models::FunctionCallOutputContentItem; use pretty_assertions::assert_eq; -#[test] -fn split_string_works() { - assert_eq!(split_string("hello world", 5, 5), (1, "hello", "world")); - assert_eq!(split_string("abc", 0, 0), (3, "", "")); -} - -#[test] -fn split_string_handles_empty_string() { - assert_eq!(split_string("", 4, 4), (0, "", "")); -} - -#[test] -fn split_string_only_keeps_prefix_when_tail_budget_is_zero() { - assert_eq!(split_string("abcdef", 3, 0), (3, "abc", "")); -} - -#[test] -fn split_string_only_keeps_suffix_when_prefix_budget_is_zero() { - assert_eq!(split_string("abcdef", 0, 3), (3, "", "def")); -} - -#[test] -fn split_string_handles_overlapping_budgets_without_removal() { - assert_eq!(split_string("abcdef", 4, 4), (0, "abcd", "ef")); -} - -#[test] -fn split_string_respects_utf8_boundaries() { - assert_eq!(split_string("😀abc😀", 5, 5), (1, "😀a", "c😀")); - - assert_eq!(split_string("😀😀😀😀😀", 1, 1), (5, "", "")); - assert_eq!(split_string("😀😀😀😀😀", 7, 7), (3, "😀", "😀")); - assert_eq!(split_string("😀😀😀😀😀", 8, 8), (1, "😀😀", "😀😀")); -} - #[test] fn truncate_bytes_less_than_placeholder_returns_placeholder() { let content = "example output"; @@ -126,31 +90,6 @@ fn truncate_tokens_reports_original_line_count_when_truncated() { ); } -#[test] -fn truncate_with_token_budget_returns_original_when_under_limit() { - let s = "short output"; - let limit = 100; - let (out, original) = truncate_with_token_budget(s, TruncationPolicy::Tokens(limit)); - assert_eq!(out, s); - assert_eq!(original, None); -} - -#[test] -fn truncate_with_token_budget_reports_truncation_at_zero_limit() { - let s = "abcdef"; - let (out, original) = truncate_with_token_budget(s, TruncationPolicy::Tokens(0)); - assert_eq!(out, "…2 tokens truncated…"); - assert_eq!(original, Some(2)); -} - -#[test] -fn truncate_middle_tokens_handles_utf8_content() { - let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; - let (out, tokens) = truncate_with_token_budget(s, TruncationPolicy::Tokens(8)); - assert_eq!(out, "😀😀😀😀…8 tokens truncated… line with text\n"); - assert_eq!(tokens, Some(16)); -} - #[test] fn truncate_middle_bytes_handles_utf8_content() { let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; @@ -185,7 +124,6 @@ fn truncates_across_multiple_under_limit_texts_and_reports_omitted() { let output = truncate_function_output_items_with_policy(&items, TruncationPolicy::Tokens(limit)); - // Expect: t1 (full), t2 (full), image, t3 (truncated), summary mentioning 2 omitted. assert_eq!(output.len(), 5); let first_text = match &output[0] { @@ -245,6 +183,29 @@ fn formatted_truncate_text_content_items_with_policy_returns_original_under_limi assert_eq!(original_token_count, None); } +#[test] +fn formatted_truncate_text_content_items_with_policy_preserves_empty_leading_text_behavior() { + let items = vec![ + FunctionCallOutputContentItem::InputText { + text: String::new(), + }, + FunctionCallOutputContentItem::InputText { + text: "abc".to_string(), + }, + ]; + + let (output, original_token_count) = + formatted_truncate_text_content_items_with_policy(&items, TruncationPolicy::Bytes(0)); + + assert_eq!( + output, + vec![FunctionCallOutputContentItem::InputText { + text: "Total output lines: 1\n\n…3 chars truncated…".to_string(), + }] + ); + assert_eq!(original_token_count, Some(1)); +} + #[test] fn formatted_truncate_text_content_items_with_policy_merges_text_and_appends_images() { let items = vec![ @@ -311,3 +272,10 @@ fn formatted_truncate_text_content_items_with_policy_merges_all_text_for_token_b ); assert_eq!(original_token_count, Some(5)); } + +#[test] +fn byte_count_conversion_clamps_non_positive_values() { + assert_eq!(approx_tokens_from_byte_count_i64(/*bytes*/ -1), 0); + assert_eq!(approx_tokens_from_byte_count_i64(/*bytes*/ 0), 0); + assert_eq!(approx_tokens_from_byte_count_i64(/*bytes*/ 5), 2); +} diff --git a/codex-rs/utils/string/src/lib.rs b/codex-rs/utils/string/src/lib.rs index 0cb218f349..a667a517b2 100644 --- a/codex-rs/utils/string/src/lib.rs +++ b/codex-rs/utils/string/src/lib.rs @@ -1,3 +1,11 @@ +mod truncate; + +pub use truncate::approx_bytes_for_tokens; +pub use truncate::approx_token_count; +pub use truncate::approx_tokens_from_byte_count; +pub use truncate::truncate_middle_chars; +pub use truncate::truncate_middle_with_token_budget; + // Truncate a &str to a byte budget at a char boundary (prefix) #[inline] pub fn take_bytes_at_char_boundary(s: &str, maxb: usize) -> &str { @@ -112,6 +120,7 @@ fn parse_markdown_hash_location_point(point: &str) -> Option<(&str, Option<&str> } #[cfg(test)] +#[allow(warnings, clippy::all)] mod tests { use super::find_uuids; use super::normalize_markdown_hash_location_suffix; diff --git a/codex-rs/utils/string/src/truncate.rs b/codex-rs/utils/string/src/truncate.rs new file mode 100644 index 0000000000..fa5b871774 --- /dev/null +++ b/codex-rs/utils/string/src/truncate.rs @@ -0,0 +1,156 @@ +//! Utilities for truncating large chunks of output while preserving a prefix +//! and suffix on UTF-8 boundaries. + +const APPROX_BYTES_PER_TOKEN: usize = 4; + +/// Truncate a string to `max_bytes` using a character-count marker. +pub fn truncate_middle_chars(s: &str, max_bytes: usize) -> String { + truncate_with_byte_estimate(s, max_bytes, /*use_tokens*/ false) +} + +/// Truncate the middle of a UTF-8 string to at most `max_tokens` approximate +/// tokens, preserving the beginning and the end. Returns the possibly +/// truncated string and `Some(original_token_count)` if truncation occurred; +/// otherwise returns the original string and `None`. +pub fn truncate_middle_with_token_budget(s: &str, max_tokens: usize) -> (String, Option) { + if s.is_empty() { + return (String::new(), None); + } + + if max_tokens > 0 && s.len() <= approx_bytes_for_tokens(max_tokens) { + return (s.to_string(), None); + } + + let truncated = truncate_with_byte_estimate( + s, + approx_bytes_for_tokens(max_tokens), + /*use_tokens*/ true, + ); + let total_tokens = u64::try_from(approx_token_count(s)).unwrap_or(u64::MAX); + + if truncated == s { + (truncated, None) + } else { + (truncated, Some(total_tokens)) + } +} + +fn truncate_with_byte_estimate(s: &str, max_bytes: usize, use_tokens: bool) -> String { + if s.is_empty() { + return String::new(); + } + + let total_chars = s.chars().count(); + + if max_bytes == 0 { + return format_truncation_marker( + use_tokens, + removed_units(use_tokens, s.len(), total_chars), + ); + } + + if s.len() <= max_bytes { + return s.to_string(); + } + + let total_bytes = s.len(); + let (left_budget, right_budget) = split_budget(max_bytes); + let (removed_chars, left, right) = split_string(s, left_budget, right_budget); + let marker = format_truncation_marker( + use_tokens, + removed_units( + use_tokens, + total_bytes.saturating_sub(max_bytes), + removed_chars, + ), + ); + + assemble_truncated_output(left, right, &marker) +} + +pub fn approx_token_count(text: &str) -> usize { + let len = text.len(); + len.saturating_add(APPROX_BYTES_PER_TOKEN.saturating_sub(1)) / APPROX_BYTES_PER_TOKEN +} + +pub fn approx_bytes_for_tokens(tokens: usize) -> usize { + tokens.saturating_mul(APPROX_BYTES_PER_TOKEN) +} + +pub fn approx_tokens_from_byte_count(bytes: usize) -> u64 { + let bytes_u64 = bytes as u64; + bytes_u64.saturating_add((APPROX_BYTES_PER_TOKEN as u64).saturating_sub(1)) + / (APPROX_BYTES_PER_TOKEN as u64) +} + +fn split_string(s: &str, beginning_bytes: usize, end_bytes: usize) -> (usize, &str, &str) { + if s.is_empty() { + return (0, "", ""); + } + + let len = s.len(); + let tail_start_target = len.saturating_sub(end_bytes); + let mut prefix_end = 0usize; + let mut suffix_start = len; + let mut removed_chars = 0usize; + let mut suffix_started = false; + + for (idx, ch) in s.char_indices() { + let char_end = idx + ch.len_utf8(); + if char_end <= beginning_bytes { + prefix_end = char_end; + continue; + } + + if idx >= tail_start_target { + if !suffix_started { + suffix_start = idx; + suffix_started = true; + } + continue; + } + + removed_chars = removed_chars.saturating_add(1); + } + + if suffix_start < prefix_end { + suffix_start = prefix_end; + } + + let before = &s[..prefix_end]; + let after = &s[suffix_start..]; + + (removed_chars, before, after) +} + +fn split_budget(budget: usize) -> (usize, usize) { + let left = budget / 2; + (left, budget - left) +} + +fn format_truncation_marker(use_tokens: bool, removed_count: u64) -> String { + if use_tokens { + format!("…{removed_count} tokens truncated…") + } else { + format!("…{removed_count} chars truncated…") + } +} + +fn removed_units(use_tokens: bool, removed_bytes: usize, removed_chars: usize) -> u64 { + if use_tokens { + approx_tokens_from_byte_count(removed_bytes) + } else { + u64::try_from(removed_chars).unwrap_or(u64::MAX) + } +} + +fn assemble_truncated_output(prefix: &str, suffix: &str, marker: &str) -> String { + let mut out = String::with_capacity(prefix.len() + marker.len() + suffix.len() + 1); + out.push_str(prefix); + out.push_str(marker); + out.push_str(suffix); + out +} + +#[cfg(test)] +mod tests; diff --git a/codex-rs/utils/string/src/truncate/tests.rs b/codex-rs/utils/string/src/truncate/tests.rs new file mode 100644 index 0000000000..5d517e86d4 --- /dev/null +++ b/codex-rs/utils/string/src/truncate/tests.rs @@ -0,0 +1,71 @@ +use super::split_string; +use super::truncate_middle_chars; +use super::truncate_middle_with_token_budget; +use pretty_assertions::assert_eq; + +#[test] +fn split_string_works() { + assert_eq!(split_string("hello world", 5, 5), (1, "hello", "world")); + assert_eq!(split_string("abc", 0, 0), (3, "", "")); +} + +#[test] +fn split_string_handles_empty_string() { + assert_eq!(split_string("", 4, 4), (0, "", "")); +} + +#[test] +fn split_string_only_keeps_prefix_when_tail_budget_is_zero() { + assert_eq!(split_string("abcdef", 3, 0), (3, "abc", "")); +} + +#[test] +fn split_string_only_keeps_suffix_when_prefix_budget_is_zero() { + assert_eq!(split_string("abcdef", 0, 3), (3, "", "def")); +} + +#[test] +fn split_string_handles_overlapping_budgets_without_removal() { + assert_eq!(split_string("abcdef", 4, 4), (0, "abcd", "ef")); +} + +#[test] +fn split_string_respects_utf8_boundaries() { + assert_eq!(split_string("😀abc😀", 5, 5), (1, "😀a", "c😀")); + + assert_eq!(split_string("😀😀😀😀😀", 1, 1), (5, "", "")); + assert_eq!(split_string("😀😀😀😀😀", 7, 7), (3, "😀", "😀")); + assert_eq!(split_string("😀😀😀😀😀", 8, 8), (1, "😀😀", "😀😀")); +} + +#[test] +fn truncate_with_token_budget_returns_original_when_under_limit() { + let s = "short output"; + let limit = 100; + let (out, original) = truncate_middle_with_token_budget(s, limit); + assert_eq!(out, s); + assert_eq!(original, None); +} + +#[test] +fn truncate_with_token_budget_reports_truncation_at_zero_limit() { + let s = "abcdef"; + let (out, original) = truncate_middle_with_token_budget(s, 0); + assert_eq!(out, "…2 tokens truncated…"); + assert_eq!(original, Some(2)); +} + +#[test] +fn truncate_middle_tokens_handles_utf8_content() { + let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; + let (out, tokens) = truncate_middle_with_token_budget(s, 8); + assert_eq!(out, "😀😀😀😀…8 tokens truncated… line with text\n"); + assert_eq!(tokens, Some(16)); +} + +#[test] +fn truncate_middle_bytes_handles_utf8_content() { + let s = "😀😀😀😀😀😀😀😀😀😀\nsecond line with text\n"; + let out = truncate_middle_chars(s, 20); + assert_eq!(out, "😀😀…21 chars truncated…with text\n"); +} From 301b17c2a198c8c1ea9438dbf97949e226052f6c Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Tue, 24 Mar 2026 15:52:13 -0700 Subject: [PATCH 08/50] app-server: add filesystem watch support (#14533) ### Summary Add the v2 app-server filesystem watch RPCs and notifications, wire them through the message processor, and implement connection-scoped watches with notify-backed change delivery. This also updates the schema fixtures, app-server documentation, and the v2 integration coverage for watch and unwatch behavior. This allows clients to efficiently watch for filesystem updates, e.g. to react on branch changes. ### Testing - exercise watch lifecycles for directory changes, atomic file replacement, missing-file targets, and unwatch cleanup --- .../schema/json/ClientRequest.json | 78 ++++ .../schema/json/ServerNotification.json | 45 +++ .../codex_app_server_protocol.schemas.json | 155 +++++++ .../codex_app_server_protocol.v2.schemas.json | 155 +++++++ .../schema/json/v2/FsChangedNotification.json | 29 ++ .../schema/json/v2/FsUnwatchParams.json | 15 + .../schema/json/v2/FsUnwatchResponse.json | 6 + .../schema/json/v2/FsWatchParams.json | 25 ++ .../schema/json/v2/FsWatchResponse.json | 30 ++ .../schema/typescript/ClientRequest.ts | 4 +- .../schema/typescript/ServerNotification.ts | 3 +- .../typescript/v2/FsChangedNotification.ts | 17 + .../schema/typescript/v2/FsUnwatchParams.ts | 12 + .../schema/typescript/v2/FsUnwatchResponse.ts | 8 + .../schema/typescript/v2/FsWatchParams.ts | 13 + .../schema/typescript/v2/FsWatchResponse.ts | 17 + .../schema/typescript/v2/index.ts | 5 + .../src/protocol/common.rs | 30 ++ .../app-server-protocol/src/protocol/v2.rs | 73 ++++ codex-rs/app-server/README.md | 25 ++ .../app-server/src/codex_message_processor.rs | 4 +- codex-rs/app-server/src/fs_api.rs | 4 +- codex-rs/app-server/src/fs_watch.rs | 379 ++++++++++++++++++ codex-rs/app-server/src/lib.rs | 1 + codex-rs/app-server/src/message_processor.rs | 53 +++ .../app-server/tests/common/mcp_process.rs | 15 + codex-rs/app-server/tests/suite/v2/fs.rs | 198 +++++++++ .../src/app/app_server_adapter.rs | 1 + codex-rs/tui_app_server/src/chatwidget.rs | 1 + 29 files changed, 1396 insertions(+), 5 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts create mode 100644 codex-rs/app-server/src/fs_watch.rs diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 903b26b80b..e245c17954 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -781,6 +781,36 @@ ], "type": "object" }, + "FsUnwatchParams": { + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "type": "object" + }, + "FsWatchParams": { + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "type": "object" + }, "FsWriteFileParams": { "description": "Write a file on the host filesystem.", "properties": { @@ -4000,6 +4030,54 @@ "title": "Fs/copyRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/watch" + ], + "title": "Fs/watchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsWatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/watchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/unwatch" + ], + "title": "Fs/unwatchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsUnwatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/unwatchRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index b576581cf9..37f7ff8c5f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AccountLoginCompletedNotification": { "properties": { "error": { @@ -998,6 +1002,27 @@ ], "type": "object" }, + "FsChangedNotification": { + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "type": "object" + }, "FuzzyFileSearchMatchType": { "enum": [ "file", @@ -4394,6 +4419,26 @@ "title": "App/list/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 104a727f3c..91f32bede9 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 @@ -883,6 +883,54 @@ "title": "Fs/copyRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "fs/watch" + ], + "title": "Fs/watchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FsWatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/watchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "fs/unwatch" + ], + "title": "Fs/unwatchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FsUnwatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/unwatchRequest", + "type": "object" + }, { "properties": { "id": { @@ -4053,6 +4101,26 @@ "title": "App/list/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/FsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" + }, { "properties": { "method": { @@ -7444,6 +7512,29 @@ ], "type": "string" }, + "FsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" + }, "FsCopyParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Copy a file or directory tree on the host filesystem.", @@ -7698,6 +7789,70 @@ "title": "FsRemoveResponse", "type": "object" }, + "FsUnwatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" + }, + "FsUnwatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" + }, + "FsWatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchParams", + "type": "object" + }, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Created watch handle returned by `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchResponse", + "type": "object" + }, "FsWriteFileParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Write a file on the host filesystem.", 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 8f4f962167..30bb2efb22 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 @@ -1417,6 +1417,54 @@ "title": "Fs/copyRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/watch" + ], + "title": "Fs/watchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsWatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/watchRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "fs/unwatch" + ], + "title": "Fs/unwatchRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsUnwatchParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Fs/unwatchRequest", + "type": "object" + }, { "properties": { "id": { @@ -4037,6 +4085,29 @@ ], "type": "string" }, + "FsChangedNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" + }, "FsCopyParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Copy a file or directory tree on the host filesystem.", @@ -4291,6 +4362,70 @@ "title": "FsRemoveResponse", "type": "object" }, + "FsUnwatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" + }, + "FsUnwatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" + }, + "FsWatchParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchParams", + "type": "object" + }, + "FsWatchResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Created watch handle returned by `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchResponse", + "type": "object" + }, "FsWriteFileParams": { "$schema": "http://json-schema.org/draft-07/schema#", "description": "Write a file on the host filesystem.", @@ -8540,6 +8675,26 @@ "title": "App/list/updatedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "fs/changed" + ], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsChangedNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Fs/changedNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json new file mode 100644 index 0000000000..ab26588ecb --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsChangedNotification.json @@ -0,0 +1,29 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "changedPaths", + "watchId" + ], + "title": "FsChangedNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json new file mode 100644 index 0000000000..4b988d97aa --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchParams.json @@ -0,0 +1,15 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Stop filesystem watch notifications for a prior `fs/watch`.", + "properties": { + "watchId": { + "description": "Watch identifier returned by `fs/watch`.", + "type": "string" + } + }, + "required": [ + "watchId" + ], + "title": "FsUnwatchParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json new file mode 100644 index 0000000000..daa80ad656 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsUnwatchResponse.json @@ -0,0 +1,6 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Successful response for `fs/unwatch`.", + "title": "FsUnwatchResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json b/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json new file mode 100644 index 0000000000..cf80c7b101 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsWatchParams.json @@ -0,0 +1,25 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Start filesystem watch notifications for an absolute path.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute file or directory path to watch." + } + }, + "required": [ + "path" + ], + "title": "FsWatchParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json b/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json new file mode 100644 index 0000000000..b516636a09 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/FsWatchResponse.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "description": "Created watch handle returned by `fs/watch`.", + "properties": { + "path": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Canonicalized path associated with the watch." + }, + "watchId": { + "description": "Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`.", + "type": "string" + } + }, + "required": [ + "path", + "watchId" + ], + "title": "FsWatchResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 5e03a26ca2..3c77652cec 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -26,6 +26,8 @@ import type { FsGetMetadataParams } from "./v2/FsGetMetadataParams"; import type { FsReadDirectoryParams } from "./v2/FsReadDirectoryParams"; import type { FsReadFileParams } from "./v2/FsReadFileParams"; import type { FsRemoveParams } from "./v2/FsRemoveParams"; +import type { FsUnwatchParams } from "./v2/FsUnwatchParams"; +import type { FsWatchParams } from "./v2/FsWatchParams"; import type { FsWriteFileParams } from "./v2/FsWriteFileParams"; import type { GetAccountParams } from "./v2/GetAccountParams"; import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams"; @@ -61,4 +63,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index d9e2df7797..85ebe847f4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -15,6 +15,7 @@ import type { ContextCompactedNotification } from "./v2/ContextCompactedNotifica import type { DeprecationNoticeNotification } from "./v2/DeprecationNoticeNotification"; import type { ErrorNotification } from "./v2/ErrorNotification"; import type { FileChangeOutputDeltaNotification } from "./v2/FileChangeOutputDeltaNotification"; +import type { FsChangedNotification } from "./v2/FsChangedNotification"; import type { HookCompletedNotification } from "./v2/HookCompletedNotification"; import type { HookStartedNotification } from "./v2/HookStartedNotification"; import type { ItemCompletedNotification } from "./v2/ItemCompletedNotification"; @@ -56,4 +57,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcriptUpdated", "params": ThreadRealtimeTranscriptUpdatedNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts new file mode 100644 index 0000000000..2e9bd0d6ec --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsChangedNotification.ts @@ -0,0 +1,17 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Filesystem watch notification emitted for `fs/watch` subscribers. + */ +export type FsChangedNotification = { +/** + * Watch identifier returned by `fs/watch`. + */ +watchId: string, +/** + * File or directory paths associated with this event. + */ +changedPaths: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts new file mode 100644 index 0000000000..b21befdb5c --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchParams.ts @@ -0,0 +1,12 @@ +// 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. + +/** + * Stop filesystem watch notifications for a prior `fs/watch`. + */ +export type FsUnwatchParams = { +/** + * Watch identifier returned by `fs/watch`. + */ +watchId: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts new file mode 100644 index 0000000000..02507d2c00 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsUnwatchResponse.ts @@ -0,0 +1,8 @@ +// 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. + +/** + * Successful response for `fs/unwatch`. + */ +export type FsUnwatchResponse = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts new file mode 100644 index 0000000000..d6d956b288 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchParams.ts @@ -0,0 +1,13 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Start filesystem watch notifications for an absolute path. + */ +export type FsWatchParams = { +/** + * Absolute file or directory path to watch. + */ +path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts new file mode 100644 index 0000000000..1927272821 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/FsWatchResponse.ts @@ -0,0 +1,17 @@ +// 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 { AbsolutePathBuf } from "../AbsolutePathBuf"; + +/** + * Created watch handle returned by `fs/watch`. + */ +export type FsWatchResponse = { +/** + * Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + */ +watchId: string, +/** + * Canonicalized path associated with the watch. + */ +path: AbsolutePathBuf, }; 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 1178f7c2ee..2ee447bba2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -97,6 +97,7 @@ export type { FileChangeOutputDeltaNotification } from "./FileChangeOutputDeltaN export type { FileChangeRequestApprovalParams } from "./FileChangeRequestApprovalParams"; export type { FileChangeRequestApprovalResponse } from "./FileChangeRequestApprovalResponse"; export type { FileUpdateChange } from "./FileUpdateChange"; +export type { FsChangedNotification } from "./FsChangedNotification"; export type { FsCopyParams } from "./FsCopyParams"; export type { FsCopyResponse } from "./FsCopyResponse"; export type { FsCreateDirectoryParams } from "./FsCreateDirectoryParams"; @@ -110,6 +111,10 @@ export type { FsReadFileParams } from "./FsReadFileParams"; export type { FsReadFileResponse } from "./FsReadFileResponse"; export type { FsRemoveParams } from "./FsRemoveParams"; export type { FsRemoveResponse } from "./FsRemoveResponse"; +export type { FsUnwatchParams } from "./FsUnwatchParams"; +export type { FsUnwatchResponse } from "./FsUnwatchResponse"; +export type { FsWatchParams } from "./FsWatchParams"; +export type { FsWatchResponse } from "./FsWatchResponse"; export type { FsWriteFileParams } from "./FsWriteFileParams"; export type { FsWriteFileResponse } from "./FsWriteFileResponse"; export type { GetAccountParams } from "./GetAccountParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index d4a8c70dd5..80cb24f0ff 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -326,6 +326,14 @@ client_request_definitions! { params: v2::FsCopyParams, response: v2::FsCopyResponse, }, + FsWatch => "fs/watch" { + params: v2::FsWatchParams, + response: v2::FsWatchResponse, + }, + FsUnwatch => "fs/unwatch" { + params: v2::FsUnwatchParams, + response: v2::FsUnwatchResponse, + }, SkillsConfigWrite => "skills/config/write" { params: v2::SkillsConfigWriteParams, response: v2::SkillsConfigWriteResponse, @@ -899,6 +907,7 @@ server_notification_definitions! { AccountUpdated => "account/updated" (v2::AccountUpdatedNotification), AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification), AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification), + FsChanged => "fs/changed" (v2::FsChangedNotification), ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification), ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification), ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification), @@ -1478,6 +1487,27 @@ mod tests { Ok(()) } + #[test] + fn serialize_fs_watch() -> Result<()> { + let request = ClientRequest::FsWatch { + request_id: RequestId::Integer(10), + params: v2::FsWatchParams { + path: absolute_path("tmp/repo/.git"), + }, + }; + assert_eq!( + json!({ + "method": "fs/watch", + "id": 10, + "params": { + "path": absolute_path_string("tmp/repo/.git") + } + }), + serde_json::to_value(&request)?, + ); + Ok(()) + } + #[test] fn serialize_list_experimental_features() -> Result<()> { let request = ClientRequest::ExperimentalFeatureList { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 09647e05cc..fab3fe3190 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -2301,6 +2301,52 @@ pub struct FsCopyParams { #[ts(export_to = "v2/")] pub struct FsCopyResponse {} +/// Start filesystem watch notifications for an absolute path. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchParams { + /// Absolute file or directory path to watch. + pub path: AbsolutePathBuf, +} + +/// Created watch handle returned by `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsWatchResponse { + /// Connection-scoped watch identifier used for `fs/unwatch` and `fs/changed`. + pub watch_id: String, + /// Canonicalized path associated with the watch. + pub path: AbsolutePathBuf, +} + +/// Stop filesystem watch notifications for a prior `fs/watch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchParams { + /// Watch identifier returned by `fs/watch`. + pub watch_id: String, +} + +/// Successful response for `fs/unwatch`. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsUnwatchResponse {} + +/// Filesystem watch notification emitted for `fs/watch` subscribers. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct FsChangedNotification { + /// Watch identifier returned by `fs/watch`. + pub watch_id: String, + /// File or directory paths associated with this event. + pub changed_paths: Vec, +} + /// PTY size in character cells for `command/exec` PTY sessions. #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] @@ -6497,6 +6543,33 @@ mod tests { assert_eq!(decoded, response); } + #[test] + fn fs_changed_notification_round_trips() { + let notification = FsChangedNotification { + watch_id: "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1".to_string(), + changed_paths: vec![ + absolute_path("tmp/repo/.git/HEAD"), + absolute_path("tmp/repo/.git/FETCH_HEAD"), + ], + }; + + let value = serde_json::to_value(¬ification).expect("serialize fs/changed notification"); + assert_eq!( + value, + json!({ + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": [ + absolute_path_string("tmp/repo/.git/HEAD"), + absolute_path_string("tmp/repo/.git/FETCH_HEAD"), + ], + }) + ); + + let decoded = serde_json::from_value::(value) + .expect("deserialize fs/changed notification"); + assert_eq!(decoded, notification); + } + #[test] fn command_exec_params_default_optional_streaming_flags() { let params = serde_json::from_value::(json!({ diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c238cc73e3..76312e81ea 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -159,6 +159,9 @@ Example with notification opt-out: - `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path. - `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`. - `fs/copy` — copy between absolute paths; directory copies require `recursive: true`. +- `fs/watch` — subscribe this connection to filesystem change notifications for an absolute file or directory path; returns a `watchId` and canonicalized `path`. +- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`. +- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. @@ -795,6 +798,28 @@ All filesystem paths in this section must be absolute. - `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`. - `fs/copy` handles both file copies and directory-tree copies; it requires `recursive: true` when `sourcePath` is a directory. Recursive copies traverse regular files, directories, and symlinks; other entry types are skipped. +### Example: Filesystem watch + +`fs/watch` accepts absolute file or directory paths. Watching a file emits `fs/changed` for that file path, including updates delivered via replace or rename operations. + +```json +{ "method": "fs/watch", "id": 44, "params": { + "path": "/Users/me/project/.git/HEAD" +} } +{ "id": 44, "result": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "path": "/Users/me/project/.git/HEAD" +} } +{ "method": "fs/changed", "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1", + "changedPaths": ["/Users/me/project/.git/HEAD"] +} } +{ "method": "fs/unwatch", "id": 45, "params": { + "watchId": "0195ec6b-1d6f-7c2e-8c7a-56f2c4a8b9d1" +} } +{ "id": 45, "result": {} } +``` + ## Events Event notifications are the server-initiated event stream for thread lifecycles, turn lifecycles, and the items within them. After you start or resume a thread, keep reading stdout for `thread/started`, `thread/archived`, `thread/unarchived`, `thread/closed`, `turn/*`, and `item/*` notifications. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 32eb28c12b..1d58e7317d 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -893,7 +893,9 @@ impl CodexMessageProcessor { | ClientRequest::FsGetMetadata { .. } | ClientRequest::FsReadDirectory { .. } | ClientRequest::FsRemove { .. } - | ClientRequest::FsCopy { .. } => { + | ClientRequest::FsCopy { .. } + | ClientRequest::FsWatch { .. } + | ClientRequest::FsUnwatch { .. } => { warn!("Filesystem request reached CodexMessageProcessor unexpectedly"); } ClientRequest::ConfigRequirementsRead { .. } => { diff --git a/codex-rs/app-server/src/fs_api.rs b/codex-rs/app-server/src/fs_api.rs index 1f8a32362f..57b355f818 100644 --- a/codex-rs/app-server/src/fs_api.rs +++ b/codex-rs/app-server/src/fs_api.rs @@ -159,7 +159,7 @@ impl FsApi { } } -fn invalid_request(message: impl Into) -> JSONRPCErrorError { +pub(crate) fn invalid_request(message: impl Into) -> JSONRPCErrorError { JSONRPCErrorError { code: INVALID_REQUEST_ERROR_CODE, message: message.into(), @@ -167,7 +167,7 @@ fn invalid_request(message: impl Into) -> JSONRPCErrorError { } } -fn map_fs_error(err: io::Error) -> JSONRPCErrorError { +pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError { if err.kind() == io::ErrorKind::InvalidInput { invalid_request(err.to_string()) } else { diff --git a/codex-rs/app-server/src/fs_watch.rs b/codex-rs/app-server/src/fs_watch.rs new file mode 100644 index 0000000000..309bee4a64 --- /dev/null +++ b/codex-rs/app-server/src/fs_watch.rs @@ -0,0 +1,379 @@ +use crate::outgoing_message::ConnectionId; +use crate::outgoing_message::OutgoingMessageSender; +use codex_app_server_protocol::FsChangedNotification; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsUnwatchResponse; +use codex_app_server_protocol::FsWatchParams; +use codex_app_server_protocol::FsWatchResponse; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::ServerNotification; +use codex_core::file_watcher::FileWatcher; +use codex_core::file_watcher::FileWatcherEvent; +use codex_core::file_watcher::FileWatcherSubscriber; +use codex_core::file_watcher::Receiver; +use codex_core::file_watcher::WatchPath; +use codex_core::file_watcher::WatchRegistration; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::collections::HashMap; +use std::collections::HashSet; +use std::hash::Hash; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex as AsyncMutex; +#[cfg(test)] +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Instant; +use tracing::warn; +use uuid::Uuid; + +const FS_CHANGED_NOTIFICATION_DEBOUNCE: Duration = Duration::from_millis(200); + +struct DebouncedReceiver { + rx: Receiver, + interval: Duration, + changed_paths: HashSet, + next_allowance: Option, +} + +impl DebouncedReceiver { + fn new(rx: Receiver, interval: Duration) -> Self { + Self { + rx, + interval, + changed_paths: HashSet::new(), + next_allowance: None, + } + } + + async fn recv(&mut self) -> Option { + while self.changed_paths.is_empty() { + self.changed_paths.extend(self.rx.recv().await?.paths); + } + let next_allowance = *self + .next_allowance + .get_or_insert_with(|| Instant::now() + self.interval); + + loop { + tokio::select! { + event = self.rx.recv() => self.changed_paths.extend(event?.paths), + _ = tokio::time::sleep_until(next_allowance) => break, + } + } + + Some(FileWatcherEvent { + paths: self.changed_paths.drain().collect(), + }) + } +} + +#[derive(Clone)] +pub(crate) struct FsWatchManager { + outgoing: Arc, + file_watcher: Arc, + state: Arc>, +} + +#[derive(Default)] +struct FsWatchState { + entries: HashMap, +} + +struct WatchEntry { + terminate_tx: oneshot::Sender>, + _subscriber: FileWatcherSubscriber, + _registration: WatchRegistration, +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WatchKey { + connection_id: ConnectionId, + watch_id: String, +} + +impl FsWatchManager { + pub(crate) fn new(outgoing: Arc) -> Self { + let file_watcher = match FileWatcher::new() { + Ok(file_watcher) => Arc::new(file_watcher), + Err(err) => { + warn!("filesystem watch manager falling back to noop core watcher: {err}"); + Arc::new(FileWatcher::noop()) + } + }; + Self::new_with_file_watcher(outgoing, file_watcher) + } + + fn new_with_file_watcher( + outgoing: Arc, + file_watcher: Arc, + ) -> Self { + Self { + outgoing, + file_watcher, + state: Arc::new(AsyncMutex::new(FsWatchState::default())), + } + } + + pub(crate) async fn watch( + &self, + connection_id: ConnectionId, + params: FsWatchParams, + ) -> Result { + let watch_id = Uuid::now_v7().to_string(); + let outgoing = self.outgoing.clone(); + let (subscriber, rx) = self.file_watcher.add_subscriber(); + let watch_root = params.path.to_path_buf().clone(); + let registration = subscriber.register_paths(vec![WatchPath { + path: params.path.to_path_buf(), + recursive: false, + }]); + let (terminate_tx, terminate_rx) = oneshot::channel(); + + self.state.lock().await.entries.insert( + WatchKey { + connection_id, + watch_id: watch_id.clone(), + }, + WatchEntry { + terminate_tx, + _subscriber: subscriber, + _registration: registration, + }, + ); + + let task_watch_id = watch_id.clone(); + tokio::spawn(async move { + let mut rx = DebouncedReceiver::new(rx, FS_CHANGED_NOTIFICATION_DEBOUNCE); + tokio::pin!(terminate_rx); + loop { + let event = tokio::select! { + biased; + _ = &mut terminate_rx => break, + event = rx.recv() => match event { + Some(event) => event, + None => break, + }, + }; + let mut changed_paths = event + .paths + .into_iter() + .filter_map(|path| { + match AbsolutePathBuf::resolve_path_against_base(&path, &watch_root) { + Ok(path) => Some(path), + Err(err) => { + warn!( + "failed to normalize watch event path ({}) for {}: {err}", + path.display(), + watch_root.display() + ); + None + } + } + }) + .collect::>(); + changed_paths.sort_by(|left, right| left.as_path().cmp(right.as_path())); + if !changed_paths.is_empty() { + outgoing + .send_server_notification_to_connection_and_wait( + connection_id, + ServerNotification::FsChanged(FsChangedNotification { + watch_id: task_watch_id.clone(), + changed_paths, + }), + ) + .await; + } + } + }); + + Ok(FsWatchResponse { + watch_id, + path: params.path, + }) + } + + pub(crate) async fn unwatch( + &self, + connection_id: ConnectionId, + params: FsUnwatchParams, + ) -> Result { + let watch_key = WatchKey { + connection_id, + watch_id: params.watch_id, + }; + let entry = self.state.lock().await.entries.remove(&watch_key); + if let Some(entry) = entry { + // Wait for the oneshot to be destroyed by the task to ensure that no notifications + // are send after the unwatch response. + let (done_tx, done_rx) = oneshot::channel(); + let _ = entry.terminate_tx.send(done_tx); + let _ = done_rx.await; + } + Ok(FsUnwatchResponse {}) + } + + pub(crate) async fn connection_closed(&self, connection_id: ConnectionId) { + let mut state = self.state.lock().await; + state + .entries + .extract_if(|key, _| key.connection_id == connection_id) + .count(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_utils_absolute_path::AbsolutePathBuf; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use uuid::Version; + + fn absolute_path(path: PathBuf) -> AbsolutePathBuf { + assert!( + path.is_absolute(), + "path must be absolute: {}", + path.display() + ); + AbsolutePathBuf::try_from(path).expect("path should be absolute") + } + + fn manager_with_noop_watcher() -> FsWatchManager { + const OUTGOING_BUFFER: usize = 1; + let (tx, _rx) = mpsc::channel(OUTGOING_BUFFER); + FsWatchManager::new_with_file_watcher( + Arc::new(OutgoingMessageSender::new(tx)), + Arc::new(FileWatcher::noop()), + ) + } + + #[tokio::test] + async fn watch_returns_a_v7_id_and_tracks_the_owner_scoped_entry() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + + let manager = manager_with_noop_watcher(); + let path = absolute_path(head_path); + let response = manager + .watch(ConnectionId(1), FsWatchParams { path: path.clone() }) + .await + .expect("watch should succeed"); + + assert_eq!(response.path, path); + let watch_id = Uuid::parse_str(&response.watch_id).expect("watch id should be a UUID"); + assert_eq!(watch_id.get_version(), Some(Version::SortRand)); + + let state = manager.state.lock().await; + assert_eq!( + state.entries.keys().cloned().collect::>(), + HashSet::from([WatchKey { + connection_id: ConnectionId(1), + watch_id: response.watch_id, + }]) + ); + } + + #[tokio::test] + async fn unwatch_is_scoped_to_the_connection_that_created_the_watch() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + + let manager = manager_with_noop_watcher(); + let response = manager + .watch( + ConnectionId(1), + FsWatchParams { + path: absolute_path(head_path), + }, + ) + .await + .expect("watch should succeed"); + let watch_key = WatchKey { + connection_id: ConnectionId(1), + watch_id: response.watch_id.clone(), + }; + + manager + .unwatch( + ConnectionId(2), + FsUnwatchParams { + watch_id: response.watch_id.clone(), + }, + ) + .await + .expect("foreign unwatch should be a no-op"); + assert!(manager.state.lock().await.entries.contains_key(&watch_key)); + + manager + .unwatch( + ConnectionId(1), + FsUnwatchParams { + watch_id: response.watch_id, + }, + ) + .await + .expect("owner unwatch should succeed"); + assert!(!manager.state.lock().await.entries.contains_key(&watch_key)); + } + + #[tokio::test] + async fn connection_closed_removes_only_that_connections_watches() { + let temp_dir = TempDir::new().expect("temp dir"); + let head_path = temp_dir.path().join("HEAD"); + let fetch_head_path = temp_dir.path().join("FETCH_HEAD"); + let packed_refs_path = temp_dir.path().join("packed-refs"); + std::fs::write(&head_path, "ref: refs/heads/main\n").expect("write HEAD"); + std::fs::write(&fetch_head_path, "old-fetch\n").expect("write FETCH_HEAD"); + std::fs::write(&packed_refs_path, "refs\n").expect("write packed-refs"); + + let manager = manager_with_noop_watcher(); + let response_1 = manager + .watch( + ConnectionId(1), + FsWatchParams { + path: absolute_path(head_path), + }, + ) + .await + .expect("first watch should succeed"); + let response_2 = manager + .watch( + ConnectionId(1), + FsWatchParams { + path: absolute_path(fetch_head_path), + }, + ) + .await + .expect("second watch should succeed"); + let response_3 = manager + .watch( + ConnectionId(2), + FsWatchParams { + path: absolute_path(packed_refs_path), + }, + ) + .await + .expect("third watch should succeed"); + + manager.connection_closed(ConnectionId(1)).await; + + assert_eq!( + manager + .state + .lock() + .await + .entries + .keys() + .cloned() + .collect::>(), + HashSet::from([WatchKey { + connection_id: ConnectionId(2), + watch_id: response_3.watch_id, + }]) + ); + assert_ne!(response_1.watch_id, response_2.watch_id); + } +} diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 63f72d5224..dc2512aaec 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -67,6 +67,7 @@ mod error_code; mod external_agent_config_api; mod filters; mod fs_api; +mod fs_watch; mod fuzzy_file_search; pub mod in_process; mod message_processor; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 96fe437c05..ad7f5ca626 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -11,6 +11,7 @@ use crate::config_api::ConfigApi; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::external_agent_config_api::ExternalAgentConfigApi; use crate::fs_api::FsApi; +use crate::fs_watch::FsWatchManager; use crate::outgoing_message::ConnectionId; use crate::outgoing_message::ConnectionRequestId; use crate::outgoing_message::OutgoingMessageSender; @@ -36,6 +37,8 @@ use codex_app_server_protocol::FsGetMetadataParams; use codex_app_server_protocol::FsReadDirectoryParams; use codex_app_server_protocol::FsReadFileParams; use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::InitializeResponse; use codex_app_server_protocol::JSONRPCError; @@ -152,6 +155,7 @@ pub(crate) struct MessageProcessor { external_agent_config_api: ExternalAgentConfigApi, fs_api: FsApi, auth_manager: Arc, + fs_watch_manager: FsWatchManager, config: Arc, config_warnings: Arc>, } @@ -248,6 +252,7 @@ impl MessageProcessor { ); let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone()); let fs_api = FsApi::default(); + let fs_watch_manager = FsWatchManager::new(outgoing.clone()); Self { outgoing, @@ -256,6 +261,7 @@ impl MessageProcessor { external_agent_config_api, fs_api, auth_manager, + fs_watch_manager, config, config_warnings: Arc::new(config_warnings), } @@ -467,6 +473,7 @@ impl MessageProcessor { pub(crate) async fn connection_closed(&mut self, connection_id: ConnectionId) { self.outgoing.connection_closed(connection_id).await; + self.fs_watch_manager.connection_closed(connection_id).await; self.codex_message_processor .connection_closed(connection_id) .await; @@ -759,6 +766,28 @@ impl MessageProcessor { ) .await; } + ClientRequest::FsWatch { request_id, params } => { + self.handle_fs_watch( + ConnectionRequestId { + connection_id, + request_id, + }, + connection_id, + params, + ) + .await; + } + ClientRequest::FsUnwatch { request_id, params } => { + self.handle_fs_unwatch( + ConnectionRequestId { + connection_id, + request_id, + }, + connection_id, + params, + ) + .await; + } other => { // Box the delegated future so this wrapper's async state machine does not // inline the full `CodexMessageProcessor::process_request` future, which @@ -910,6 +939,30 @@ impl MessageProcessor { Err(error) => self.outgoing.send_error(request_id, error).await, } } + + async fn handle_fs_watch( + &self, + request_id: ConnectionRequestId, + connection_id: ConnectionId, + params: FsWatchParams, + ) { + match self.fs_watch_manager.watch(connection_id, params).await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } + + async fn handle_fs_unwatch( + &self, + request_id: ConnectionRequestId, + connection_id: ConnectionId, + params: FsUnwatchParams, + ) { + match self.fs_watch_manager.unwatch(connection_id, params).await { + Ok(response) => self.outgoing.send_response(request_id, response).await, + Err(error) => self.outgoing.send_error(request_id, error).await, + } + } } #[cfg(test)] diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 1a132ccee1..067e06ec43 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -31,6 +31,8 @@ use codex_app_server_protocol::FsGetMetadataParams; use codex_app_server_protocol::FsReadDirectoryParams; use codex_app_server_protocol::FsReadFileParams; use codex_app_server_protocol::FsRemoveParams; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchParams; use codex_app_server_protocol::FsWriteFileParams; use codex_app_server_protocol::GetAccountParams; use codex_app_server_protocol::GetAuthStatusParams; @@ -790,6 +792,19 @@ impl McpProcess { self.send_request("fs/copy", params).await } + pub async fn send_fs_watch_request(&mut self, params: FsWatchParams) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/watch", params).await + } + + pub async fn send_fs_unwatch_request( + &mut self, + params: FsUnwatchParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("fs/unwatch", params).await + } + /// Send an `account/logout` JSON-RPC request. pub async fn send_logout_account_request(&mut self) -> anyhow::Result { self.send_request("account/logout", /*params*/ None).await diff --git a/codex-rs/app-server/tests/suite/v2/fs.rs b/codex-rs/app-server/tests/suite/v2/fs.rs index bc8ae20ec1..e113904809 100644 --- a/codex-rs/app-server/tests/suite/v2/fs.rs +++ b/codex-rs/app-server/tests/suite/v2/fs.rs @@ -4,11 +4,15 @@ use app_test_support::McpProcess; use app_test_support::to_response; use base64::Engine; use base64::engine::general_purpose::STANDARD; +use codex_app_server_protocol::FsChangedNotification; use codex_app_server_protocol::FsCopyParams; use codex_app_server_protocol::FsGetMetadataResponse; use codex_app_server_protocol::FsReadDirectoryEntry; use codex_app_server_protocol::FsReadFileResponse; +use codex_app_server_protocol::FsUnwatchParams; +use codex_app_server_protocol::FsWatchResponse; use codex_app_server_protocol::FsWriteFileParams; +use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::RequestId; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; @@ -17,6 +21,8 @@ use std::path::PathBuf; use tempfile::TempDir; use tokio::time::Duration; use tokio::time::timeout; +use uuid::Uuid; +use uuid::Version; #[cfg(unix)] use std::os::unix::fs::symlink; @@ -611,3 +617,195 @@ async fn fs_copy_rejects_standalone_fifo_source() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_directory_reports_changed_child_paths_and_unwatch_stops_notifications() +-> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let fetch_head = git_dir.join("FETCH_HEAD"); + std::fs::create_dir_all(&git_dir)?; + std::fs::write(&fetch_head, "old\n")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + path: absolute_path(git_dir.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(git_dir.clone())); + let watch_id = Uuid::parse_str(&watch_response.watch_id)?; + assert_eq!(watch_id.get_version(), Some(Version::SortRand)); + + std::fs::write(&fetch_head, "updated\n")?; + + // Kernel file watching is not reliable in every sandboxed test environment. + // Keep validating notification shape when the backend does emit, but do not + // fail the whole suite if no OS event arrives. + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!(changed.watch_id, watch_response.watch_id.clone()); + assert_eq!( + changed.changed_paths, + vec![absolute_path(fetch_head.clone())] + ); + } + while timeout( + Duration::from_millis(200), + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await + .is_ok() + {} + + let unwatch_request_id = mcp + .send_fs_unwatch_request(FsUnwatchParams { + watch_id: watch_response.watch_id, + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(unwatch_request_id)), + ) + .await??; + + std::fs::write(git_dir.join("packed-refs"), "refs\n")?; + let maybe_notification = timeout( + Duration::from_millis(1500), + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await; + assert!( + maybe_notification.is_err(), + "fs/unwatch should stop future change notifications" + ); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_file_reports_atomic_replace_events() -> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let head_path = git_dir.join("HEAD"); + std::fs::create_dir_all(&git_dir)?; + std::fs::write(&head_path, "ref: refs/heads/main\n")?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + path: absolute_path(head_path.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(head_path.clone())); + + replace_file_atomically(&head_path, "ref: refs/heads/feature\n")?; + + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!( + changed, + FsChangedNotification { + watch_id: watch_response.watch_id, + changed_paths: vec![absolute_path(head_path.clone())], + } + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_allows_missing_file_targets() -> Result<()> { + let codex_home = TempDir::new()?; + let git_dir = codex_home.path().join("repo").join(".git"); + let fetch_head = git_dir.join("FETCH_HEAD"); + std::fs::create_dir_all(&git_dir)?; + + let mut mcp = initialized_mcp(&codex_home).await?; + let watch_request_id = mcp + .send_fs_watch_request(codex_app_server_protocol::FsWatchParams { + path: absolute_path(fetch_head.clone()), + }) + .await?; + let watch_response: FsWatchResponse = to_response( + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(watch_request_id)), + ) + .await??, + )?; + assert_eq!(watch_response.path, absolute_path(fetch_head.clone())); + + replace_file_atomically(&fetch_head, "origin/main\n")?; + + if let Some(changed) = maybe_fs_changed_notification(&mut mcp).await? { + assert_eq!( + changed, + FsChangedNotification { + watch_id: watch_response.watch_id, + changed_paths: vec![absolute_path(fetch_head.clone())], + } + ); + } + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn fs_watch_rejects_relative_paths() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = initialized_mcp(&codex_home).await?; + + let watch_id = mcp + .send_raw_request("fs/watch", Some(json!({ "path": "relative-path" }))) + .await?; + expect_error_message( + &mut mcp, + watch_id, + "Invalid request: AbsolutePathBuf deserialized without a base path", + ) + .await?; + + Ok(()) +} + +fn fs_changed_notification(notification: JSONRPCNotification) -> Result { + let params = notification + .params + .context("fs/changed notification should include params")?; + Ok(serde_json::from_value::(params)?) +} + +async fn maybe_fs_changed_notification( + mcp: &mut McpProcess, +) -> Result> { + match timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("fs/changed"), + ) + .await + { + Ok(notification) => Ok(Some(fs_changed_notification(notification?)?)), + Err(_) => Ok(None), + } +} + +fn replace_file_atomically(path: &PathBuf, contents: &str) -> Result<()> { + let temp_path = path.with_extension("lock"); + std::fs::write(&temp_path, contents)?; + std::fs::rename(temp_path, path)?; + Ok(()) +} diff --git a/codex-rs/tui_app_server/src/app/app_server_adapter.rs b/codex-rs/tui_app_server/src/app/app_server_adapter.rs index 20f13589c9..c358b9cef1 100644 --- a/codex-rs/tui_app_server/src/app/app_server_adapter.rs +++ b/codex-rs/tui_app_server/src/app/app_server_adapter.rs @@ -388,6 +388,7 @@ fn server_notification_thread_target( | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) | ServerNotification::CommandExecOutputDelta(_) + | ServerNotification::FsChanged(_) | ServerNotification::WindowsWorldWritableWarning(_) | ServerNotification::WindowsSandboxSetupCompleted(_) | ServerNotification::AccountLoginCompleted(_) => None, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index b444dc13bc..df73ef20a3 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -6169,6 +6169,7 @@ impl ChatWidget { | ServerNotification::McpServerStatusUpdated(_) | ServerNotification::McpServerOauthLoginCompleted(_) | ServerNotification::AppListUpdated(_) + | ServerNotification::FsChanged(_) | ServerNotification::ContextCompacted(_) | ServerNotification::FuzzyFileSearchSessionUpdated(_) | ServerNotification::FuzzyFileSearchSessionCompleted(_) From 6323f0104d17d211029faab149231ba787f7da37 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 16:03:40 -0700 Subject: [PATCH 09/50] Use delayed shimmer for plugin loading headers in tui and tui_app_server (#15674) - Add a small delayed loading header for plugin list/detail loading messages in the TUI. Keep existing text for the first 1s, then show shimmer on the loading line. - Apply the same behavior in both tui and tui_app_server. https://github.com/user-attachments/assets/71dd35e4-7e3b-4e7b-867a-3c13dc395d3a --- codex-rs/tui/src/chatwidget/plugins.rs | 98 ++++++++++++++++--- ...t__tests__plugins_popup_loading_state.snap | 2 +- .../tui_app_server/src/chatwidget/plugins.rs | 98 ++++++++++++++++--- ...t__tests__plugins_popup_loading_state.snap | 2 +- 4 files changed, 168 insertions(+), 32 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 079f70e226..e44081f761 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -1,4 +1,6 @@ use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; @@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; +const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); +const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); + +struct DelayedLoadingHeader { + started_at: Instant, + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, +} + +impl DelayedLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + loading_text, + note, + } + } +} + +impl Renderable for DelayedLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(3); + lines.push(Line::from("Plugins".bold())); + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.started_at); + if elapsed < LOADING_ANIMATION_DELAY { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); + lines.push(Line::from(self.loading_text.as_str().dim())); + } else if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + lines.push(Line::from(shimmer_spans(self.loading_text.as_str()))); + } else { + lines.push(Line::from(self.loading_text.as_str().dim())); + } + + if let Some(note) = &self.note { + lines.push(Line::from(note.as_str().dim())); + } + + Paragraph::new(lines).render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 2 + u16::from(self.note.is_some()) + } +} #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { @@ -474,16 +545,14 @@ impl ChatWidget { } fn plugins_loading_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Loading available plugins...".dim())); - header.push(Line::from( - "Available marketplaces will appear here when ready.".dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Loading available plugins...".to_string(), + Some("This first pass shows the ChatGPT marketplace only.".to_string()), + )), items: vec![SelectionItem { name: "Loading plugins...".to_string(), description: Some("This updates when the marketplace list is ready.".to_string()), @@ -495,15 +564,14 @@ impl ChatWidget { } fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Loading details for {plugin_display_name}...").dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + format!("Loading details for {plugin_display_name}..."), + /*note*/ None, + )), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), description: Some("This updates when plugin details load.".to_string()), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap index 42eff7f81f..2b0f6837eb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - Available marketplaces will appear here when ready. + This first pass shows the ChatGPT marketplace only. › Loading plugins... This updates when the marketplace list is ready. diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index 079f70e226..e44081f761 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -1,4 +1,6 @@ use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; use super::ChatWidget; use crate::app_event::AppEvent; @@ -7,6 +9,9 @@ use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; use crate::render::renderable::ColumnRenderable; +use crate::render::renderable::Renderable; +use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallResponse; @@ -17,10 +22,76 @@ use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::PluginUninstallResponse; use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; +const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); +const LOADING_ANIMATION_INTERVAL: Duration = Duration::from_millis(100); + +struct DelayedLoadingHeader { + started_at: Instant, + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, +} + +impl DelayedLoadingHeader { + fn new( + frame_requester: FrameRequester, + animations_enabled: bool, + loading_text: String, + note: Option, + ) -> Self { + Self { + started_at: Instant::now(), + frame_requester, + animations_enabled, + loading_text, + note, + } + } +} + +impl Renderable for DelayedLoadingHeader { + fn render(&self, area: Rect, buf: &mut Buffer) { + if area.is_empty() { + return; + } + + let mut lines = Vec::with_capacity(3); + lines.push(Line::from("Plugins".bold())); + + let now = Instant::now(); + let elapsed = now.saturating_duration_since(self.started_at); + if elapsed < LOADING_ANIMATION_DELAY { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_DELAY - elapsed); + lines.push(Line::from(self.loading_text.as_str().dim())); + } else if self.animations_enabled { + self.frame_requester + .schedule_frame_in(LOADING_ANIMATION_INTERVAL); + lines.push(Line::from(shimmer_spans(self.loading_text.as_str()))); + } else { + lines.push(Line::from(self.loading_text.as_str().dim())); + } + + if let Some(note) = &self.note { + lines.push(Line::from(note.as_str().dim())); + } + + Paragraph::new(lines).render_ref(area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 2 + u16::from(self.note.is_some()) + } +} #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { @@ -474,16 +545,14 @@ impl ChatWidget { } fn plugins_loading_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from("Loading available plugins...".dim())); - header.push(Line::from( - "Available marketplaces will appear here when ready.".dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + "Loading available plugins...".to_string(), + Some("This first pass shows the ChatGPT marketplace only.".to_string()), + )), items: vec![SelectionItem { name: "Loading plugins...".to_string(), description: Some("This updates when the marketplace list is ready.".to_string()), @@ -495,15 +564,14 @@ impl ChatWidget { } fn plugin_detail_loading_popup_params(&self, plugin_display_name: &str) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Plugins".bold())); - header.push(Line::from( - format!("Loading details for {plugin_display_name}...").dim(), - )); - SelectionViewParams { view_id: Some(PLUGINS_SELECTION_VIEW_ID), - header: Box::new(header), + header: Box::new(DelayedLoadingHeader::new( + self.frame_requester.clone(), + self.config.animations, + format!("Loading details for {plugin_display_name}..."), + /*note*/ None, + )), items: vec![SelectionItem { name: "Loading plugin details...".to_string(), description: Some("This updates when plugin details load.".to_string()), diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap index 846c73fe47..f1f18c1eaf 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugins_popup_loading_state.snap @@ -4,6 +4,6 @@ expression: popup --- Plugins Loading available plugins... - Available marketplaces will appear here when ready. + This first pass shows the ChatGPT marketplace only. › Loading plugins... This updates when the marketplace list is ready. From 24c4ecaaac7ba9205e9c48376de7450a087f1576 Mon Sep 17 00:00:00 2001 From: Ruslan Nigmatullin Date: Tue, 24 Mar 2026 16:13:34 -0700 Subject: [PATCH 10/50] app-server: Return codex home in initialize response (#15689) This allows clients to get enough information to interact with the codex skills/configuration/etc. --- .../json/codex_app_server_protocol.schemas.json | 13 +++++++++++++ .../schema/json/v1/InitializeResponse.json | 15 +++++++++++++++ .../schema/typescript/InitializeResponse.ts | 5 +++++ codex-rs/app-server-protocol/src/protocol/v1.rs | 2 ++ codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/message_processor.rs | 14 ++++++++++++++ codex-rs/app-server/tests/suite/v2/initialize.rs | 7 +++++++ 7 files changed, 57 insertions(+), 1 deletion(-) 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 91f32bede9..ad79d0987e 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 @@ -1,6 +1,10 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, "AdditionalFileSystemPermissions": { "properties": { "read": { @@ -2305,6 +2309,14 @@ "InitializeResponse": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { + "codexHome": { + "allOf": [ + { + "$ref": "#/definitions/v2/AbsolutePathBuf" + } + ], + "description": "Absolute path to the server's $CODEX_HOME directory." + }, "platformFamily": { "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", "type": "string" @@ -2318,6 +2330,7 @@ } }, "required": [ + "codexHome", "platformFamily", "platformOs", "userAgent" diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json index ada38a6582..1de65f82f4 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeResponse.json @@ -1,6 +1,20 @@ { "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, "properties": { + "codexHome": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to the server's $CODEX_HOME directory." + }, "platformFamily": { "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", "type": "string" @@ -14,6 +28,7 @@ } }, "required": [ + "codexHome", "platformFamily", "platformOs", "userAgent" diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts index 47978fc8d1..c1d0a822f2 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeResponse.ts @@ -1,8 +1,13 @@ // 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 { AbsolutePathBuf } from "./AbsolutePathBuf"; export type InitializeResponse = { userAgent: string, +/** + * Absolute path to the server's $CODEX_HOME directory. + */ +codexHome: AbsolutePathBuf, /** * Platform family for the running app-server target, for example * `"unix"` or `"windows"`. diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 67e552dea3..6aa2e9fa30 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -56,6 +56,8 @@ pub struct InitializeCapabilities { #[serde(rename_all = "camelCase")] pub struct InitializeResponse { pub user_agent: String, + /// Absolute path to the server's $CODEX_HOME directory. + pub codex_home: AbsolutePathBuf, /// Platform family for the running app-server target, for example /// `"unix"` or `"windows"`. pub platform_family: String, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 76312e81ea..b877c4a035 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -75,7 +75,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat ## Initialization -Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services plus `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error. +Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services, `codexHome` for the server's Codex home directory, and `platformFamily` and `platformOs` strings describing the app-server runtime target; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error. `initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index ad7f5ca626..a67c810bd0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -8,6 +8,7 @@ use std::sync::atomic::Ordering; use crate::codex_message_processor::CodexMessageProcessor; use crate::codex_message_processor::CodexMessageProcessorArgs; use crate::config_api::ConfigApi; +use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_REQUEST_ERROR_CODE; use crate::external_agent_config_api::ExternalAgentConfigApi; use crate::fs_api::FsApi; @@ -590,8 +591,21 @@ impl MessageProcessor { } let user_agent = get_codex_user_agent(); + let codex_home = match self.config.codex_home.clone().try_into() { + Ok(codex_home) => codex_home, + Err(err) => { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("Invalid CODEX_HOME: {err}"), + data: None, + }; + self.outgoing.send_error(connection_request_id, error).await; + return; + } + }; let response = InitializeResponse { user_agent, + codex_home, platform_family: std::env::consts::FAMILY.to_string(), platform_os: std::env::consts::OS.to_string(), }; diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 9b5f0cacc8..165160468f 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -14,6 +14,7 @@ use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::UserInput as V2UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cargo_bin::cargo_bin; use core_test_support::fs_wait; use pretty_assertions::assert_eq; @@ -30,6 +31,7 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { let responses = Vec::new(); let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; + let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -48,11 +50,13 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> { }; let InitializeResponse { user_agent, + codex_home: response_codex_home, platform_family, platform_os, } = to_response::(response)?; assert!(user_agent.starts_with("codex_vscode/")); + assert_eq!(response_codex_home, expected_codex_home); assert_eq!(platform_family, std::env::consts::FAMILY); assert_eq!(platform_os, std::env::consts::OS); Ok(()) @@ -63,6 +67,7 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> { let responses = Vec::new(); let server = create_mock_responses_server_sequence_unchecked(responses).await; let codex_home = TempDir::new()?; + let expected_codex_home = AbsolutePathBuf::try_from(codex_home.path().canonicalize()?)?; create_config_toml(codex_home.path(), &server.uri(), "never")?; let mut mcp = McpProcess::new_with_env( codex_home.path(), @@ -88,11 +93,13 @@ async fn initialize_respects_originator_override_env_var() -> Result<()> { }; let InitializeResponse { user_agent, + codex_home: response_codex_home, platform_family, platform_os, } = to_response::(response)?; assert!(user_agent.starts_with("codex_originator_via_env_var/")); + assert_eq!(response_codex_home, expected_codex_home); assert_eq!(platform_family, std::env::consts::FAMILY); assert_eq!(platform_os, std::env::consts::OS); Ok(()) From 4a210faf338c9a63c27c4216c1588097472fa2b3 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 24 Mar 2026 16:32:31 -0700 Subject: [PATCH 11/50] fix: keep rmcp-client env vars as OsString (#15363) ## Why This is a follow-up to #15360. That change fixed the `arg0` helper setup, but `rmcp-client` still coerced stdio transport environment values into UTF-8 `String`s before program resolution and process spawn. If `PATH` or another inherited environment value contains non-UTF-8 bytes, that loses fidelity before it reaches `which` and `Command`. ## What changed - change `create_env_for_mcp_server()` to return `HashMap` and read inherited values with `std::env::var_os()` - change `TransportRecipe::Stdio.env`, `RmcpClient::new_stdio_client()`, and `program_resolver::resolve()` to keep stdio transport env values in `OsString` form within `rmcp-client` - keep the `codex-core` config boundary stringly, but convert configured stdio env values to `OsString` once when constructing the transport - update the rmcp-client stdio test fixtures and callers to use `OsString` env maps - add a Unix regression test that verifies `create_env_for_mcp_server()` preserves a non-UTF-8 `PATH` ## How to verify - `cargo test -p codex-rmcp-client` - `cargo test -p codex-core mcp_connection_manager` - `just argument-comment-lint` Targeted coverage in this change includes `utils::tests::create_env_preserves_path_when_it_is_not_utf8`, while the updated stdio transport path is exercised by the existing rmcp-client tests that construct `RmcpClient::new_stdio_client()`. --- codex-rs/core/src/mcp_connection_manager.rs | 7 ++- codex-rs/rmcp-client/src/program_resolver.rs | 52 ++++++++++--------- codex-rs/rmcp-client/src/rmcp_client.rs | 4 +- codex-rs/rmcp-client/src/utils.rs | 40 ++++++++++---- .../tests/process_group_cleanup.rs | 4 +- 5 files changed, 67 insertions(+), 40 deletions(-) diff --git a/codex-rs/core/src/mcp_connection_manager.rs b/codex-rs/core/src/mcp_connection_manager.rs index 938d6d0b2b..eac8485048 100644 --- a/codex-rs/core/src/mcp_connection_manager.rs +++ b/codex-rs/core/src/mcp_connection_manager.rs @@ -1440,7 +1440,12 @@ async fn make_rmcp_client( } => { let command_os: OsString = command.into(); let args_os: Vec = args.into_iter().map(Into::into).collect(); - RmcpClient::new_stdio_client(command_os, args_os, env, &env_vars, cwd) + let env_os = env.map(|env| { + env.into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect::>() + }); + RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd) .await .map_err(|err| StartupOutcomeError::from(anyhow!(err))) } diff --git a/codex-rs/rmcp-client/src/program_resolver.rs b/codex-rs/rmcp-client/src/program_resolver.rs index e62336e263..d9be2741c7 100644 --- a/codex-rs/rmcp-client/src/program_resolver.rs +++ b/codex-rs/rmcp-client/src/program_resolver.rs @@ -13,18 +13,13 @@ use std::collections::HashMap; use std::ffi::OsString; -#[cfg(windows)] -use std::env; -#[cfg(windows)] -use tracing::debug; - /// Resolves a program to its executable path on Unix systems. /// /// Unix systems handle PATH resolution and script execution natively through /// the kernel's shebang (`#!`) mechanism, so this function simply returns /// the program name unchanged. #[cfg(unix)] -pub fn resolve(program: OsString, _env: &HashMap) -> std::io::Result { +pub fn resolve(program: OsString, _env: &HashMap) -> std::io::Result { Ok(program) } @@ -38,25 +33,22 @@ pub fn resolve(program: OsString, _env: &HashMap) -> std::io::Re /// This enables tools like `npx`, `pnpm`, and `yarn` to work correctly on Windows /// without requiring users to specify full paths or extensions in their configuration. #[cfg(windows)] -pub fn resolve(program: OsString, env: &HashMap) -> std::io::Result { +pub fn resolve(program: OsString, env: &HashMap) -> std::io::Result { // Get current directory for relative path resolution - let cwd = env::current_dir() + let cwd = std::env::current_dir() .map_err(|e| std::io::Error::other(format!("Failed to get current directory: {e}")))?; // Extract PATH from environment for search locations - let search_path = env.get("PATH"); + let search_path = env.get(std::ffi::OsStr::new("PATH")); // Attempt resolution via which crate match which::which_in(&program, search_path, &cwd) { Ok(resolved) => { - debug!("Resolved {:?} to {:?}", program, resolved); + tracing::debug!("Resolved {program:?} to {resolved:?}"); Ok(resolved.into_os_string()) } Err(e) => { - debug!( - "Failed to resolve {:?}: {}. Using original path", - program, e - ); + tracing::debug!("Failed to resolve {program:?}: {e}. Using original path"); // Fallback to original program - let Command::new() handle the error Ok(program) } @@ -146,7 +138,7 @@ mod tests { // Held to prevent the temporary directory from being deleted. _temp_dir: TempDir, program_name: String, - mcp_env: HashMap, + mcp_env: HashMap, } impl TestExecutableEnv { @@ -160,10 +152,10 @@ mod tests { // Build a clean environment with the temp dir in the PATH. let mut extra_env = HashMap::new(); - extra_env.insert("PATH".to_string(), Self::build_path(dir_path)); + extra_env.insert(OsString::from("PATH"), Self::build_path_env_var(dir_path)); #[cfg(windows)] - extra_env.insert("PATHEXT".to_string(), Self::ensure_cmd_extension()); + extra_env.insert(OsString::from("PATHEXT"), Self::ensure_cmd_extension()); let mcp_env = create_env_for_mcp_server(Some(extra_env), &[]); @@ -202,20 +194,30 @@ mod tests { } /// Prepends the given directory to the system's PATH variable. - fn build_path(dir: &Path) -> String { - let current = std::env::var("PATH").unwrap_or_default(); - let sep = if cfg!(windows) { ";" } else { ":" }; - format!("{}{sep}{current}", dir.to_string_lossy()) + fn build_path_env_var(dir: &Path) -> OsString { + let mut path = OsString::from(dir.as_os_str()); + if let Some(current) = std::env::var_os("PATH") { + let sep = if cfg!(windows) { ";" } else { ":" }; + path.push(sep); + path.push(current); + } + path } /// Ensures `.CMD` is in the `PATHEXT` variable on Windows for script discovery. #[cfg(windows)] - fn ensure_cmd_extension() -> String { - let current = std::env::var("PATHEXT").unwrap_or_default(); - if current.to_uppercase().contains(".CMD") { + fn ensure_cmd_extension() -> OsString { + let current = std::env::var_os("PATHEXT").unwrap_or_default(); + if current + .to_string_lossy() + .to_ascii_uppercase() + .contains(".CMD") + { current } else { - format!(".CMD;{current}") + let mut path_ext = OsString::from(".CMD;"); + path_ext.push(current); + path_ext } } } diff --git a/codex-rs/rmcp-client/src/rmcp_client.rs b/codex-rs/rmcp-client/src/rmcp_client.rs index 55a3603ed7..aa460c21b9 100644 --- a/codex-rs/rmcp-client/src/rmcp_client.rs +++ b/codex-rs/rmcp-client/src/rmcp_client.rs @@ -390,7 +390,7 @@ enum TransportRecipe { Stdio { program: OsString, args: Vec, - env: Option>, + env: Option>, env_vars: Vec, cwd: Option, }, @@ -478,7 +478,7 @@ impl RmcpClient { pub async fn new_stdio_client( program: OsString, args: Vec, - env: Option>, + env: Option>, env_vars: &[String], cwd: Option, ) -> io::Result { diff --git a/codex-rs/rmcp-client/src/utils.rs b/codex-rs/rmcp-client/src/utils.rs index df09b0f8be..cb906aaaa9 100644 --- a/codex-rs/rmcp-client/src/utils.rs +++ b/codex-rs/rmcp-client/src/utils.rs @@ -5,16 +5,17 @@ use reqwest::header::HeaderName; use reqwest::header::HeaderValue; use std::collections::HashMap; use std::env; +use std::ffi::OsString; pub(crate) fn create_env_for_mcp_server( - extra_env: Option>, + extra_env: Option>, env_vars: &[String], -) -> HashMap { +) -> HashMap { DEFAULT_ENV_VARS .iter() .copied() .chain(env_vars.iter().map(String::as_str)) - .filter_map(|var| env::var(var).ok().map(|value| (var.to_string(), value))) + .filter_map(|var| env::var_os(var).map(|value| (OsString::from(var), value))) .chain(extra_env.unwrap_or_default()) .collect() } @@ -140,7 +141,7 @@ mod tests { use pretty_assertions::assert_eq; use serial_test::serial; - use std::ffi::OsString; + use std::ffi::OsStr; struct EnvVarGuard { key: String, @@ -148,10 +149,10 @@ mod tests { } impl EnvVarGuard { - fn set(key: &str, value: &str) -> Self { + fn set(key: &str, value: impl AsRef) -> Self { let original = std::env::var_os(key); unsafe { - std::env::set_var(key, value); + std::env::set_var(key, value.as_ref()); } Self { key: key.to_string(), @@ -177,9 +178,12 @@ mod tests { #[tokio::test] async fn create_env_honors_overrides() { let value = "custom".to_string(); - let env = - create_env_for_mcp_server(Some(HashMap::from([("TZ".into(), value.clone())])), &[]); - assert_eq!(env.get("TZ"), Some(&value)); + let expected = OsString::from(&value); + let env = create_env_for_mcp_server( + Some(HashMap::from([(OsString::from("TZ"), expected.clone())])), + &[], + ); + assert_eq!(env.get(OsStr::new("TZ")), Some(&expected)); } #[test] @@ -187,8 +191,24 @@ mod tests { fn create_env_includes_additional_whitelisted_variables() { let custom_var = "EXTRA_RMCP_ENV"; let value = "from-env"; + let expected = OsString::from(value); let _guard = EnvVarGuard::set(custom_var, value); let env = create_env_for_mcp_server(None, &[custom_var.to_string()]); - assert_eq!(env.get(custom_var), Some(&value.to_string())); + assert_eq!(env.get(OsStr::new(custom_var)), Some(&expected)); + } + + #[cfg(unix)] + #[test] + #[serial(extra_rmcp_env)] + fn create_env_preserves_path_when_it_is_not_utf8() { + use std::os::unix::ffi::OsStrExt; + + let raw_path = std::ffi::OsStr::from_bytes(b"/tmp/codex-\xFF/bin"); + let expected = raw_path.to_os_string(); + let _guard = EnvVarGuard::set("PATH", raw_path); + + let env = create_env_for_mcp_server(None, &[]); + + assert_eq!(env.get(OsStr::new("PATH")), Some(&expected)); } } diff --git a/codex-rs/rmcp-client/tests/process_group_cleanup.rs b/codex-rs/rmcp-client/tests/process_group_cleanup.rs index bb033af875..2d6c220ce8 100644 --- a/codex-rs/rmcp-client/tests/process_group_cleanup.rs +++ b/codex-rs/rmcp-client/tests/process_group_cleanup.rs @@ -73,8 +73,8 @@ async fn drop_kills_wrapper_process_group() -> Result<()> { ), ], Some(HashMap::from([( - "CHILD_PID_FILE".to_string(), - child_pid_file_str, + OsString::from("CHILD_PID_FILE"), + OsString::from(child_pid_file_str), )])), &[], None, From 05b967c79a72e409cd9d8d6536ca29201e4873fb Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 17:40:14 -0700 Subject: [PATCH 12/50] Remove provenance filtering in $mentions for apps and skills from plugins (#15700) - Removes provenance filtering in the mentions feature for apps and skills that were installed as part of a plugin. - All skills and apps for a plugin are mentionable with this change. --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 112 ++---------------- .../src/bottom_pane/chat_composer.rs | 62 ++-------- 2 files changed, 24 insertions(+), 150 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 7e5831ab5b..6d5c92d490 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3584,41 +3584,8 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); - let plugin_namespaces: HashSet = - self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { - plugins - .iter() - .filter_map(|plugin| { - let (plugin_name, _) = plugin - .config_name - .split_once('@') - .unwrap_or((plugin.config_name.as_str(), "")); - let plugin_name = plugin_name.trim(); - if plugin_name.is_empty() { - None - } else { - Some(plugin_name.to_ascii_lowercase()) - } - }) - .collect() - }); - let plugin_display_names: HashSet = - self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { - plugins - .iter() - .map(|plugin| plugin.display_name.to_ascii_lowercase()) - .collect() - }); - if let Some(skills) = self.skills.as_ref() { for skill in skills { - let is_plugin_namespaced_skill = - skill.name.split_once(':').is_some_and(|(namespace, _)| { - plugin_namespaces.contains(&namespace.to_ascii_lowercase()) - }); - if is_plugin_namespaced_skill { - continue; - } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -3698,30 +3665,18 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } - let plugin_backed_connector = connector - .plugin_display_names - .iter() - .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); - if plugin_backed_connector { - continue; - } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); - let category_tag = if connector.plugin_display_names.is_empty() { - "[App]".to_string() - } else { - "[Plugin]".to_string() - }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some(category_tag), + category_tag: Some("[App]".to_string()), sort_rank: 1, }); } @@ -5428,7 +5383,7 @@ mod tests { } #[test] - fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { + fn mention_items_show_plugin_owned_skill_and_app_duplicates() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -5491,63 +5446,20 @@ mod tests { })); let mentions = composer.mention_items(); - assert_eq!(mentions.len(), 1); + assert_eq!(mentions.len(), 3); + assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string())); + assert_eq!( + mentions[0].path, + Some("/tmp/repo/google-calendar/SKILL.md".to_string()) + ); assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); - assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); + assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string())); assert_eq!( - mentions[0].path, - Some("plugin://google-calendar@debug".to_string()) - ); - } - - #[test] - fn mention_items_hide_plugin_owned_app_duplicates_when_provenance_matches() { - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - composer.set_connectors_enabled(true); - composer.set_text_content("$goog".to_string(), Vec::new(), Vec::new()); - composer.set_plugin_mentions(Some(vec![PluginCapabilitySummary { - config_name: "google-calendar@debug".to_string(), - display_name: "Google Calendar".to_string(), - description: None, - has_skills: false, - mcp_server_names: vec!["google-calendar".to_string()], - app_connector_ids: vec![codex_core::plugins::AppConnectorId( - "google_calendar".to_string(), - )], - }])); - composer.set_connector_mentions(Some(ConnectorsSnapshot { - connectors: vec![AppInfo { - id: "google_calendar".to_string(), - name: "Google Calendar".to_string(), - description: Some("Look up events and availability".to_string()), - logo_url: None, - logo_url_dark: None, - distribution_channel: None, - branding: None, - app_metadata: None, - labels: None, - install_url: Some("https://example.test/google-calendar".to_string()), - is_accessible: true, - is_enabled: true, - plugin_display_names: vec!["Google Calendar".to_string()], - }], - })); - - let mentions = composer.mention_items(); - assert_eq!(mentions.len(), 1); - assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); - assert_eq!( - mentions[0].path, + mentions[1].path, Some("plugin://google-calendar@debug".to_string()) ); + assert_eq!(mentions[2].category_tag, Some("[App]".to_string())); + assert_eq!(mentions[2].path, Some("app://google_calendar".to_string())); } #[test] diff --git a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs index d7dc0ae096..fea3b8c2ad 100644 --- a/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui_app_server/src/bottom_pane/chat_composer.rs @@ -3599,41 +3599,8 @@ impl ChatComposer { fn mention_items(&self) -> Vec { let mut mentions = Vec::new(); - let plugin_namespaces: HashSet = - self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { - plugins - .iter() - .filter_map(|plugin| { - let (plugin_name, _) = plugin - .config_name - .split_once('@') - .unwrap_or((plugin.config_name.as_str(), "")); - let plugin_name = plugin_name.trim(); - if plugin_name.is_empty() { - None - } else { - Some(plugin_name.to_ascii_lowercase()) - } - }) - .collect() - }); - let plugin_display_names: HashSet = - self.plugins.as_ref().map_or_else(HashSet::new, |plugins| { - plugins - .iter() - .map(|plugin| plugin.display_name.to_ascii_lowercase()) - .collect() - }); - if let Some(skills) = self.skills.as_ref() { for skill in skills { - let is_plugin_namespaced_skill = - skill.name.split_once(':').is_some_and(|(namespace, _)| { - plugin_namespaces.contains(&namespace.to_ascii_lowercase()) - }); - if is_plugin_namespaced_skill { - continue; - } let display_name = skill_display_name(skill).to_string(); let description = skill_description(skill); let skill_name = skill.name.clone(); @@ -3713,30 +3680,18 @@ impl ChatComposer { if !connector.is_accessible || !connector.is_enabled { continue; } - let plugin_backed_connector = connector - .plugin_display_names - .iter() - .any(|name| plugin_display_names.contains(&name.to_ascii_lowercase())); - if plugin_backed_connector { - continue; - } let display_name = connectors::connector_display_label(connector); let description = Some(Self::connector_brief_description(connector)); let slug = codex_core::connectors::connector_mention_slug(connector); let search_terms = vec![display_name.clone(), connector.id.clone(), slug.clone()]; let connector_id = connector.id.as_str(); - let category_tag = if connector.plugin_display_names.is_empty() { - "[App]".to_string() - } else { - "[Plugin]".to_string() - }; mentions.push(MentionItem { display_name: display_name.clone(), description, insert_text: format!("${slug}"), search_terms, path: Some(format!("app://{connector_id}")), - category_tag: Some(category_tag), + category_tag: Some("[App]".to_string()), sort_rank: 1, }); } @@ -5443,7 +5398,7 @@ mod tests { } #[test] - fn mention_items_hide_plugin_owned_skill_and_app_duplicates() { + fn mention_items_show_plugin_owned_skill_and_app_duplicates() { let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new( @@ -5506,13 +5461,20 @@ mod tests { })); let mentions = composer.mention_items(); - assert_eq!(mentions.len(), 1); - assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); - assert_eq!(mentions[0].category_tag, Some("[Plugin]".to_string())); + assert_eq!(mentions.len(), 3); + assert_eq!(mentions[0].category_tag, Some("[Skill]".to_string())); assert_eq!( mentions[0].path, + Some("/tmp/repo/google-calendar/SKILL.md".to_string()) + ); + assert_eq!(mentions[0].display_name, "Google Calendar".to_string()); + assert_eq!(mentions[1].category_tag, Some("[Plugin]".to_string())); + assert_eq!( + mentions[1].path, Some("plugin://google-calendar@debug".to_string()) ); + assert_eq!(mentions[2].category_tag, Some("[App]".to_string())); + assert_eq!(mentions[2].path, Some("app://google_calendar".to_string())); } #[test] From 38b638d89d89bdc2a6bc43d0bc2d084254df29b9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 17:40:26 -0700 Subject: [PATCH 13/50] Add legal link to TUI /plugin details (#15692) - Adds language and "[learn more](https://help.openai.com/en/articles/11487775-apps-in-chatgpt)" link to plugin details pages. - Message is hidden when plugin is installed image --- codex-rs/tui/src/chatwidget/plugins.rs | 56 ++++++++++++- ...ests__plugin_detail_popup_installable.snap | 5 +- ..._tests__plugin_detail_popup_installed.snap | 15 ++++ codex-rs/tui/src/chatwidget/tests.rs | 83 ++++++++++++++++++- codex-rs/tui/src/onboarding/mod.rs | 1 + .../tui_app_server/src/chatwidget/plugins.rs | 56 ++++++++++++- ...ests__plugin_detail_popup_installable.snap | 5 +- ..._tests__plugin_detail_popup_installed.snap | 15 ++++ .../tui_app_server/src/chatwidget/tests.rs | 83 ++++++++++++++++++- codex-rs/tui_app_server/src/onboarding/mod.rs | 1 + 10 files changed, 308 insertions(+), 12 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap create mode 100644 codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index e44081f761..ce08cc8791 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -8,6 +8,7 @@ use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; +use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; @@ -24,10 +25,12 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); @@ -93,6 +96,29 @@ impl Renderable for DelayedLoadingHeader { } } +const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; + +struct PluginDisclosureLine { + line: Line<'static>, +} + +impl Renderable for PluginDisclosureLine { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .render(area, buf); + mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(u16::MAX) + } +} + #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] @@ -812,13 +838,37 @@ impl ChatWidget { ) -> SelectionViewParams { let marketplace_label = plugin.marketplace_name.clone(); let display_name = plugin_display_name(&plugin.summary); - let status_label = plugin_status_label(&plugin.summary); + let detail_status_label = if plugin.summary.installed { + if plugin.summary.enabled { + "Installed" + } else { + "Installed · Disabled" + } + } else { + match plugin.summary.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + }; let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( - format!("{display_name} · {marketplace_label}").bold(), + format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), )); - header.push(Line::from(status_label.dim())); + if !plugin.summary.installed { + header.push(PluginDisclosureLine { + line: Line::from(vec![ + "Data shared with this app is subject to the app's ".into(), + "terms of service".bold(), + " and ".into(), + "privacy policy".bold(), + ". ".into(), + "Learn more".cyan().underlined(), + ".".into(), + ]), + }); + } if let Some(description) = plugin_detail_description(plugin) { header.push(Line::from(description.dim())); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index 0388e3d161..b9e5683c46 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,8 +3,9 @@ source: tui/src/chatwidget/tests.rs expression: popup --- Plugins - Figma · ChatGPT Marketplace - Available + Figma · Can be installed · ChatGPT Marketplace + Data shared with this app is subject to the app's terms of service and privacy policy. Learn + more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap new file mode 100644 index 0000000000..71ae46d78d --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · Installed · ChatGPT Marketplace + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Uninstall plugin Remove this plugin now. + Skills design-review, extract-copy + Apps Figma, Slack + MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index fad59d5cbc..e1314e27f0 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7083,6 +7083,40 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn strip_osc8_for_snapshot(text: &str) -> String { + // Snapshots should assert the visible popup text, not terminal hyperlink escapes. + let bytes = text.as_bytes(); + let mut stripped = String::with_capacity(text.len()); + let mut i = 0; + + while i < bytes.len() { + if bytes[i..].starts_with(b"\x1B]8;;") { + i += 5; + while i < bytes.len() { + if bytes[i] == b'\x07' { + i += 1; + break; + } + if i + 1 < bytes.len() && bytes[i] == b'\x1B' && bytes[i + 1] == b'\\' { + i += 2; + break; + } + i += 1; + } + continue; + } + + let ch = text[i..] + .chars() + .next() + .expect("slice should always contain a char"); + stripped.push(ch); + i += ch.len_utf8(); + } + + stripped +} + fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from( std::env::temp_dir() @@ -7338,7 +7372,54 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa ); let popup = render_bottom_popup(&chat, 100); - assert_snapshot!("plugin_detail_popup_installable", popup); + assert_snapshot!( + "plugin_detail_popup_installable", + strip_osc8_for_snapshot(&popup) + ); +} + +#[tokio::test] +async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + true, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert!( + !popup.contains("Data shared with this app is subject to the app's"), + "expected installed plugin details to hide the disclosure line, got:\n{popup}" + ); + assert_snapshot!( + "plugin_detail_popup_installed", + strip_osc8_for_snapshot(&popup) + ); } #[tokio::test] diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index d4cfd6d1f4..8a4dc760fa 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,5 +1,6 @@ mod auth; pub mod onboarding_screen; mod trust_directory; +pub(crate) use auth::mark_url_hyperlink; pub use trust_directory::TrustDirectorySelection; mod welcome; diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index e44081f761..ce08cc8791 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -8,6 +8,7 @@ use crate::bottom_pane::ColumnWidthMode; use crate::bottom_pane::SelectionItem; use crate::bottom_pane::SelectionViewParams; use crate::history_cell; +use crate::onboarding::mark_url_hyperlink; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::shimmer::shimmer_spans; @@ -24,10 +25,12 @@ use codex_features::Feature; use codex_utils_absolute_path::AbsolutePathBuf; use ratatui::buffer::Buffer; use ratatui::layout::Rect; +use ratatui::prelude::Widget; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; +use ratatui::widgets::Wrap; const PLUGINS_SELECTION_VIEW_ID: &str = "plugins-selection"; const LOADING_ANIMATION_DELAY: Duration = Duration::from_secs(1); @@ -93,6 +96,29 @@ impl Renderable for DelayedLoadingHeader { } } +const APPS_HELP_ARTICLE_URL: &str = "https://help.openai.com/en/articles/11487775-apps-in-chatgpt"; + +struct PluginDisclosureLine { + line: Line<'static>, +} + +impl Renderable for PluginDisclosureLine { + fn render(&self, area: Rect, buf: &mut Buffer) { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .render(area, buf); + mark_url_hyperlink(buf, area, APPS_HELP_ARTICLE_URL); + } + + fn desired_height(&self, width: u16) -> u16 { + Paragraph::new(self.line.clone()) + .wrap(Wrap { trim: false }) + .line_count(width) + .try_into() + .unwrap_or(u16::MAX) + } +} + #[derive(Debug, Clone, Default)] pub(super) enum PluginsCacheState { #[default] @@ -812,13 +838,37 @@ impl ChatWidget { ) -> SelectionViewParams { let marketplace_label = plugin.marketplace_name.clone(); let display_name = plugin_display_name(&plugin.summary); - let status_label = plugin_status_label(&plugin.summary); + let detail_status_label = if plugin.summary.installed { + if plugin.summary.enabled { + "Installed" + } else { + "Installed · Disabled" + } + } else { + match plugin.summary.install_policy { + PluginInstallPolicy::NotAvailable => "Not installable", + PluginInstallPolicy::Available => "Can be installed", + PluginInstallPolicy::InstalledByDefault => "Available by default", + } + }; let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( - format!("{display_name} · {marketplace_label}").bold(), + format!("{display_name} · {detail_status_label} · {marketplace_label}").bold(), )); - header.push(Line::from(status_label.dim())); + if !plugin.summary.installed { + header.push(PluginDisclosureLine { + line: Line::from(vec![ + "Data shared with this app is subject to the app's ".into(), + "terms of service".bold(), + " and ".into(), + "privacy policy".bold(), + ". ".into(), + "Learn more".cyan().underlined(), + ".".into(), + ]), + }); + } if let Some(description) = plugin_detail_description(plugin) { header.push(Line::from(description.dim())); } diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap index 399f160799..487141a2ae 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installable.snap @@ -3,8 +3,9 @@ source: tui_app_server/src/chatwidget/tests.rs expression: popup --- Plugins - Figma · ChatGPT Marketplace - Available + Figma · Can be installed · ChatGPT Marketplace + Data shared with this app is subject to the app's terms of service and privacy policy. Learn + more. Turn Figma files into implementation context. › 1. Back to plugins Return to the plugin list. diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap new file mode 100644 index 0000000000..651a323aa2 --- /dev/null +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__plugin_detail_popup_installed.snap @@ -0,0 +1,15 @@ +--- +source: tui_app_server/src/chatwidget/tests.rs +expression: popup +--- + Plugins + Figma · Installed · ChatGPT Marketplace + Turn Figma files into implementation context. + +› 1. Back to plugins Return to the plugin list. + 2. Uninstall plugin Remove this plugin now. + Skills design-review, extract-copy + Apps Figma, Slack + MCP Servers figma-mcp, docs-mcp + + Press esc to close. diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index a684806128..c3c2ef7692 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7680,6 +7680,40 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { lines.join("\n") } +fn strip_osc8_for_snapshot(text: &str) -> String { + // Snapshots should assert the visible popup text, not terminal hyperlink escapes. + let bytes = text.as_bytes(); + let mut stripped = String::with_capacity(text.len()); + let mut i = 0; + + while i < bytes.len() { + if bytes[i..].starts_with(b"\x1B]8;;") { + i += 5; + while i < bytes.len() { + if bytes[i] == b'\x07' { + i += 1; + break; + } + if i + 1 < bytes.len() && bytes[i] == b'\x1B' && bytes[i + 1] == b'\\' { + i += 2; + break; + } + i += 1; + } + continue; + } + + let ch = text[i..] + .chars() + .next() + .expect("slice should always contain a char"); + stripped.push(ch); + i += ch.len_utf8(); + } + + stripped +} + fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { AbsolutePathBuf::try_from( std::env::temp_dir() @@ -7935,7 +7969,54 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa ); let popup = render_bottom_popup(&chat, 100); - assert_snapshot!("plugin_detail_popup_installable", popup); + assert_snapshot!( + "plugin_detail_popup_installable", + strip_osc8_for_snapshot(&popup) + ); +} + +#[tokio::test] +async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + chat.set_feature_enabled(Feature::Plugins, true); + + let summary = plugins_test_summary( + "plugin-figma", + "figma", + Some("Figma"), + Some("Design handoff."), + true, + true, + PluginInstallPolicy::Available, + ); + let response = plugins_test_response(vec![plugins_test_curated_marketplace(vec![ + summary.clone(), + ])]); + let cwd = chat.config.cwd.clone(); + chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.add_plugins_output(); + chat.on_plugin_detail_loaded( + cwd, + Ok(PluginReadResponse { + plugin: plugins_test_detail( + summary, + Some("Turn Figma files into implementation context."), + &["design-review", "extract-copy"], + &[("Figma", true), ("Slack", false)], + &["figma-mcp", "docs-mcp"], + ), + }), + ); + + let popup = render_bottom_popup(&chat, 100); + assert!( + !popup.contains("Data shared with this app is subject to the app's"), + "expected installed plugin details to hide the disclosure line, got:\n{popup}" + ); + assert_snapshot!( + "plugin_detail_popup_installed", + strip_osc8_for_snapshot(&popup) + ); } #[tokio::test] diff --git a/codex-rs/tui_app_server/src/onboarding/mod.rs b/codex-rs/tui_app_server/src/onboarding/mod.rs index d4cfd6d1f4..8a4dc760fa 100644 --- a/codex-rs/tui_app_server/src/onboarding/mod.rs +++ b/codex-rs/tui_app_server/src/onboarding/mod.rs @@ -1,5 +1,6 @@ mod auth; pub mod onboarding_screen; mod trust_directory; +pub(crate) use auth::mark_url_hyperlink; pub use trust_directory::TrustDirectorySelection; mod welcome; From ea3f3467e29a3870132632ba3ef76df1554c5cdc Mon Sep 17 00:00:00 2001 From: evawong-oai Date: Tue, 24 Mar 2026 17:55:06 -0700 Subject: [PATCH 14/50] Expand ~ in MDM workspace write roots (#15351) ## Summary - Reuse the existing config path resolver for the macOS MDM managed preferences layer so `writable_roots = ["~/code"]` expands the same way as file-backed config - keep the change scoped to the MDM branch in `config_loader`; the current net diff is only `config_loader/mod.rs` plus focused regression tests in `config_loader/tests.rs` and `config/service_tests.rs` - research note: `resolve_relative_paths_in_config_toml(...)` is already used in several existing configuration paths, including [CLI overrides](https://github.com/openai/codex/blob/74fda242d3651f0a43ec8657bdbc7bde426dce0e/codex-rs/core/src/config_loader/mod.rs#L152-L163), [file-backed managed config](https://github.com/openai/codex/blob/74fda242d3651f0a43ec8657bdbc7bde426dce0e/codex-rs/core/src/config_loader/mod.rs#L274-L285), [normal config-file loading](https://github.com/openai/codex/blob/74fda242d3651f0a43ec8657bdbc7bde426dce0e/codex-rs/core/src/config_loader/mod.rs#L311-L331), [project `.codex/config.toml` loading](https://github.com/openai/codex/blob/74fda242d3651f0a43ec8657bdbc7bde426dce0e/codex-rs/core/src/config_loader/mod.rs#L863-L865), and [role config loading](https://github.com/openai/codex/blob/74fda242d3651f0a43ec8657bdbc7bde426dce0e/codex-rs/core/src/agent/role.rs#L105-L109) ## Validation - `cargo fmt --all --check` - `cargo test -p codex-core managed_preferences_expand_home_directory_in_workspace_write_roots -- --nocapture` - `cargo test -p codex-core write_value_succeeds_when_managed_preferences_expand_home_directory_paths -- --nocapture` --------- Co-authored-by: Michael Bolin Co-authored-by: Michael Bolin --- codex-rs/core/src/config/service_tests.rs | 48 +++++++++++++++++++++++ codex-rs/core/src/config_loader/mod.rs | 4 +- codex-rs/core/src/config_loader/tests.rs | 48 +++++++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) diff --git a/codex-rs/core/src/config/service_tests.rs b/codex-rs/core/src/config/service_tests.rs index bc3006a9a9..a1897c9ddd 100644 --- a/codex-rs/core/src/config/service_tests.rs +++ b/codex-rs/core/src/config/service_tests.rs @@ -237,6 +237,54 @@ async fn read_includes_origins_and_layers() { )); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> { + use base64::Engine; + + let tmp = tempdir().expect("tempdir"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?; + + let service = ConfigService::new( + tmp.path().to_path_buf(), + vec![], + LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, + }, + CloudRequirementsLoader::default(), + ); + + let response = service + .write_value(ConfigValueWriteParams { + file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()), + key_path: "model".to_string(), + value: serde_json::json!("updated"), + merge_strategy: MergeStrategy::Replace, + expected_version: None, + }) + .await + .expect("write succeeds"); + + assert_eq!(response.status, WriteStatus::Ok); + assert_eq!( + std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"), + "model = \"updated\"\n" + ); + + Ok(()) +} + #[tokio::test] async fn write_value_reports_override() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index e7d1e634e8..767ffe635d 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -285,9 +285,11 @@ pub async fn load_config_layers_state( )); } if let Some(config) = managed_config_from_mdm { + let managed_config = + resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, - config.managed_config, + managed_config, config.raw_toml, )); } diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 03be02ebfe..6a2bc0b6b9 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -380,6 +380,54 @@ flag = false assert!(raw.contains("value = \"managed\"")); } +#[cfg(target_os = "macos")] +#[tokio::test] +async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()> +{ + use base64::Engine; + + let Some(home) = dirs::home_dir() else { + return Ok(()); + }; + let tmp = tempdir()?; + + let config = ConfigBuilder::default() + .codex_home(tmp.path().to_path_buf()) + .fallback_cwd(Some(tmp.path().to_path_buf())) + .loader_overrides(LoaderOverrides { + managed_config_path: Some(tmp.path().join("managed_config.toml")), + managed_preferences_base64: Some( + base64::prelude::BASE64_STANDARD.encode( + r#" +sandbox_mode = "workspace-write" +[sandbox_workspace_write] +writable_roots = ["~/code"] +"# + .as_bytes(), + ), + ), + macos_managed_config_requirements_base64: None, + }) + .build() + .await?; + + let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?; + match config.permissions.sandbox_policy.get() { + SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + assert_eq!( + writable_roots + .iter() + .filter(|root| **root == expected_root) + .count(), + 1, + ); + } + other => panic!("expected workspace-write policy, got {other:?}"), + } + + Ok(()) +} + #[cfg(target_os = "macos")] #[tokio::test] async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> { From 2e03d8b4d247e762bbd7a3756d5e8a227883bd79 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 24 Mar 2026 18:10:53 -0700 Subject: [PATCH 15/50] Extract rollout into its own crate (#15548) --- codex-rs/Cargo.lock | 39 ++++++- codex-rs/Cargo.toml | 4 + codex-rs/core/Cargo.toml | 9 +- codex-rs/core/src/codex.rs | 2 +- codex-rs/core/src/lib.rs | 10 +- codex-rs/core/src/rollout.rs | 63 +++++++++++ ...error.rs => session_rollout_init_error.rs} | 0 codex-rs/core/src/state_db_bridge.rs | 21 ++++ ...cation.rs => thread_rollout_truncation.rs} | 2 +- ....rs => thread_rollout_truncation_tests.rs} | 0 codex-rs/core/src/utils/mod.rs | 1 + codex-rs/core/src/utils/path_utils.rs | 1 + codex-rs/rollout/BUILD.bazel | 6 ++ codex-rs/rollout/Cargo.toml | 49 +++++++++ codex-rs/rollout/src/config.rs | 100 ++++++++++++++++++ .../src/rollout/mod.rs => rollout/src/lib.rs} | 32 ++++-- .../{core/src/rollout => rollout/src}/list.rs | 18 ++-- .../src/rollout => rollout/src}/metadata.rs | 47 ++++---- .../rollout => rollout/src}/metadata_tests.rs | 25 +++-- .../src/rollout => rollout/src}/policy.rs | 9 +- .../src/rollout => rollout/src}/recorder.rs | 45 ++++---- .../rollout => rollout/src}/recorder_tests.rs | 65 ++++-------- .../rollout => rollout/src}/session_index.rs | 0 .../src}/session_index_tests.rs | 2 + codex-rs/{core => rollout}/src/state_db.rs | 33 +++--- .../{core => rollout}/src/state_db_tests.rs | 9 +- .../src/rollout => rollout/src}/tests.rs | 22 ++-- codex-rs/utils/path-utils/BUILD.bazel | 6 ++ codex-rs/utils/path-utils/Cargo.toml | 17 +++ .../{core => utils/path-utils}/src/env.rs | 0 .../path-utils/src/lib.rs} | 6 +- .../path-utils}/src/path_utils_tests.rs | 0 32 files changed, 475 insertions(+), 168 deletions(-) create mode 100644 codex-rs/core/src/rollout.rs rename codex-rs/core/src/{rollout/error.rs => session_rollout_init_error.rs} (100%) create mode 100644 codex-rs/core/src/state_db_bridge.rs rename codex-rs/core/src/{rollout/truncation.rs => thread_rollout_truncation.rs} (98%) rename codex-rs/core/src/{rollout/truncation_tests.rs => thread_rollout_truncation_tests.rs} (100%) create mode 100644 codex-rs/core/src/utils/mod.rs create mode 100644 codex-rs/core/src/utils/path_utils.rs create mode 100644 codex-rs/rollout/BUILD.bazel create mode 100644 codex-rs/rollout/Cargo.toml create mode 100644 codex-rs/rollout/src/config.rs rename codex-rs/{core/src/rollout/mod.rs => rollout/src/lib.rs} (68%) rename codex-rs/{core/src/rollout => rollout/src}/list.rs (99%) rename codex-rs/{core/src/rollout => rollout/src}/metadata.rs (93%) rename codex-rs/{core/src/rollout => rollout/src}/metadata_tests.rs (96%) rename codex-rs/{core/src/rollout => rollout/src}/policy.rs (95%) rename codex-rs/{core/src/rollout => rollout/src}/recorder.rs (97%) rename codex-rs/{core/src/rollout => rollout/src}/recorder_tests.rs (91%) rename codex-rs/{core/src/rollout => rollout/src}/session_index.rs (100%) rename codex-rs/{core/src/rollout => rollout/src}/session_index_tests.rs (99%) rename codex-rs/{core => rollout}/src/state_db.rs (95%) rename codex-rs/{core => rollout}/src/state_db_tests.rs (80%) rename codex-rs/{core/src/rollout => rollout/src}/tests.rs (98%) create mode 100644 codex-rs/utils/path-utils/BUILD.bazel create mode 100644 codex-rs/utils/path-utils/Cargo.toml rename codex-rs/{core => utils/path-utils}/src/env.rs (100%) rename codex-rs/{core/src/path_utils.rs => utils/path-utils/src/lib.rs} (98%) rename codex-rs/{core => utils/path-utils}/src/path_utils_tests.rs (100%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 697b8f5d32..9ecf9b1de4 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1881,7 +1881,6 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", - "codex-file-search", "codex-git-utils", "codex-hooks", "codex-login", @@ -1889,6 +1888,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-rmcp-client", + "codex-rollout", "codex-sandboxing", "codex-secrets", "codex-shell-command", @@ -1903,6 +1903,7 @@ dependencies = [ "codex-utils-home-dir", "codex-utils-image", "codex-utils-output-truncation", + "codex-utils-path", "codex-utils-pty", "codex-utils-readiness", "codex-utils-stream-parser", @@ -1950,7 +1951,6 @@ dependencies = [ "test-case", "test-log", "thiserror 2.0.18", - "time", "tokio", "tokio-tungstenite", "tokio-util", @@ -2469,6 +2469,31 @@ dependencies = [ "which 8.0.0", ] +[[package]] +name = "codex-rollout" +version = "0.0.0" +dependencies = [ + "anyhow", + "async-trait", + "chrono", + "codex-file-search", + "codex-git-utils", + "codex-login", + "codex-otel", + "codex-protocol", + "codex-state", + "codex-utils-path", + "codex-utils-string", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "time", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "codex-sandboxing" version = "0.0.0" @@ -2902,6 +2927,16 @@ dependencies = [ "pretty_assertions", ] +[[package]] +name = "codex-utils-path" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "dunce", + "pretty_assertions", + "tempfile", +] + [[package]] name = "codex-utils-pty" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ebd88c945f..5854c1c46d 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -40,6 +40,7 @@ members = [ "ollama", "process-hardening", "protocol", + "rollout", "rmcp-client", "responses-api-proxy", "sandboxing", @@ -66,6 +67,7 @@ members = [ "utils/approval-presets", "utils/oss", "utils/output-truncation", + "utils/path-utils", "utils/fuzzy-match", "utils/stream-parser", "codex-client", @@ -130,6 +132,7 @@ codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } +codex-rollout = { path = "rollout" } codex-responses-api-proxy = { path = "responses-api-proxy" } codex-rmcp-client = { path = "rmcp-client" } codex-sandboxing = { path = "sandboxing" } @@ -156,6 +159,7 @@ codex-utils-image = { path = "utils/image" } codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-oss = { path = "utils/oss" } codex-utils-output-truncation = { path = "utils/output-truncation" } +codex-utils-path = { path = "utils/path-utils" } codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 51dd305b35..a3868e4358 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -40,13 +40,13 @@ codex-login = { workspace = true } codex-shell-command = { workspace = true } codex-skills = { workspace = true } codex-execpolicy = { workspace = true } -codex-file-search = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-artifacts = { workspace = true } codex-protocol = { workspace = true } +codex-rollout = { workspace = true } codex-rmcp-client = { workspace = true } codex-sandboxing = { workspace = true } codex-state = { workspace = true } @@ -56,6 +56,7 @@ codex-utils-cache = { workspace = true } codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } +codex-utils-path = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } @@ -95,12 +96,6 @@ similar = { workspace = true } tempfile = { workspace = true } test-log = { workspace = true } thiserror = { workspace = true } -time = { workspace = true, features = [ - "formatting", - "parsing", - "local-offset", - "macros", -] } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2f8c2877f3..d699ec5a87 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -1570,7 +1570,7 @@ impl Session { })?; let rollout_path = rollout_recorder .as_ref() - .map(|rec| rec.rollout_path.clone()); + .map(|rec| rec.rollout_path().to_path_buf()); let mut post_session_configured_events = Vec::::new(); diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d4a8ae93aa..71f4328aed 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -32,7 +32,7 @@ pub mod connectors; mod context_manager; mod contextual_user_message; pub mod custom_prompts; -pub mod env; +pub use codex_utils_path::env; mod environment_context; pub mod error; pub mod exec; @@ -65,7 +65,8 @@ pub mod mention_syntax; mod mentions; pub mod message_history; mod model_provider_info; -pub mod path_utils; +pub mod utils; +pub use utils::path_utils; pub mod personality_migration; pub mod plugins; mod sandbox_tags; @@ -123,11 +124,14 @@ pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; +mod session_rollout_init_error; pub mod shell; pub mod shell_snapshot; pub mod skills; pub mod spawn; -pub mod state_db; +pub mod state_db_bridge; +pub use codex_rollout::state_db; +mod thread_rollout_truncation; mod tools; pub mod turn_diff_tracker; mod turn_metadata; diff --git a/codex-rs/core/src/rollout.rs b/codex-rs/core/src/rollout.rs new file mode 100644 index 0000000000..c3a7218710 --- /dev/null +++ b/codex-rs/core/src/rollout.rs @@ -0,0 +1,63 @@ +use crate::config::Config; +pub use codex_rollout::ARCHIVED_SESSIONS_SUBDIR; +pub use codex_rollout::INTERACTIVE_SESSION_SOURCES; +pub use codex_rollout::RolloutRecorder; +pub use codex_rollout::RolloutRecorderParams; +pub use codex_rollout::SESSIONS_SUBDIR; +pub use codex_rollout::SessionMeta; +pub use codex_rollout::append_thread_name; +pub use codex_rollout::find_archived_thread_path_by_id_str; +#[deprecated(note = "use find_thread_path_by_id_str")] +pub use codex_rollout::find_conversation_path_by_id_str; +pub use codex_rollout::find_thread_name_by_id; +pub use codex_rollout::find_thread_path_by_id_str; +pub use codex_rollout::find_thread_path_by_name_str; +pub use codex_rollout::rollout_date_parts; + +impl codex_rollout::RolloutConfigView for Config { + fn codex_home(&self) -> &std::path::Path { + self.codex_home.as_path() + } + + fn sqlite_home(&self) -> &std::path::Path { + self.sqlite_home.as_path() + } + + fn cwd(&self) -> &std::path::Path { + self.cwd.as_path() + } + + fn model_provider_id(&self) -> &str { + self.model_provider_id.as_str() + } + + fn generate_memories(&self) -> bool { + self.memories.generate_memories + } +} + +pub mod list { + pub use codex_rollout::list::*; +} + +pub(crate) mod metadata { + pub(crate) use codex_rollout::metadata::builder_from_items; +} + +pub mod policy { + pub use codex_rollout::policy::*; +} + +pub mod recorder { + pub use codex_rollout::recorder::*; +} + +pub mod session_index { + pub use codex_rollout::session_index::*; +} + +pub(crate) use crate::session_rollout_init_error::map_session_init_error; + +pub(crate) mod truncation { + pub(crate) use crate::thread_rollout_truncation::*; +} diff --git a/codex-rs/core/src/rollout/error.rs b/codex-rs/core/src/session_rollout_init_error.rs similarity index 100% rename from codex-rs/core/src/rollout/error.rs rename to codex-rs/core/src/session_rollout_init_error.rs diff --git a/codex-rs/core/src/state_db_bridge.rs b/codex-rs/core/src/state_db_bridge.rs new file mode 100644 index 0000000000..f073c498b5 --- /dev/null +++ b/codex-rs/core/src/state_db_bridge.rs @@ -0,0 +1,21 @@ +use codex_rollout::state_db as rollout_state_db; +pub use codex_rollout::state_db::StateDbHandle; +pub use codex_rollout::state_db::apply_rollout_items; +pub use codex_rollout::state_db::find_rollout_path_by_id; +pub use codex_rollout::state_db::get_dynamic_tools; +pub use codex_rollout::state_db::list_thread_ids_db; +pub use codex_rollout::state_db::list_threads_db; +pub use codex_rollout::state_db::mark_thread_memory_mode_polluted; +pub use codex_rollout::state_db::normalize_cwd_for_state_db; +pub use codex_rollout::state_db::open_if_present; +pub use codex_rollout::state_db::persist_dynamic_tools; +pub use codex_rollout::state_db::read_repair_rollout_path; +pub use codex_rollout::state_db::reconcile_rollout; +pub use codex_rollout::state_db::touch_thread_updated_at; +pub use codex_state::LogEntry; + +use crate::config::Config; + +pub async fn get_state_db(config: &Config) -> Option { + rollout_state_db::get_state_db(config).await +} diff --git a/codex-rs/core/src/rollout/truncation.rs b/codex-rs/core/src/thread_rollout_truncation.rs similarity index 98% rename from codex-rs/core/src/rollout/truncation.rs rename to codex-rs/core/src/thread_rollout_truncation.rs index fee84974fe..5fa1881ffa 100644 --- a/codex-rs/core/src/rollout/truncation.rs +++ b/codex-rs/core/src/thread_rollout_truncation.rs @@ -69,5 +69,5 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start( } #[cfg(test)] -#[path = "truncation_tests.rs"] +#[path = "thread_rollout_truncation_tests.rs"] mod tests; diff --git a/codex-rs/core/src/rollout/truncation_tests.rs b/codex-rs/core/src/thread_rollout_truncation_tests.rs similarity index 100% rename from codex-rs/core/src/rollout/truncation_tests.rs rename to codex-rs/core/src/thread_rollout_truncation_tests.rs diff --git a/codex-rs/core/src/utils/mod.rs b/codex-rs/core/src/utils/mod.rs new file mode 100644 index 0000000000..adbdcef12b --- /dev/null +++ b/codex-rs/core/src/utils/mod.rs @@ -0,0 +1 @@ +pub mod path_utils; diff --git a/codex-rs/core/src/utils/path_utils.rs b/codex-rs/core/src/utils/path_utils.rs new file mode 100644 index 0000000000..3715857d1a --- /dev/null +++ b/codex-rs/core/src/utils/path_utils.rs @@ -0,0 +1 @@ +pub use codex_utils_path::*; diff --git a/codex-rs/rollout/BUILD.bazel b/codex-rs/rollout/BUILD.bazel new file mode 100644 index 0000000000..a91a3dd507 --- /dev/null +++ b/codex-rs/rollout/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "rollout", + crate_name = "codex_rollout", +) diff --git a/codex-rs/rollout/Cargo.toml b/codex-rs/rollout/Cargo.toml new file mode 100644 index 0000000000..ef5a8dc22a --- /dev/null +++ b/codex-rs/rollout/Cargo.toml @@ -0,0 +1,49 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-rollout" +version.workspace = true + +[lib] +doctest = false +name = "codex_rollout" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +async-trait = { workspace = true } +chrono = { workspace = true, features = ["serde"] } +codex-file-search = { workspace = true } +codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +codex-state = { workspace = true } +codex-utils-path = { workspace = true } +codex-utils-string = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +time = { workspace = true, features = [ + "formatting", + "local-offset", + "macros", + "parsing", +] } +tokio = { workspace = true, features = [ + "fs", + "io-util", + "macros", + "process", + "rt", + "sync", + "time", +] } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/rollout/src/config.rs b/codex-rs/rollout/src/config.rs new file mode 100644 index 0000000000..420949bfbd --- /dev/null +++ b/codex-rs/rollout/src/config.rs @@ -0,0 +1,100 @@ +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +pub trait RolloutConfigView { + fn codex_home(&self) -> &Path; + fn sqlite_home(&self) -> &Path; + fn cwd(&self) -> &Path; + fn model_provider_id(&self) -> &str; + fn generate_memories(&self) -> bool; +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RolloutConfig { + pub codex_home: PathBuf, + pub sqlite_home: PathBuf, + pub cwd: PathBuf, + pub model_provider_id: String, + pub generate_memories: bool, +} + +pub type Config = RolloutConfig; + +impl RolloutConfig { + pub fn from_view(view: &impl RolloutConfigView) -> Self { + Self { + codex_home: view.codex_home().to_path_buf(), + sqlite_home: view.sqlite_home().to_path_buf(), + cwd: view.cwd().to_path_buf(), + model_provider_id: view.model_provider_id().to_string(), + generate_memories: view.generate_memories(), + } + } +} + +impl RolloutConfigView for RolloutConfig { + fn codex_home(&self) -> &Path { + self.codex_home.as_path() + } + + fn sqlite_home(&self) -> &Path { + self.sqlite_home.as_path() + } + + fn cwd(&self) -> &Path { + self.cwd.as_path() + } + + fn model_provider_id(&self) -> &str { + self.model_provider_id.as_str() + } + + fn generate_memories(&self) -> bool { + self.generate_memories + } +} + +impl RolloutConfigView for &T { + fn codex_home(&self) -> &Path { + (*self).codex_home() + } + + fn sqlite_home(&self) -> &Path { + (*self).sqlite_home() + } + + fn cwd(&self) -> &Path { + (*self).cwd() + } + + fn model_provider_id(&self) -> &str { + (*self).model_provider_id() + } + + fn generate_memories(&self) -> bool { + (*self).generate_memories() + } +} + +impl RolloutConfigView for Arc { + fn codex_home(&self) -> &Path { + self.as_ref().codex_home() + } + + fn sqlite_home(&self) -> &Path { + self.as_ref().sqlite_home() + } + + fn cwd(&self) -> &Path { + self.as_ref().cwd() + } + + fn model_provider_id(&self) -> &str { + self.as_ref().model_provider_id() + } + + fn generate_memories(&self) -> bool { + self.as_ref().generate_memories() + } +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/rollout/src/lib.rs similarity index 68% rename from codex-rs/core/src/rollout/mod.rs rename to codex-rs/rollout/src/lib.rs index 3b8ad9b412..160792a390 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/rollout/src/lib.rs @@ -1,9 +1,23 @@ -//! Rollout module: persistence and discovery of session rollout files. +//! Rollout persistence and discovery for Codex session files. use std::sync::LazyLock; use codex_protocol::protocol::SessionSource; +pub mod config; +pub mod list; +pub mod metadata; +pub mod policy; +pub mod recorder; +pub mod session_index; +pub mod state_db; + +pub(crate) mod default_client { + pub use codex_login::default_client::*; +} + +pub(crate) use codex_protocol::protocol; + pub const SESSIONS_SUBDIR: &str = "sessions"; pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions"; pub static INTERACTIVE_SESSION_SOURCES: LazyLock> = LazyLock::new(|| { @@ -15,26 +29,22 @@ pub static INTERACTIVE_SESSION_SOURCES: LazyLock> = LazyLock: ] }); -pub(crate) mod error; -pub mod list; -pub(crate) mod metadata; -pub(crate) mod policy; -pub mod recorder; -pub(crate) mod session_index; -pub(crate) mod truncation; - pub use codex_protocol::protocol::SessionMeta; -pub(crate) use error::map_session_init_error; +pub use config::RolloutConfig; +pub use config::RolloutConfigView; pub use list::find_archived_thread_path_by_id_str; pub use list::find_thread_path_by_id_str; #[deprecated(note = "use find_thread_path_by_id_str")] pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str; pub use list::rollout_date_parts; +pub use policy::EventPersistenceMode; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; pub use session_index::append_thread_name; pub use session_index::find_thread_name_by_id; +pub use session_index::find_thread_names_by_ids; pub use session_index::find_thread_path_by_name_str; +pub use state_db::StateDbHandle; #[cfg(test)] -pub mod tests; +mod tests; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/rollout/src/list.rs similarity index 99% rename from codex-rs/core/src/rollout/list.rs rename to codex-rs/rollout/src/list.rs index 6ae5908d8a..e7d3dae5de 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/rollout/src/list.rs @@ -1,3 +1,5 @@ +#![allow(warnings, clippy::all)] + use async_trait::async_trait; use std::cmp::Reverse; use std::ffi::OsStr; @@ -111,16 +113,16 @@ pub enum ThreadSortKey { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ThreadListLayout { +pub enum ThreadListLayout { NestedByDate, Flat, } -pub(crate) struct ThreadListConfig<'a> { - pub(crate) allowed_sources: &'a [SessionSource], - pub(crate) model_providers: Option<&'a [String]>, - pub(crate) default_provider: &'a str, - pub(crate) layout: ThreadListLayout, +pub struct ThreadListConfig<'a> { + pub allowed_sources: &'a [SessionSource], + pub model_providers: Option<&'a [String]>, + pub default_provider: &'a str, + pub layout: ThreadListLayout, } /// Pagination cursor identifying a file by timestamp and UUID. @@ -300,7 +302,7 @@ impl From for Cursor { /// can be supplied on the next call to resume after the last returned item, resilient to /// concurrent new sessions being appended. Ordering is stable by the requested sort key /// (timestamp desc, then UUID desc). -pub(crate) async fn get_threads( +pub async fn get_threads( codex_home: &Path, page_size: usize, cursor: Option<&Cursor>, @@ -325,7 +327,7 @@ pub(crate) async fn get_threads( .await } -pub(crate) async fn get_threads_in_root( +pub async fn get_threads_in_root( root: PathBuf, page_size: usize, cursor: Option<&Cursor>, diff --git a/codex-rs/core/src/rollout/metadata.rs b/codex-rs/rollout/src/metadata.rs similarity index 93% rename from codex-rs/core/src/rollout/metadata.rs rename to codex-rs/rollout/src/metadata.rs index f3d15c37cc..51ebb5ef1e 100644 --- a/codex-rs/core/src/rollout/metadata.rs +++ b/codex-rs/rollout/src/metadata.rs @@ -1,7 +1,9 @@ -use crate::config::Config; -use crate::rollout; -use crate::rollout::list::parse_timestamp_uuid_from_filename; -use crate::rollout::recorder::RolloutRecorder; +use crate::ARCHIVED_SESSIONS_SUBDIR; +use crate::SESSIONS_SUBDIR; +use crate::config::RolloutConfigView; +use crate::list; +use crate::list::parse_timestamp_uuid_from_filename; +use crate::recorder::RolloutRecorder; use crate::state_db::normalize_cwd_for_state_db; use chrono::DateTime; use chrono::NaiveDateTime; @@ -62,7 +64,7 @@ pub(crate) fn builder_from_session_meta( Some(builder) } -pub(crate) fn builder_from_items( +pub fn builder_from_items( items: &[RolloutItem], rollout_path: &Path, ) -> Option { @@ -93,7 +95,7 @@ pub(crate) fn builder_from_items( )) } -pub(crate) async fn extract_metadata_from_rollout( +pub async fn extract_metadata_from_rollout( rollout_path: &Path, default_provider: &str, ) -> anyhow::Result { @@ -131,7 +133,10 @@ pub(crate) async fn extract_metadata_from_rollout( }) } -pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, config: &Config) { +pub(crate) async fn backfill_sessions( + runtime: &codex_state::StateRuntime, + config: &impl RolloutConfigView, +) { let metric_client = codex_otel::metrics::global(); let timer = metric_client .as_ref() @@ -141,7 +146,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi Err(err) => { warn!( "failed to read backfill state at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); BackfillState::default() } @@ -154,7 +159,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi Err(err) => { warn!( "failed to claim backfill worker at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); return; } @@ -162,7 +167,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi if !claimed { info!( "state db backfill already running at {}; skipping duplicate worker", - config.codex_home.display() + config.codex_home().display() ); return; } @@ -171,7 +176,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi Err(err) => { warn!( "failed to read claimed backfill state at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); BackfillState { status: BackfillStatus::Running, @@ -183,15 +188,15 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi if let Err(err) = runtime.mark_backfill_running().await { warn!( "failed to mark backfill running at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); } else { backfill_state.status = BackfillStatus::Running; } } - let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR); - let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR); + let sessions_root = config.codex_home().join(SESSIONS_SUBDIR); + let archived_root = config.codex_home().join(ARCHIVED_SESSIONS_SUBDIR); let mut rollout_paths: Vec = Vec::new(); for (root, archived) in [(sessions_root, false), (archived_root, true)] { if !tokio::fs::try_exists(&root).await.unwrap_or(false) { @@ -200,7 +205,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi match collect_rollout_paths(&root).await { Ok(paths) => { rollout_paths.extend(paths.into_iter().map(|path| BackfillRolloutPath { - watermark: backfill_watermark_for_path(config.codex_home.as_path(), &path), + watermark: backfill_watermark_for_path(config.codex_home(), &path), path, archived, })); @@ -227,9 +232,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi for batch in rollout_paths.chunks(BACKFILL_BATCH_SIZE) { for rollout in batch { stats.scanned = stats.scanned.saturating_add(1); - match extract_metadata_from_rollout(&rollout.path, config.model_provider_id.as_str()) - .await - { + match extract_metadata_from_rollout(&rollout.path, config.model_provider_id()).await { Ok(outcome) => { if outcome.parse_errors > 0 && let Some(ref metric_client) = metric_client @@ -268,9 +271,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi continue; } stats.upserted = stats.upserted.saturating_add(1); - if let Ok(meta_line) = - rollout::list::read_session_meta_line(&rollout.path).await - { + if let Ok(meta_line) = list::read_session_meta_line(&rollout.path).await { if let Err(err) = runtime .persist_dynamic_tools( meta_line.meta.id, @@ -308,7 +309,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi { warn!( "failed to checkpoint backfill at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); } else { last_watermark = Some(last_entry.watermark.clone()); @@ -321,7 +322,7 @@ pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, confi { warn!( "failed to mark backfill complete at {}: {err}", - config.codex_home.display() + config.codex_home().display() ); } diff --git a/codex-rs/core/src/rollout/metadata_tests.rs b/codex-rs/rollout/src/metadata_tests.rs similarity index 96% rename from codex-rs/core/src/rollout/metadata_tests.rs rename to codex-rs/rollout/src/metadata_tests.rs index 85500e6230..e086806628 100644 --- a/codex-rs/core/src/rollout/metadata_tests.rs +++ b/codex-rs/rollout/src/metadata_tests.rs @@ -1,4 +1,7 @@ +#![allow(warnings, clippy::all)] + use super::*; +use crate::config::RolloutConfig; use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Timelike; @@ -21,6 +24,16 @@ use std::path::PathBuf; use tempfile::tempdir; use uuid::Uuid; +fn test_config(codex_home: PathBuf) -> RolloutConfig { + RolloutConfig { + sqlite_home: codex_home.clone(), + cwd: codex_home.clone(), + codex_home, + model_provider_id: "test-provider".to_string(), + generate_memories: true, + } +} + #[tokio::test] async fn extract_metadata_from_rollout_uses_session_meta() { let dir = tempdir().expect("tempdir"); @@ -197,9 +210,7 @@ async fn backfill_sessions_resumes_from_watermark_and_marks_complete() { )) .await; - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); + let config = test_config(codex_home.clone()); backfill_sessions(runtime.as_ref(), &config).await; let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id"); @@ -267,9 +278,7 @@ async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_f .await .expect("existing metadata upsert"); - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); + let config = test_config(codex_home.clone()); backfill_sessions(runtime.as_ref(), &config).await; let persisted = runtime @@ -304,9 +313,7 @@ async fn backfill_sessions_normalizes_cwd_before_upsert() { .await .expect("initialize runtime"); - let mut config = crate::config::test_config(); - config.codex_home = codex_home.clone(); - config.model_provider_id = "test-provider".to_string(); + let config = test_config(codex_home.clone()); backfill_sessions(runtime.as_ref(), &config).await; let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id"); diff --git a/codex-rs/core/src/rollout/policy.rs b/codex-rs/rollout/src/policy.rs similarity index 95% rename from codex-rs/core/src/rollout/policy.rs rename to codex-rs/rollout/src/policy.rs index 8b1f94dbd5..c4b4b8c339 100644 --- a/codex-rs/core/src/rollout/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -11,8 +11,7 @@ pub enum EventPersistenceMode { /// Whether a rollout `item` should be persisted in rollout files for the /// provided persistence `mode`. -#[inline] -pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool { +pub fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool { match item { RolloutItem::ResponseItem(item) => should_persist_response_item(item), RolloutItem::EventMsg(ev) => should_persist_event_msg(ev, mode), @@ -25,7 +24,7 @@ pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersiste /// Whether a `ResponseItem` should be persisted in rollout files. #[inline] -pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { +pub fn should_persist_response_item(item: &ResponseItem) -> bool { match item { ResponseItem::Message { .. } | ResponseItem::Reasoning { .. } @@ -46,7 +45,7 @@ pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool { /// Whether a `ResponseItem` should be persisted for the memories. #[inline] -pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { +pub fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool { match item { ResponseItem::Message { role, .. } => role != "developer", ResponseItem::LocalShellCall { .. } @@ -68,7 +67,7 @@ pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> /// Whether an `EventMsg` should be persisted in rollout files for the /// provided persistence `mode`. #[inline] -pub(crate) fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool { +pub fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool { match mode { EventPersistenceMode::Limited => should_persist_event_msg_limited(ev), EventPersistenceMode::Extended => should_persist_event_msg_extended(ev), diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/rollout/src/recorder.rs similarity index 97% rename from codex-rs/core/src/rollout/recorder.rs rename to codex-rs/rollout/src/recorder.rs index 30b0734df1..f39c38af60 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/rollout/src/recorder.rs @@ -11,6 +11,7 @@ use chrono::Utc; use codex_protocol::ThreadId; use codex_protocol::dynamic_tools::DynamicToolSpec; use codex_protocol::models::BaseInstructions; +use codex_utils_string::truncate_middle_chars; use serde_json::Value; use time::OffsetDateTime; use time::format_description::FormatItem; @@ -39,9 +40,8 @@ use super::list::parse_timestamp_uuid_from_filename; use super::metadata; use super::policy::EventPersistenceMode; use super::policy::is_persisted_response_item; -use crate::config::Config; +use crate::config::RolloutConfigView; use crate::default_client::originator; -use crate::path_utils; use crate::state_db; use crate::state_db::StateDbHandle; use codex_git_utils::collect_git_info; @@ -56,8 +56,7 @@ use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_state::StateRuntime; use codex_state::ThreadMetadataBuilder; -use codex_utils_output_truncation::TruncationPolicy; -use codex_utils_output_truncation::truncate_text; +use codex_utils_path as path_utils; /// Records all [`ResponseItem`]s for a session and flushes them to disk after /// every update. @@ -146,9 +145,9 @@ fn sanitize_rollout_item_for_persistence( match item { RolloutItem::EventMsg(EventMsg::ExecCommandEnd(mut event)) => { // Persist only a bounded aggregated summary of command output. - event.aggregated_output = truncate_text( + event.aggregated_output = truncate_middle_chars( &event.aggregated_output, - TruncationPolicy::Bytes(PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES), + PERSISTED_EXEC_AGGREGATED_OUTPUT_MAX_BYTES, ); // Drop unnecessary fields from rollout storage since aggregated_output is all we need. event.stdout.clear(); @@ -164,7 +163,7 @@ impl RolloutRecorder { /// List threads (rollout files) under the provided Codex home directory. #[allow(clippy::too_many_arguments)] pub async fn list_threads( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -190,7 +189,7 @@ impl RolloutRecorder { /// List archived threads (rollout files) under the archived sessions directory. #[allow(clippy::too_many_arguments)] pub async fn list_archived_threads( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -215,7 +214,7 @@ impl RolloutRecorder { #[allow(clippy::too_many_arguments)] async fn list_threads_with_db_fallback( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -225,7 +224,7 @@ impl RolloutRecorder { archived: bool, search_term: Option<&str>, ) -> std::io::Result { - let codex_home = config.codex_home.as_path(); + let codex_home = config.codex_home(); // Filesystem-first listing intentionally overfetches so we can repair stale/missing // SQLite rollout paths before the final DB-backed page is returned. let fs_page_size = page_size.saturating_mul(2).max(page_size); @@ -299,7 +298,7 @@ impl RolloutRecorder { /// Find the newest recorded thread path, optionally filtering to a matching cwd. #[allow(clippy::too_many_arguments)] pub async fn find_latest_thread_path( - config: &Config, + config: &impl RolloutConfigView, page_size: usize, cursor: Option<&Cursor>, sort_key: ThreadSortKey, @@ -308,7 +307,7 @@ impl RolloutRecorder { default_provider: &str, filter_cwd: Option<&Path>, ) -> std::io::Result> { - let codex_home = config.codex_home.as_path(); + let codex_home = config.codex_home(); let state_db_ctx = state_db::get_state_db(config).await; if state_db_ctx.is_some() { let mut db_cursor = cursor.cloned(); @@ -369,7 +368,7 @@ impl RolloutRecorder { /// /// For resumed sessions, this immediately opens the existing rollout file. pub async fn new( - config: &Config, + config: &impl RolloutConfigView, params: RolloutRecorderParams, state_db_ctx: Option, state_builder: Option, @@ -401,21 +400,21 @@ impl RolloutRecorder { id: session_id, forked_from_id, timestamp, - cwd: config.cwd.clone(), + cwd: config.cwd().to_path_buf(), originator: originator().value, cli_version: env!("CARGO_PKG_VERSION").to_string(), agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), agent_path: source.get_agent_path().map(Into::into), source, - model_provider: Some(config.model_provider_id.clone()), + model_provider: Some(config.model_provider_id().to_string()), base_instructions: Some(base_instructions), dynamic_tools: if dynamic_tools.is_empty() { None } else { Some(dynamic_tools) }, - memory_mode: (!config.memories.generate_memories) + memory_mode: (!config.generate_memories()) .then_some("disabled".to_string()), }; @@ -445,7 +444,7 @@ impl RolloutRecorder { }; // Clone the cwd for the spawned task to collect git info asynchronously - let cwd = config.cwd.clone(); + let cwd = config.cwd().to_path_buf(); // A reasonably-sized bounded channel. If the buffer fills up the send // future will yield, which is fine – we only need to ensure we do not @@ -463,8 +462,8 @@ impl RolloutRecorder { rollout_path.clone(), state_db_ctx.clone(), state_builder, - config.model_provider_id.clone(), - config.memories.generate_memories, + config.model_provider_id().to_string(), + config.generate_memories(), )); Ok(Self { @@ -483,7 +482,7 @@ impl RolloutRecorder { self.state_db.clone() } - pub(crate) async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> { + pub async fn record_items(&self, items: &[RolloutItem]) -> std::io::Result<()> { let mut filtered = Vec::new(); for item in items { // Note that function calls may look a bit strange if they are @@ -529,7 +528,7 @@ impl RolloutRecorder { .map_err(|e| IoError::other(format!("failed waiting for rollout flush: {e}"))) } - pub(crate) async fn load_rollout_items( + pub async fn load_rollout_items( path: &Path, ) -> std::io::Result<(Vec, Option, usize)> { trace!("Resuming rollout from {path:?}"); @@ -661,13 +660,13 @@ struct LogFileInfo { } fn precompute_log_file_info( - config: &Config, + config: &impl RolloutConfigView, conversation_id: ThreadId, ) -> std::io::Result { // Resolve ~/.codex/sessions/YYYY/MM/DD path. let timestamp = OffsetDateTime::now_local() .map_err(|e| IoError::other(format!("failed to get local time: {e}")))?; - let mut dir = config.codex_home.clone(); + let mut dir = config.codex_home().to_path_buf(); dir.push(SESSIONS_SUBDIR); dir.push(timestamp.year().to_string()); dir.push(format!("{:02}", u8::from(timestamp.month()))); diff --git a/codex-rs/core/src/rollout/recorder_tests.rs b/codex-rs/rollout/src/recorder_tests.rs similarity index 91% rename from codex-rs/core/src/rollout/recorder_tests.rs rename to codex-rs/rollout/src/recorder_tests.rs index 9443c877b4..5a868b1623 100644 --- a/codex-rs/core/src/rollout/recorder_tests.rs +++ b/codex-rs/rollout/src/recorder_tests.rs @@ -1,7 +1,8 @@ +#![allow(warnings, clippy::all)] + use super::*; -use crate::config::ConfigBuilder; +use crate::config::RolloutConfig; use chrono::TimeZone; -use codex_features::Feature; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::AskForApproval; @@ -19,6 +20,16 @@ use std::time::Duration; use tempfile::TempDir; use uuid::Uuid; +fn test_config(codex_home: &Path) -> RolloutConfig { + RolloutConfig { + codex_home: codex_home.to_path_buf(), + sqlite_home: codex_home.to_path_buf(), + cwd: codex_home.to_path_buf(), + model_provider_id: "test-provider".to_string(), + generate_memories: true, + } +} + fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result { let day_dir = root.join("sessions/2025/01/03"); fs::create_dir_all(&day_dir)?; @@ -54,10 +65,7 @@ fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; + let config = test_config(home.path()); let thread_id = ThreadId::new(); let recorder = RolloutRecorder::new( &config, @@ -141,14 +149,7 @@ async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result< #[tokio::test] async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) .await @@ -229,14 +230,7 @@ async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Resu async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone()) .await @@ -280,14 +274,7 @@ async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> #[tokio::test] async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .disable(Feature::Sqlite) - .expect("test config should allow sqlite to be disabled"); + let config = test_config(home.path()); let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?; let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?; @@ -328,14 +315,7 @@ async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Re #[tokio::test] async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let uuid = Uuid::from_u128(9010); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); @@ -396,14 +376,7 @@ async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Resul #[tokio::test] async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Result<()> { let home = TempDir::new().expect("temp dir"); - let mut config = ConfigBuilder::default() - .codex_home(home.path().to_path_buf()) - .build() - .await?; - config - .features - .enable(Feature::Sqlite) - .expect("test config should allow sqlite"); + let config = test_config(home.path()); let uuid = Uuid::from_u128(9011); let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id"); diff --git a/codex-rs/core/src/rollout/session_index.rs b/codex-rs/rollout/src/session_index.rs similarity index 100% rename from codex-rs/core/src/rollout/session_index.rs rename to codex-rs/rollout/src/session_index.rs diff --git a/codex-rs/core/src/rollout/session_index_tests.rs b/codex-rs/rollout/src/session_index_tests.rs similarity index 99% rename from codex-rs/core/src/rollout/session_index_tests.rs rename to codex-rs/rollout/src/session_index_tests.rs index 864c4c5cf8..6c434ffebf 100644 --- a/codex-rs/core/src/rollout/session_index_tests.rs +++ b/codex-rs/rollout/src/session_index_tests.rs @@ -1,3 +1,5 @@ +#![allow(warnings, clippy::all)] + use super::*; use pretty_assertions::assert_eq; use std::collections::HashMap; diff --git a/codex-rs/core/src/state_db.rs b/codex-rs/rollout/src/state_db.rs similarity index 95% rename from codex-rs/core/src/state_db.rs rename to codex-rs/rollout/src/state_db.rs index 7230186204..6367a27ca9 100644 --- a/codex-rs/core/src/state_db.rs +++ b/codex-rs/rollout/src/state_db.rs @@ -1,8 +1,8 @@ -use crate::config::Config; -use crate::path_utils::normalize_for_path_comparison; -use crate::rollout::list::Cursor; -use crate::rollout::list::ThreadSortKey; -use crate::rollout::metadata; +use crate::config::RolloutConfig; +use crate::config::RolloutConfigView; +use crate::list::Cursor; +use crate::list::ThreadSortKey; +use crate::metadata; use chrono::DateTime; use chrono::NaiveDateTime; use chrono::Timelike; @@ -13,6 +13,7 @@ use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionSource; pub use codex_state::LogEntry; use codex_state::ThreadMetadataBuilder; +use codex_utils_path::normalize_for_path_comparison; use serde_json::Value; use std::path::Path; use std::path::PathBuf; @@ -23,9 +24,9 @@ use uuid::Uuid; /// Core-facing handle to the SQLite-backed state runtime. pub type StateDbHandle = Arc; -/// Initialize the state runtime for thread state persistence and backfill checks. To only be used -/// inside `core`. The initialization should not be done anywhere else. -pub(crate) async fn init(config: &Config) -> Option { +/// Initialize the state runtime for thread state persistence and backfill checks. +pub async fn init(config: &impl RolloutConfigView) -> Option { + let config = RolloutConfig::from_view(config); let runtime = match codex_state::StateRuntime::init( config.sqlite_home.clone(), config.model_provider_id.clone(), @@ -62,18 +63,18 @@ pub(crate) async fn init(config: &Config) -> Option { } /// Get the DB if the feature is enabled and the DB exists. -pub async fn get_state_db(config: &Config) -> Option { - let state_path = codex_state::state_db_path(config.sqlite_home.as_path()); +pub async fn get_state_db(config: &impl RolloutConfigView) -> Option { + let state_path = codex_state::state_db_path(config.sqlite_home()); if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) { return None; } let runtime = codex_state::StateRuntime::init( - config.sqlite_home.clone(), - config.model_provider_id.clone(), + config.sqlite_home().to_path_buf(), + config.model_provider_id().to_string(), ) .await .ok()?; - require_backfill_complete(runtime, config.sqlite_home.as_path()).await + require_backfill_complete(runtime, config.sqlite_home()).await } /// Open the state runtime when the SQLite file exists, without feature gating. @@ -135,7 +136,7 @@ fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option { Some(codex_state::Anchor { ts, id }) } -pub(crate) fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { +pub fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf { normalize_for_path_comparison(cwd).unwrap_or_else(|_| cwd.to_path_buf()) } @@ -398,7 +399,7 @@ pub async fn reconcile_rollout( ); return; } - if let Ok(meta_line) = crate::rollout::list::read_session_meta_line(rollout_path).await { + if let Ok(meta_line) = crate::list::read_session_meta_line(rollout_path).await { persist_dynamic_tools( Some(ctx), meta_line.meta.id, @@ -463,7 +464,7 @@ pub async fn read_repair_rollout_path( if !saw_existing_metadata { warn!("state db discrepancy during read_repair_rollout_path: upsert_needed (slow path)"); } - let default_provider = crate::rollout::list::read_session_meta_line(rollout_path) + let default_provider = crate::list::read_session_meta_line(rollout_path) .await .ok() .and_then(|meta| meta.meta.model_provider) diff --git a/codex-rs/core/src/state_db_tests.rs b/codex-rs/rollout/src/state_db_tests.rs similarity index 80% rename from codex-rs/core/src/state_db_tests.rs rename to codex-rs/rollout/src/state_db_tests.rs index adf08197d6..e5e3211768 100644 --- a/codex-rs/core/src/state_db_tests.rs +++ b/codex-rs/rollout/src/state_db_tests.rs @@ -1,6 +1,13 @@ +#![allow(warnings, clippy::all)] + use super::*; -use crate::rollout::list::parse_cursor; +use crate::list::parse_cursor; +use chrono::DateTime; +use chrono::NaiveDateTime; +use chrono::Timelike; +use chrono::Utc; use pretty_assertions::assert_eq; +use uuid::Uuid; #[test] fn cursor_to_anchor_normalizes_timestamp_format() { diff --git a/codex-rs/core/src/rollout/tests.rs b/codex-rs/rollout/src/tests.rs similarity index 98% rename from codex-rs/core/src/rollout/tests.rs rename to codex-rs/rollout/src/tests.rs index 17c1792d32..6a5f25286d 100644 --- a/codex-rs/core/src/rollout/tests.rs +++ b/codex-rs/rollout/src/tests.rs @@ -1,3 +1,4 @@ +#![allow(warnings, clippy::all)] #![allow(clippy::unwrap_used, clippy::expect_used)] use std::ffi::OsStr; @@ -17,14 +18,15 @@ use time::format_description::FormatItem; use time::macros::format_description; use uuid::Uuid; -use crate::rollout::INTERACTIVE_SESSION_SOURCES; -use crate::rollout::list::Cursor; -use crate::rollout::list::ThreadItem; -use crate::rollout::list::ThreadSortKey; -use crate::rollout::list::ThreadsPage; -use crate::rollout::list::get_threads; -use crate::rollout::list::read_head_for_summary; -use crate::rollout::rollout_date_parts; +use crate::INTERACTIVE_SESSION_SOURCES; +use crate::find_thread_path_by_id_str; +use crate::list::Cursor; +use crate::list::ThreadItem; +use crate::list::ThreadSortKey; +use crate::list::ThreadsPage; +use crate::list::get_threads; +use crate::list::read_head_for_summary; +use crate::rollout_date_parts; use anyhow::Result; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; @@ -229,7 +231,7 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() { )); insert_state_db_thread(home, thread_id, stale_db_path.as_path(), false).await; - let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string()) + let found = find_thread_path_by_id_str(home, &uuid.to_string()) .await .expect("lookup should succeed"); assert_eq!(found, Some(fs_rollout_path.clone())); @@ -255,7 +257,7 @@ async fn find_thread_path_repairs_missing_db_row_after_filesystem_fallback() { .await .expect("backfill should be complete"); - let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string()) + let found = find_thread_path_by_id_str(home, &uuid.to_string()) .await .expect("lookup should succeed"); assert_eq!(found, Some(fs_rollout_path.clone())); diff --git a/codex-rs/utils/path-utils/BUILD.bazel b/codex-rs/utils/path-utils/BUILD.bazel new file mode 100644 index 0000000000..fdfbf099b1 --- /dev/null +++ b/codex-rs/utils/path-utils/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "path-utils", + crate_name = "codex_utils_path", +) diff --git a/codex-rs/utils/path-utils/Cargo.toml b/codex-rs/utils/path-utils/Cargo.toml new file mode 100644 index 0000000000..0d1693361f --- /dev/null +++ b/codex-rs/utils/path-utils/Cargo.toml @@ -0,0 +1,17 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-utils-path" +version.workspace = true + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +dunce = { workspace = true } +tempfile = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/core/src/env.rs b/codex-rs/utils/path-utils/src/env.rs similarity index 100% rename from codex-rs/core/src/env.rs rename to codex-rs/utils/path-utils/src/env.rs diff --git a/codex-rs/core/src/path_utils.rs b/codex-rs/utils/path-utils/src/lib.rs similarity index 98% rename from codex-rs/core/src/path_utils.rs rename to codex-rs/utils/path-utils/src/lib.rs index eca2ce1663..a6dc6f8b3a 100644 --- a/codex-rs/core/src/path_utils.rs +++ b/codex-rs/utils/path-utils/src/lib.rs @@ -1,3 +1,7 @@ +//! Path normalization, symlink resolution, and atomic writes shared across Codex crates. + +pub mod env; + use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashSet; use std::io; @@ -5,8 +9,6 @@ use std::path::Path; use std::path::PathBuf; use tempfile::NamedTempFile; -use crate::env; - pub fn normalize_for_path_comparison(path: impl AsRef) -> std::io::Result { let canonical = path.as_ref().canonicalize()?; Ok(normalize_for_wsl(canonical)) diff --git a/codex-rs/core/src/path_utils_tests.rs b/codex-rs/utils/path-utils/src/path_utils_tests.rs similarity index 100% rename from codex-rs/core/src/path_utils_tests.rs rename to codex-rs/utils/path-utils/src/path_utils_tests.rs From 9e5874df07686441c555c3d46a83b0c303269a25 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Tue, 24 Mar 2026 18:23:35 -0700 Subject: [PATCH 16/50] test bug --- codex-rs/core/tests/suite/thread_metadata.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/thread_metadata.rs index 94ae3b6241..397039e7b2 100644 --- a/codex-rs/core/tests/suite/thread_metadata.rs +++ b/codex-rs/core/tests/suite/thread_metadata.rs @@ -1,5 +1,6 @@ use anyhow::Result; use codex_core::CodexAuth; +use codex_core::config::Constrained; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -24,6 +25,9 @@ async fn thread_start_tracks_thread_started_analytics() -> Result<()> { config.model_reasoning_summary = Some(ReasoningSummary::Detailed); config.service_tier = Some(ServiceTier::Flex); config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.permissions.sandbox_policy = Constrained::allow_any( + codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(), + ); config.personality = Some(Personality::Friendly); config.ephemeral = true; }) From d72fa2a209837e549769b4452b599960464ba1d8 Mon Sep 17 00:00:00 2001 From: Charley Cunningham Date: Tue, 24 Mar 2026 18:34:44 -0700 Subject: [PATCH 17/50] [codex] Defer fork context injection until first turn (#15699) ## Summary - remove the fork-startup `build_initial_context` injection - keep the reconstructed `reference_context_item` as the fork baseline until the first real turn - update fork-history tests and the request snapshot, and add a `TODO(ccunningham)` for remaining nondiffable initial-context inputs ## Why Fork startup was appending current-session initial context immediately after reconstructing the parent rollout, then the first real turn could emit context updates again. That duplicated model-visible context in the child rollout. ## Impact Forked sessions now behave like resume for context seeding: startup reconstructs history and preserves the prior baseline, and the first real turn handles any current-session context emission. --------- Co-authored-by: Codex --- codex-rs/core/src/codex.rs | 9 --------- codex-rs/core/src/codex_tests.rs | 20 ++++++++++--------- codex-rs/core/src/context_manager/updates.rs | 4 ++++ ..._startup_context_then_first_turn_diff.snap | 10 ++++------ codex-rs/core/tests/suite/fork_thread.rs | 6 ++---- .../core/tests/suite/permissions_messages.rs | 6 +++--- 6 files changed, 24 insertions(+), 31 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d699ec5a87..168965e1ea 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -2215,15 +2215,6 @@ impl Session { self.persist_rollout_items(&rollout_items).await; } - // Append the current session's initial context after the reconstructed history. - let initial_context = self.build_initial_context(&turn_context).await; - self.record_conversation_items(&turn_context, &initial_context) - .await; - { - let mut state = self.state.lock().await; - state.set_reference_context_item(Some(turn_context.to_turn_context_item())); - } - // Forked threads should remain file-backed immediately after startup. self.ensure_rollout_materialized().await; diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 0ba0a1beb0..12720f2437 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -1115,18 +1115,12 @@ async fn recompute_token_usage_updates_model_context_window() { #[tokio::test] async fn record_initial_history_reconstructs_forked_transcript() { let (session, turn_context) = make_session_and_context().await; - let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await; + let (rollout_items, expected) = sample_rollout(&session, &turn_context).await; session .record_initial_history(InitialHistory::Forked(rollout_items)) .await; - let reconstruction_turn = session.new_default_turn().await; - expected.extend( - session - .build_initial_context(reconstruction_turn.as_ref()) - .await, - ); let history = session.state.lock().await.clone_history(); assert_eq!(expected, history.raw_items()); } @@ -1244,7 +1238,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result< let request = first_forked_request.single_request(); let snapshot = context_snapshot::format_labeled_requests_snapshot( - "First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.", + "First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.", &[("First Forked Turn Request", &request)], &ContextSnapshotOptions::default() .render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 }) @@ -1309,7 +1303,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { text_elements: Vec::new(), }, )), - RolloutItem::TurnContext(previous_context_item), + RolloutItem::TurnContext(previous_context_item.clone()), RolloutItem::EventMsg(EventMsg::TurnComplete( codex_protocol::protocol::TurnCompleteEvent { turn_id, @@ -1322,6 +1316,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { .record_initial_history(InitialHistory::Forked(rollout_items)) .await; + let history = session.clone_history().await; assert_eq!( session.previous_turn_settings().await, Some(PreviousTurnSettings { @@ -1329,6 +1324,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { realtime_active: Some(turn_context.realtime_active), }) ); + assert_eq!(history.raw_items(), &[]); + assert_eq!( + serde_json::to_value(session.reference_context_item().await) + .expect("serialize fork reference context item"), + serde_json::to_value(Some(previous_context_item)) + .expect("serialize expected reference context item") + ); } #[tokio::test] diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index c122187901..34fd41c083 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -193,6 +193,10 @@ pub(crate) fn build_settings_update_items( exec_policy: &Policy, personality_feature_enabled: bool, ) -> Vec { + // TODO(ccunningham): build_settings_update_items still does not cover every + // model-visible item emitted by build_initial_context. Persist the remaining + // inputs or add explicit replay events so fork/resume can diff everything + // deterministically. let contextual_user_message = build_environment_update_item(previous, next, shell); let developer_update_sections = [ // Keep model-switch instructions first so model-specific guidance is read before diff --git a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap index 90bb82d409..d55f29a2a7 100644 --- a/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap +++ b/codex-rs/core/src/snapshots/codex_core__codex_tests__fork_startup_context_then_first_turn_diff.snap @@ -1,17 +1,15 @@ --- source: core/src/codex_tests.rs -assertion_line: 1282 +assertion_line: 1254 expression: snapshot --- -Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode. +Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode. ## First Forked Turn Request 00:message/developer: 01:message/user:> 02:message/user:fork seed -03:message/developer: -04:message/user:> -05:message/developer[2]: +03:message/developer[2]: [01] [02] Fork turn collaboration instructions. -06:message/user:after fork +04:message/user:after fork diff --git a/codex-rs/core/tests/suite/fork_thread.rs b/codex-rs/core/tests/suite/fork_thread.rs index a16e460204..e24cb74d7c 100644 --- a/codex-rs/core/tests/suite/fork_thread.rs +++ b/codex-rs/core/tests/suite/fork_thread.rs @@ -125,9 +125,8 @@ async fn fork_thread_twice_drops_to_first_message() { // GetHistory on fork1 flushed; the file is ready. let fork1_items = read_items(&fork1_path); - assert!(fork1_items.len() > expected_after_first.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork1_items[..expected_after_first.len()]).unwrap(), + serde_json::to_value(&fork1_items).unwrap(), serde_json::to_value(&expected_after_first).unwrap() ); @@ -156,9 +155,8 @@ async fn fork_thread_twice_drops_to_first_message() { .unwrap_or(0); let expected_after_second: Vec = fork1_items[..cut_last_on_fork1].to_vec(); let fork2_items = read_items(&fork2_path); - assert!(fork2_items.len() > expected_after_second.len()); pretty_assertions::assert_eq!( - serde_json::to_value(&fork2_items[..expected_after_second.len()]).unwrap(), + serde_json::to_value(&fork2_items).unwrap(), serde_json::to_value(&expected_after_second).unwrap() ); } diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 30180c8cf6..e71a158797 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -443,14 +443,14 @@ async fn resume_and_fork_append_permissions_messages() -> Result<()> { let body4 = req4.single_request().body_json(); let input4 = body4["input"].as_array().expect("input array"); let permissions_fork = permissions_texts(input4); - assert_eq!(permissions_fork.len(), permissions_base.len() + 2); + assert_eq!(permissions_fork.len(), permissions_base.len() + 1); assert_eq!( &permissions_fork[..permissions_base.len()], permissions_base.as_slice() ); let new_permissions = &permissions_fork[permissions_base.len()..]; - assert_eq!(new_permissions.len(), 2); - assert_eq!(new_permissions[0], new_permissions[1]); + assert_eq!(new_permissions.len(), 1); + assert_eq!(permissions_fork, permissions_resume); assert!(!permissions_base.contains(&new_permissions[0])); Ok(()) From 0b08d893042ba0c0d5c2f020b1c78b46af2ebe59 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 19:27:00 -0700 Subject: [PATCH 18/50] [app-server] Add a method to override feature flags. (#15601) - [x] Add a method to override feature flags globally and not just thread level. --- .../schema/json/ClientRequest.json | 39 +++ .../codex_app_server_protocol.schemas.json | 58 ++++ .../codex_app_server_protocol.v2.schemas.json | 58 ++++ ...xperimentalFeatureEnablementSetParams.json | 17 ++ ...erimentalFeatureEnablementSetResponse.json | 17 ++ .../schema/typescript/ClientRequest.ts | 3 +- .../ExperimentalFeatureEnablementSetParams.ts | 12 + ...xperimentalFeatureEnablementSetResponse.ts | 9 + .../schema/typescript/v2/index.ts | 2 + .../src/protocol/common.rs | 4 + .../app-server-protocol/src/protocol/v2.rs | 19 ++ codex-rs/app-server/README.md | 1 + .../app-server/src/codex_message_processor.rs | 74 +++-- codex-rs/app-server/src/config_api.rs | 264 +++++++++++++++++- codex-rs/app-server/src/message_processor.rs | 41 ++- .../app-server/tests/common/mcp_process.rs | 10 + .../suite/v2/experimental_feature_list.rs | 216 +++++++++++++- 17 files changed, 806 insertions(+), 38 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json create mode 100644 codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index e245c17954..9227a97d55 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -524,6 +524,21 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "type": "object" + }, "ExperimentalFeatureListParams": { "properties": { "cursor": { @@ -4294,6 +4309,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index ad79d0987e..d4269f5239 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 @@ -1151,6 +1151,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -7249,6 +7273,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { 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 30bb2efb22..acf36d6c3c 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 @@ -1681,6 +1681,30 @@ "title": "ExperimentalFeature/listRequest", "type": "object" }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "experimentalFeature/enablement/set" + ], + "title": "ExperimentalFeature/enablement/setRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExperimentalFeatureEnablementSetParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "ExperimentalFeature/enablement/setRequest", + "type": "object" + }, { "properties": { "id": { @@ -3809,6 +3833,40 @@ ], "type": "object" }, + "ExperimentalFeatureEnablementSetParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" + }, + "ExperimentalFeatureEnablementSetResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" + }, "ExperimentalFeatureListParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json new file mode 100644 index 0000000000..9d6bcec932 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetParams.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Process-wide runtime feature enablement keyed by canonical feature name.\n\nOnly named features are updated. Omitted features are left unchanged. Send an empty map for a no-op.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json new file mode 100644 index 0000000000..9cdbf0691f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ExperimentalFeatureEnablementSetResponse.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "enablement": { + "additionalProperties": { + "type": "boolean" + }, + "description": "Feature enablement entries updated by this request.", + "type": "object" + } + }, + "required": [ + "enablement" + ], + "title": "ExperimentalFeatureEnablementSetResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 3c77652cec..e33a986359 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -16,6 +16,7 @@ import type { CommandExecWriteParams } from "./v2/CommandExecWriteParams"; import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams"; import type { ConfigReadParams } from "./v2/ConfigReadParams"; import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams"; +import type { ExperimentalFeatureEnablementSetParams } from "./v2/ExperimentalFeatureEnablementSetParams"; import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams"; import type { ExternalAgentConfigDetectParams } from "./v2/ExternalAgentConfigDetectParams"; import type { ExternalAgentConfigImportParams } from "./v2/ExternalAgentConfigImportParams"; @@ -63,4 +64,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts new file mode 100644 index 0000000000..cd9ced0b3a --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetParams.ts @@ -0,0 +1,12 @@ +// 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 ExperimentalFeatureEnablementSetParams = { +/** + * Process-wide runtime feature enablement keyed by canonical feature name. + * + * Only named features are updated. Omitted features are left unchanged. + * Send an empty map for a no-op. + */ +enablement: { [key in string]?: boolean }, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts new file mode 100644 index 0000000000..903576d9bc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ExperimentalFeatureEnablementSetResponse.ts @@ -0,0 +1,9 @@ +// 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 ExperimentalFeatureEnablementSetResponse = { +/** + * Feature enablement entries updated by this request. + */ +enablement: { [key in string]?: boolean }, }; 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 2ee447bba2..d0b1b88198 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -81,6 +81,8 @@ export type { DynamicToolSpec } from "./DynamicToolSpec"; export type { ErrorNotification } from "./ErrorNotification"; export type { ExecPolicyAmendment } from "./ExecPolicyAmendment"; export type { ExperimentalFeature } from "./ExperimentalFeature"; +export type { ExperimentalFeatureEnablementSetParams } from "./ExperimentalFeatureEnablementSetParams"; +export type { ExperimentalFeatureEnablementSetResponse } from "./ExperimentalFeatureEnablementSetResponse"; export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams"; export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse"; export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 80cb24f0ff..5a9215f2ee 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -392,6 +392,10 @@ client_request_definitions! { params: v2::ExperimentalFeatureListParams, response: v2::ExperimentalFeatureListResponse, }, + ExperimentalFeatureEnablementSet => "experimentalFeature/enablement/set" { + params: v2::ExperimentalFeatureEnablementSetParams, + response: v2::ExperimentalFeatureEnablementSetResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index fab3fe3190..67c4e95856 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -1926,6 +1926,25 @@ pub struct ExperimentalFeatureListResponse { pub next_cursor: Option, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetParams { + /// Process-wide runtime feature enablement keyed by canonical feature name. + /// + /// Only named features are updated. Omitted features are left unchanged. + /// Send an empty map for a no-op. + pub enablement: std::collections::BTreeMap, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ExperimentalFeatureEnablementSetResponse { + /// Feature enablement entries updated by this request. + pub enablement: std::collections::BTreeMap, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b877c4a035..1e186c0d5e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -164,6 +164,7 @@ Example with notification opt-out: - `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`. - `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata. - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. +- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable > config.toml > experimentalFeature/enablement/set (new) > code default. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). - `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1d58e7317d..402dd51ddd 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1,6 +1,7 @@ use crate::bespoke_event_handling::apply_bespoke_event_handling; use crate::command_exec::CommandExecManager; use crate::command_exec::StartCommandExecParams; +use crate::config_api::apply_runtime_feature_enablement; use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; use crate::error_code::INTERNAL_ERROR_CODE; use crate::error_code::INVALID_PARAMS_ERROR_CODE; @@ -283,6 +284,7 @@ use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; use codex_utils_json_to_toml::json_to_toml; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::ffi::OsStr; @@ -371,7 +373,8 @@ pub(crate) struct CodexMessageProcessor { outgoing: Arc, arg0_paths: Arg0DispatchPaths, config: Arc, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, cloud_requirements: Arc>, active_login: Arc>>, pending_thread_unloads: Arc>>, @@ -422,7 +425,8 @@ pub(crate) struct CodexMessageProcessorArgs { pub(crate) outgoing: Arc, pub(crate) arg0_paths: Arg0DispatchPaths, pub(crate) config: Arc, - pub(crate) cli_overrides: Vec<(String, TomlValue)>, + pub(crate) cli_overrides: Arc>>, + pub(crate) runtime_feature_enablement: Arc>>, pub(crate) cloud_requirements: Arc>, pub(crate) feedback: CodexFeedback, pub(crate) log_db: Option, @@ -486,6 +490,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, feedback, log_db, @@ -497,6 +502,7 @@ impl CodexMessageProcessor { arg0_paths, config, cli_overrides, + runtime_feature_enablement, cloud_requirements, active_login: Arc::new(Mutex::new(None)), pending_thread_unloads: Arc::new(Mutex::new(HashSet::new())), @@ -517,7 +523,7 @@ impl CodexMessageProcessor { ) -> Result { let cloud_requirements = self.current_cloud_requirements(); let mut config = codex_core::config::ConfigBuilder::default() - .cli_overrides(self.cli_overrides.clone()) + .cli_overrides(self.current_cli_overrides()) .fallback_cwd(fallback_cwd) .cloud_requirements(cloud_requirements) .build() @@ -527,6 +533,7 @@ impl CodexMessageProcessor { message: format!("failed to reload config: {err}"), data: None, })?; + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); config.codex_linux_sandbox_exe = self.arg0_paths.codex_linux_sandbox_exe.clone(); config.main_execve_wrapper_exe = self.arg0_paths.main_execve_wrapper_exe.clone(); Ok(config) @@ -539,6 +546,20 @@ impl CodexMessageProcessor { .unwrap_or_default() } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + /// If a client sends `developer_instructions: null` during a mode switch, /// use the built-in instructions for that mode. fn normalize_turn_start_collaboration_mode( @@ -884,7 +905,8 @@ impl CodexMessageProcessor { } ClientRequest::ConfigRead { .. } | ClientRequest::ConfigValueWrite { .. } - | ClientRequest::ConfigBatchWrite { .. } => { + | ClientRequest::ConfigBatchWrite { .. } + | ClientRequest::ExperimentalFeatureEnablementSet { .. } => { warn!("Config request reached CodexMessageProcessor unexpectedly"); } ClientRequest::FsReadFile { .. } @@ -1085,7 +1107,7 @@ impl CodexMessageProcessor { let cloud_requirements = self.cloud_requirements.clone(); let chatgpt_base_url = self.config.chatgpt_base_url.clone(); let codex_home = self.config.codex_home.clone(); - let cli_overrides = self.cli_overrides.clone(); + let cli_overrides = self.current_cli_overrides(); let auth_url = server.auth_url.clone(); tokio::spawn(async move { let (success, error_msg) = match tokio::time::timeout( @@ -1273,11 +1295,9 @@ impl CodexMessageProcessor { self.config.chatgpt_base_url.clone(), self.config.codex_home.clone(), ); - sync_default_client_residency_requirement( - &self.cli_overrides, - self.cloud_requirements.as_ref(), - ) - .await; + let cli_overrides = self.current_cli_overrides(); + sync_default_client_residency_requirement(&cli_overrides, self.cloud_requirements.as_ref()) + .await; self.outgoing .send_response(request_id, LoginAccountResponse::ChatgptAuthTokens {}) @@ -1892,8 +1912,8 @@ impl CodexMessageProcessor { personality, ); typesafe_overrides.ephemeral = ephemeral; - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); let listener_task_context = ListenerTaskContext { thread_manager: Arc::clone(&self.thread_manager), thread_state_manager: self.thread_state_manager.clone(), @@ -1903,10 +1923,12 @@ impl CodexMessageProcessor { codex_home: self.config.codex_home.clone(), }; let request_trace = request_context.request_trace(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let thread_start_task = async move { Self::thread_start_task( listener_task_context, cli_overrides, + runtime_feature_enablement, cloud_requirements, request_id, config, @@ -1979,6 +2001,7 @@ impl CodexMessageProcessor { async fn thread_start_task( listener_task_context: ListenerTaskContext, cli_overrides: Vec<(String, TomlValue)>, + runtime_feature_enablement: BTreeMap, cloud_requirements: CloudRequirementsLoader, request_id: ConnectionRequestId, config_overrides: Option>, @@ -1995,6 +2018,7 @@ impl CodexMessageProcessor { typesafe_overrides, &cloud_requirements, &listener_task_context.codex_home, + &runtime_feature_enablement, ) .await { @@ -3497,13 +3521,16 @@ impl CodexMessageProcessor { // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -4039,13 +4066,16 @@ impl CodexMessageProcessor { typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. let cloud_requirements = self.current_cloud_requirements(); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let config = match derive_config_for_cwd( - &self.cli_overrides, + &cli_overrides, request_overrides, typesafe_overrides, history_cwd, &cloud_requirements, &self.config.codex_home, + &runtime_feature_enablement, ) .await { @@ -7224,12 +7254,13 @@ impl CodexMessageProcessor { WindowsSandboxSetupMode::Unelevated => CoreWindowsSandboxSetupMode::Unelevated, }; let config = Arc::clone(&self.config); - let cli_overrides = self.cli_overrides.clone(); let cloud_requirements = self.current_cloud_requirements(); let command_cwd = params .cwd .map(PathBuf::from) .unwrap_or_else(|| config.cwd.clone()); + let cli_overrides = self.current_cli_overrides(); + let runtime_feature_enablement = self.current_runtime_feature_enablement(); let outgoing = Arc::clone(&self.outgoing); let connection_id = request_id.connection_id; @@ -7244,6 +7275,7 @@ impl CodexMessageProcessor { Some(command_cwd.clone()), &cloud_requirements, &config.codex_home, + &runtime_feature_enablement, ) .await; let setup_result = match derived_config { @@ -7873,6 +7905,7 @@ async fn derive_config_from_params( typesafe_overrides: ConfigOverrides, cloud_requirements: &CloudRequirementsLoader, codex_home: &Path, + runtime_feature_enablement: &BTreeMap, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7885,13 +7918,15 @@ async fn derive_config_from_params( ) .collect::>(); - codex_core::config::ConfigBuilder::default() + let mut config = codex_core::config::ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .cloud_requirements(cloud_requirements.clone()) .build() - .await + .await?; + apply_runtime_feature_enablement(&mut config, runtime_feature_enablement); + Ok(config) } async fn derive_config_for_cwd( @@ -7901,6 +7936,7 @@ async fn derive_config_for_cwd( cwd: Option, cloud_requirements: &CloudRequirementsLoader, codex_home: &Path, + runtime_feature_enablement: &BTreeMap, ) -> std::io::Result { let merged_cli_overrides = cli_overrides .iter() @@ -7913,14 +7949,16 @@ async fn derive_config_for_cwd( ) .collect::>(); - codex_core::config::ConfigBuilder::default() + let mut config = codex_core::config::ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) .cli_overrides(merged_cli_overrides) .harness_overrides(typesafe_overrides) .fallback_cwd(cwd) .cloud_requirements(cloud_requirements.clone()) .build() - .await + .await?; + apply_runtime_feature_enablement(&mut config, runtime_feature_enablement); + Ok(config) } async fn read_history_cwd_from_state_db( diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index bc82e9152e..9ade74914d 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -9,11 +9,14 @@ use codex_app_server_protocol::ConfigRequirementsReadResponse; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWriteErrorCode; use codex_app_server_protocol::ConfigWriteResponse; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::NetworkRequirements; use codex_app_server_protocol::SandboxMode; use codex_core::AnalyticsEventsClient; use codex_core::ThreadManager; +use codex_core::config::Config; use codex_core::config::ConfigService; use codex_core::config::ConfigServiceError; use codex_core::config_loader::CloudRequirementsLoader; @@ -24,15 +27,21 @@ use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirem use codex_core::plugins::PluginId; use codex_core::plugins::collect_plugin_enabled_candidates; use codex_core::plugins::installed_plugin_telemetry_metadata; +use codex_features::canonical_feature_for_key; +use codex_features::feature_for_key; use codex_protocol::config_types::WebSearchMode; use codex_protocol::protocol::Op; use serde_json::json; +use std::collections::BTreeMap; +use std::collections::BTreeSet; use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; use toml::Value as TomlValue; use tracing::warn; +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &["apps", "plugins"]; + #[async_trait] pub(crate) trait UserConfigReloader: Send + Sync { async fn reload_user_config(&self); @@ -56,7 +65,8 @@ impl UserConfigReloader for ThreadManager { #[derive(Clone)] pub(crate) struct ConfigApi { codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -66,7 +76,8 @@ pub(crate) struct ConfigApi { impl ConfigApi { pub(crate) fn new( codex_home: PathBuf, - cli_overrides: Vec<(String, TomlValue)>, + cli_overrides: Arc>>, + runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, cloud_requirements: Arc>, user_config_reloader: Arc, @@ -75,6 +86,7 @@ impl ConfigApi { Self { codex_home, cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, user_config_reloader, @@ -83,24 +95,87 @@ impl ConfigApi { } fn config_service(&self) -> ConfigService { - let cloud_requirements = self - .cloud_requirements - .read() - .map(|guard| guard.clone()) - .unwrap_or_default(); ConfigService::new( self.codex_home.clone(), - self.cli_overrides.clone(), + self.current_cli_overrides(), self.loader_overrides.clone(), - cloud_requirements, + self.current_cloud_requirements(), ) } + fn current_cli_overrides(&self) -> Vec<(String, TomlValue)> { + self.cli_overrides + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_runtime_feature_enablement(&self) -> BTreeMap { + self.runtime_feature_enablement + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + fn current_cloud_requirements(&self) -> CloudRequirementsLoader { + self.cloud_requirements + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + async fn load_latest_config( + &self, + fallback_cwd: Option, + ) -> Result { + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.current_cli_overrides()) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(fallback_cwd) + .cloud_requirements(self.current_cloud_requirements()) + .build() + .await + .map_err(|err| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to resolve feature override precedence: {err}"), + data: None, + })?; + apply_runtime_feature_enablement(&mut config, &self.current_runtime_feature_enablement()); + Ok(config) + } + pub(crate) async fn read( &self, params: ConfigReadParams, ) -> Result { - self.config_service().read(params).await.map_err(map_error) + let fallback_cwd = params.cwd.as_ref().map(PathBuf::from); + let mut response = self + .config_service() + .read(params) + .await + .map_err(map_error)?; + let config = self.load_latest_config(fallback_cwd).await?; + for feature_key in SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT { + let Some(feature) = feature_for_key(feature_key) else { + continue; + }; + let features = response + .config + .additional + .entry("features".to_string()) + .or_insert_with(|| json!({})); + if !features.is_object() { + *features = json!({}); + } + if let Some(features) = features.as_object_mut() { + features.insert( + (*feature_key).to_string(), + json!(config.features.enabled(feature)), + ); + } + } + Ok(response) } pub(crate) async fn config_requirements_read( @@ -154,6 +229,68 @@ impl ConfigApi { Ok(response) } + pub(crate) async fn set_experimental_feature_enablement( + &self, + params: ExperimentalFeatureEnablementSetParams, + ) -> Result { + let ExperimentalFeatureEnablementSetParams { enablement } = params; + for key in enablement.keys() { + if canonical_feature_for_key(key).is_some() { + if SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.contains(&key.as_str()) { + continue; + } + + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "unsupported feature enablement `{key}`: currently supported features are {}", + SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT.join(", ") + ), + data: None, + }); + } + + let message = if let Some(feature) = feature_for_key(key) { + format!( + "invalid feature enablement `{key}`: use canonical feature key `{}`", + feature.key() + ) + } else { + format!("invalid feature enablement `{key}`") + }; + return Err(JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message, + data: None, + }); + } + + if enablement.is_empty() { + return Ok(ExperimentalFeatureEnablementSetResponse { enablement }); + } + + { + let mut runtime_feature_enablement = + self.runtime_feature_enablement + .write() + .map_err(|_| JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: "failed to update feature enablement".to_string(), + data: None, + })?; + runtime_feature_enablement.extend( + enablement + .iter() + .map(|(name, enabled)| (name.clone(), *enabled)), + ); + } + + self.load_latest_config(/*fallback_cwd*/ None).await?; + self.user_config_reloader.reload_user_config().await; + + Ok(ExperimentalFeatureEnablementSetResponse { enablement }) + } + fn emit_plugin_toggle_events(&self, pending_changes: std::collections::BTreeMap) { for (plugin_id, enabled) in pending_changes { let Ok(plugin_id) = PluginId::parse(&plugin_id) else { @@ -170,6 +307,49 @@ impl ConfigApi { } } +pub(crate) fn protected_feature_keys( + config_layer_stack: &codex_core::config_loader::ConfigLayerStack, +) -> BTreeSet { + let mut protected_features = config_layer_stack + .effective_config() + .get("features") + .and_then(toml::Value::as_table) + .map(|features| features.keys().cloned().collect::>()) + .unwrap_or_default(); + + if let Some(feature_requirements) = config_layer_stack + .requirements_toml() + .feature_requirements + .as_ref() + { + protected_features.extend(feature_requirements.entries.keys().cloned()); + } + + protected_features +} + +pub(crate) fn apply_runtime_feature_enablement( + config: &mut Config, + runtime_feature_enablement: &BTreeMap, +) { + let protected_features = protected_feature_keys(&config.config_layer_stack); + for (name, enabled) in runtime_feature_enablement { + if protected_features.contains(name) { + continue; + } + let Some(feature) = feature_for_key(name) else { + continue; + }; + if let Err(err) = config.features.set_enabled(feature, *enabled) { + warn!( + feature = name, + error = %err, + "failed to apply runtime feature enablement" + ); + } + } +} + fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigRequirements { ConfigRequirements { allowed_approval_policies: requirements.allowed_approval_policies.map(|policies| { @@ -265,6 +445,7 @@ mod tests { use super::*; use codex_core::AnalyticsEventsClient; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; + use codex_features::Feature; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; use pretty_assertions::assert_eq; use serde_json::json; @@ -392,6 +573,66 @@ mod tests { ); } + #[tokio::test] + async fn apply_runtime_feature_enablement_keeps_cli_overrides_above_config_and_runtime() { + let codex_home = TempDir::new().expect("create temp dir"); + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\napps = false\n", + ) + .expect("write config"); + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cli_overrides(vec![( + "features.apps".to_string(), + TomlValue::Boolean(true), + )]) + .build() + .await + .expect("load config"); + + apply_runtime_feature_enablement( + &mut config, + &BTreeMap::from([("apps".to_string(), false)]), + ); + + assert!(config.features.enabled(Feature::Apps)); + } + + #[tokio::test] + async fn apply_runtime_feature_enablement_keeps_cloud_pins_above_cli_and_runtime() { + let codex_home = TempDir::new().expect("create temp dir"); + + let mut config = codex_core::config::ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .cli_overrides(vec![( + "features.apps".to_string(), + TomlValue::Boolean(true), + )]) + .cloud_requirements(CloudRequirementsLoader::new(async { + Ok(Some(ConfigRequirementsToml { + feature_requirements: Some( + codex_core::config_loader::FeatureRequirementsToml { + entries: BTreeMap::from([("apps".to_string(), false)]), + }, + ), + ..Default::default() + })) + })) + .build() + .await + .expect("load config"); + + apply_runtime_feature_enablement( + &mut config, + &BTreeMap::from([("apps".to_string(), true)]), + ); + + assert!(!config.features.enabled(Feature::Apps)); + } + #[tokio::test] async fn batch_write_reloads_user_config_when_requested() { let codex_home = TempDir::new().expect("create temp dir"); @@ -406,7 +647,8 @@ mod tests { ); let config_api = ConfigApi::new( codex_home.path().to_path_buf(), - Vec::new(), + Arc::new(RwLock::new(Vec::new())), + Arc::new(RwLock::new(BTreeMap::new())), LoaderOverrides::default(), Arc::new(RwLock::new(CloudRequirementsLoader::default())), reloader.clone(), diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index a67c810bd0..d070738fba 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::collections::HashSet; use std::future::Future; use std::sync::Arc; @@ -30,6 +31,7 @@ use codex_app_server_protocol::ConfigReadParams; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::ExperimentalApi; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; use codex_app_server_protocol::ExternalAgentConfigDetectParams; use codex_app_server_protocol::ExternalAgentConfigImportParams; use codex_app_server_protocol::FsCopyParams; @@ -226,6 +228,8 @@ impl MessageProcessor { .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); + let cli_overrides = Arc::new(RwLock::new(cli_overrides)); + let runtime_feature_enablement = Arc::new(RwLock::new(BTreeMap::new())); let cloud_requirements = Arc::new(RwLock::new(cloud_requirements)); let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs { auth_manager: auth_manager.clone(), @@ -234,6 +238,7 @@ impl MessageProcessor { arg0_paths, config: Arc::clone(&config), cli_overrides: cli_overrides.clone(), + runtime_feature_enablement: runtime_feature_enablement.clone(), cloud_requirements: cloud_requirements.clone(), feedback, log_db, @@ -246,6 +251,7 @@ impl MessageProcessor { let config_api = ConfigApi::new( config.codex_home.clone(), cli_overrides, + runtime_feature_enablement, loader_overrides, cloud_requirements, thread_manager, @@ -700,6 +706,16 @@ impl MessageProcessor { ) .await; } + ClientRequest::ExperimentalFeatureEnablementSet { request_id, params } => { + self.handle_experimental_feature_enablement_set( + ConnectionRequestId { + connection_id, + request_id, + }, + params, + ) + .await; + } ClientRequest::ConfigRequirementsRead { request_id, params: _, @@ -848,7 +864,30 @@ impl MessageProcessor { request_id: ConnectionRequestId, params: ConfigBatchWriteParams, ) { - match self.config_api.batch_write(params).await { + self.handle_config_mutation_result(request_id, self.config_api.batch_write(params).await) + .await; + } + + async fn handle_experimental_feature_enablement_set( + &self, + request_id: ConnectionRequestId, + params: ExperimentalFeatureEnablementSetParams, + ) { + self.handle_config_mutation_result( + request_id, + self.config_api + .set_experimental_feature_enablement(params) + .await, + ) + .await; + } + + async fn handle_config_mutation_result( + &self, + request_id: ConnectionRequestId, + result: std::result::Result, + ) { + match result { Ok(response) => { self.codex_message_processor.clear_plugin_related_caches(); self.codex_message_processor diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 067e06ec43..fe21cdafff 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -465,6 +465,16 @@ impl McpProcess { self.send_request("experimentalFeature/list", params).await } + /// Send an `experimentalFeature/enablement/set` JSON-RPC request. + pub async fn send_experimental_feature_enablement_set_request( + &mut self, + params: codex_app_server_protocol::ExperimentalFeatureEnablementSetParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("experimentalFeature/enablement/set", params) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 7ff5f6fe39..7bbcc36ee2 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -3,16 +3,24 @@ use std::time::Duration; use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::ConfigReadParams; +use codex_app_server_protocol::ConfigReadResponse; use codex_app_server_protocol::ExperimentalFeature; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetParams; +use codex_app_server_protocol::ExperimentalFeatureEnablementSetResponse; use codex_app_server_protocol::ExperimentalFeatureListParams; use codex_app_server_protocol::ExperimentalFeatureListResponse; use codex_app_server_protocol::ExperimentalFeatureStage; +use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_core::config::ConfigBuilder; use codex_features::FEATURES; use codex_features::Stage; use pretty_assertions::assert_eq; +use serde::de::DeserializeOwned; +use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; @@ -34,13 +42,7 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu .send_experimental_feature_list_request(ExperimentalFeatureListParams::default()) .await?; - let response: JSONRPCResponse = timeout( - DEFAULT_TIMEOUT, - mcp.read_stream_until_response_message(RequestId::Integer(request_id)), - ) - .await??; - - let actual = to_response::(response)?; + let actual = read_response::(&mut mcp, request_id).await?; let expected_data = FEATURES .iter() .map(|spec| { @@ -82,3 +84,203 @@ async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Resu assert_eq!(actual, expected); Ok(()) } + +#[tokio::test] +async fn experimental_feature_enablement_set_applies_to_global_and_thread_config_reads() +-> Result<()> { + let codex_home = TempDir::new()?; + let project_cwd = codex_home.path().join("project"); + std::fs::create_dir_all(&project_cwd)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + for cwd in [None, Some(project_cwd.display().to_string())] { + let ConfigReadResponse { config, .. } = read_config(&mut mcp, cwd).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + } + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_does_not_override_user_config() -> Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join("config.toml"), + "[features]\napps = false\n", + )?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let actual = + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("apps".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(false)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_only_updates_named_features() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement( + &mut mcp, + BTreeMap::from([("plugins".to_string(), true)]), + ) + .await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::from([("plugins".to_string(), true)]), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("plugins")), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_empty_map_is_no_op() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + set_experimental_feature_enablement(&mut mcp, BTreeMap::from([("apps".to_string(), true)])) + .await?; + let actual = set_experimental_feature_enablement(&mut mcp, BTreeMap::new()).await?; + + assert_eq!( + actual, + ExperimentalFeatureEnablementSetResponse { + enablement: BTreeMap::new(), + } + ); + + let ConfigReadResponse { config, .. } = read_config(&mut mcp, /*cwd*/ None).await?; + + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("apps")), + Some(&json!(true)) + ); + + Ok(()) +} + +#[tokio::test] +async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement: BTreeMap::from([("personality".to_string(), true)]), + }) + .await?; + let JSONRPCError { error, .. } = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.code, -32600); + assert!( + error + .message + .contains("unsupported feature enablement `personality`"), + "{}", + error.message + ); + assert!(error.message.contains("apps, plugins"), "{}", error.message); + + Ok(()) +} + +async fn set_experimental_feature_enablement( + mcp: &mut McpProcess, + enablement: BTreeMap, +) -> Result { + let request_id = mcp + .send_experimental_feature_enablement_set_request(ExperimentalFeatureEnablementSetParams { + enablement, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_config(mcp: &mut McpProcess, cwd: Option) -> Result { + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: false, + cwd, + }) + .await?; + read_response(mcp, request_id).await +} + +async fn read_response(mcp: &mut McpProcess, request_id: i64) -> Result { + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} From 2250508c2e900c577c48de2620d4f3cd0575e1c9 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Tue, 24 Mar 2026 20:03:10 -0700 Subject: [PATCH 19/50] TUI plugin menu cleanup - hide app ID (#15708) - Hide App ID from plugin details page. --- codex-rs/tui/src/chatwidget/plugins.rs | 14 +------------- codex-rs/tui_app_server/src/chatwidget/plugins.rs | 14 +------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index ce08cc8791..120b7237ee 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -420,13 +420,6 @@ impl ChatWidget { } else { "Not installed yet." }; - let description = app - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -437,12 +430,7 @@ impl ChatWidget { )); header.push(Line::from(status_label.dim())); - let mut items = vec![SelectionItem { - name: app.name.clone(), - description, - is_disabled: true, - ..Default::default() - }]; + let mut items = Vec::new(); if let Some(install_url) = app.install_url.clone() { let install_label = if is_installed { diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index ce08cc8791..120b7237ee 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -420,13 +420,6 @@ impl ChatWidget { } else { "Not installed yet." }; - let description = app - .description - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let mut header = ColumnRenderable::new(); header.push(Line::from("Plugins".bold())); header.push(Line::from( @@ -437,12 +430,7 @@ impl ChatWidget { )); header.push(Line::from(status_label.dim())); - let mut items = vec![SelectionItem { - name: app.name.clone(), - description, - is_disabled: true, - ..Default::default() - }]; + let mut items = Vec::new(); if let Some(install_url) = app.install_url.clone() { let install_label = if is_installed { From fece9ce7455261bf8a158c350970984bfff581d1 Mon Sep 17 00:00:00 2001 From: Shaqayeq Date: Tue, 24 Mar 2026 20:12:52 -0700 Subject: [PATCH 20/50] Fix stale quickstart integration assertion (#15677) TL;DR: update the quickstart integration assertion to match the current example output. - replace the stale `Status:` expectation for `01_quickstart_constructor` with `Server:`, `Items:`, and `Text:` - keep the existing guard against `Server: unknown` --- sdk/python/tests/test_real_app_server_integration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/python/tests/test_real_app_server_integration.py b/sdk/python/tests/test_real_app_server_integration.py index b5e37c444c..3c114f1202 100644 --- a/sdk/python/tests/test_real_app_server_integration.py +++ b/sdk/python/tests/test_real_app_server_integration.py @@ -505,7 +505,7 @@ def test_real_examples_run_and_assert( out = result.stdout if folder == "01_quickstart_constructor": - assert "Status:" in out and "Text:" in out + assert "Server:" in out and "Items:" in out and "Text:" in out assert "Server: unknown" not in out elif folder == "02_turn_run": assert "thread_id:" in out and "turn_id:" in out and "status:" in out From 0bff38c54a89f4658cfe79466e8a17a469f9e201 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 20:31:21 -0700 Subject: [PATCH 21/50] [plugins] Flip the flags. (#15713) - [x] Flip the `plugins` and `apps` flags. --- codex-rs/core/src/plugins/discoverable_tests.rs | 6 ++++++ codex-rs/core/src/tools/spec_tests.rs | 8 +++----- codex-rs/features/src/lib.rs | 12 ++++-------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index cb2ac15493..fd9df9ddf6 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -44,6 +44,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu let codex_home = tempdir().expect("tempdir should succeed"); let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path()); write_openai_curated_marketplace(&curated_root, &["slack"]); + write_file( + &codex_home.path().join(crate::config::CONFIG_TOML_FILE), + r#"[features] +plugins = false +"#, + ); let config = load_plugins_config(codex_home.path()).await; let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index dd806f887b..b5f50ae6ff 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2075,11 +2075,9 @@ fn tool_suggest_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); features.enable(Feature::ToolSuggest); - for feature in [Feature::Apps, Feature::Plugins] { - if feature != disabled_feature { - features.enable(feature); - } - } + features.enable(Feature::Apps); + features.enable(Feature::Plugins); + features.disable(disabled_feature); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index f49428cec5..6128fe43fa 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -714,12 +714,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Apps, key: "apps", - stage: Stage::Experimental { - name: "Apps", - menu_description: "Use a connected ChatGPT App using \"$\". Install Apps via /apps command. Restart Codex after enabling.", - announcement: "NEW: Use ChatGPT Apps (Connectors) in Codex via $ mentions. Enable in /experimental and restart Codex!", - }, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::ToolSuggest, @@ -730,8 +726,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::Plugins, key: "plugins", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::ImageGeneration, From 8c62829a2b0e46fe0a9b62145eddad696fbac700 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Tue, 24 Mar 2026 21:52:11 -0700 Subject: [PATCH 22/50] [plugins] Flip on additional flags. (#15719) - [x] Flip on additional flags. --- codex-rs/core/src/tools/spec_tests.rs | 3 +-- codex-rs/features/src/lib.rs | 8 ++++---- codex-rs/features/src/tests.rs | 12 +++++++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index b5f50ae6ff..c114761761 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2030,8 +2030,7 @@ fn search_tool_requires_model_capability_only() { fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); - features.enable(Feature::Apps); - features.enable(Feature::Plugins); + features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 6128fe43fa..be98fac69a 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -720,8 +720,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolSuggest, key: "tool_suggest", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Plugins, @@ -778,8 +778,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::ToolCallMcpElicitation, key: "tool_call_mcp_elicitation", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::Personality, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index faf02b083e..7c37ef6175 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -115,9 +115,9 @@ fn request_permissions_tool_is_under_development() { } #[test] -fn tool_suggest_is_under_development() { - assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment); - assert_eq!(Feature::ToolSuggest.default_enabled(), false); +fn tool_suggest_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable); + assert_eq!(Feature::ToolSuggest.default_enabled(), true); } #[test] @@ -138,6 +138,12 @@ fn image_generation_is_under_development() { assert_eq!(Feature::ImageGeneration.default_enabled(), false); } +#[test] +fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { + assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable); + assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); +} + #[test] fn image_detail_original_feature_is_under_development() { assert_eq!( From 95ba76262059226193af10fdfbbb529000f8463c Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 24 Mar 2026 22:54:18 -0700 Subject: [PATCH 23/50] fix: support split carveouts in windows restricted-token sandbox (#14172) ## Summary - keep legacy Windows restricted-token sandboxing as the supported baseline - support the split-policy subset that restricted-token can enforce directly today - support full-disk read, the same writable root set as legacy `WorkspaceWrite`, and extra read-only carveouts under those writable roots via additional deny-write ACLs - continue to fail closed for unsupported split-only shapes, including explicit unreadable (`none`) carveouts, reopened writable descendants under read-only carveouts, and writable root sets that do not match the legacy workspace roots ## Example Given a filesystem policy like: ```toml ":root" = "read" ":cwd" = "write" "./docs" = "read" ``` the restricted-token backend can keep the workspace writable while denying writes under `docs` by layering an extra deny-write carveout on top of the legacy workspace-write roots. A policy like: ```toml "/workspace" = "write" "/workspace/docs" = "read" "/workspace/docs/tmp" = "write" ``` still fails closed, because the unelevated backend cannot reopen the nested writable descendant safely. ## Stack -> fix: support split carveouts in windows restricted-token sandbox #14172 fix: support split carveouts in windows elevated sandbox #14568 --- codex-rs/app-server/src/command_exec.rs | 60 ++-- codex-rs/core/README.md | 14 +- codex-rs/core/src/exec.rs | 268 +++++++++++---- codex-rs/core/src/exec_tests.rs | 306 ++++++++++++------ codex-rs/core/src/sandboxing/mod.rs | 38 +++ codex-rs/core/src/tasks/user_shell.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 2 + codex-rs/windows-sandbox-rs/src/lib.rs | 34 +- 8 files changed, 538 insertions(+), 185 deletions(-) diff --git a/codex-rs/app-server/src/command_exec.rs b/codex-rs/app-server/src/command_exec.rs index 8f92cd0ba2..a1a042de6a 100644 --- a/codex-rs/app-server/src/command_exec.rs +++ b/codex-rs/app-server/src/command_exec.rs @@ -734,21 +734,21 @@ mod tests { access: ReadOnlyAccess::FullAccess, network_access: false, }; - ExecRequest { - command: vec!["cmd".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::DefaultTimeout, - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::WindowsRestrictedToken, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - } + ExecRequest::new( + vec!["cmd".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::DefaultTimeout, + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::WindowsRestrictedToken, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ) } #[tokio::test] @@ -846,21 +846,21 @@ mod tests { outgoing: Arc::new(OutgoingMessageSender::new(tx)), request_id: request_id.clone(), process_id: Some("proc-100".to_string()), - exec_request: ExecRequest { - command: vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], - cwd: PathBuf::from("."), - env: HashMap::new(), - network: None, - expiration: ExecExpiration::Cancellation(CancellationToken::new()), - capture_policy: codex_core::exec::ExecCapturePolicy::ShellTool, - sandbox: SandboxType::None, - windows_sandbox_level: WindowsSandboxLevel::Disabled, - windows_sandbox_private_desktop: false, - sandbox_policy: sandbox_policy.clone(), - file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), - network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), - arg0: None, - }, + exec_request: ExecRequest::new( + vec!["sh".to_string(), "-lc".to_string(), "sleep 30".to_string()], + PathBuf::from("."), + HashMap::new(), + /*network*/ None, + ExecExpiration::Cancellation(CancellationToken::new()), + codex_core::exec::ExecCapturePolicy::ShellTool, + SandboxType::None, + WindowsSandboxLevel::Disabled, + /*windows_sandbox_private_desktop*/ false, + sandbox_policy.clone(), + FileSystemSandboxPolicy::from(&sandbox_policy), + NetworkSandboxPolicy::from(&sandbox_policy), + /*arg0*/ None, + ), started_network_proxy: None, tty: false, stream_stdin: false, diff --git a/codex-rs/core/README.md b/codex-rs/core/README.md index c27ec5460e..c154ef463d 100644 --- a/codex-rs/core/README.md +++ b/codex-rs/core/README.md @@ -51,7 +51,6 @@ Expects the binary containing `codex-core` to run the equivalent of `codex sandb Legacy `SandboxPolicy` / `sandbox_mode` configs are still supported on Linux. They can continue to use the legacy Landlock path when the split filesystem policy is sandbox-equivalent to the legacy model after `cwd` resolution. - Split filesystem policies that need direct `FileSystemSandboxPolicy` enforcement, such as read-only or denied carveouts under a broader writable root, automatically route through bubblewrap. The legacy Landlock path is used @@ -83,13 +82,18 @@ backend-managed system read roots required for basic execution, such as `C:\ProgramData`. When it is `false`, those extra system roots are omitted. The unelevated restricted-token backend still supports the legacy full-read -Windows model only. Restricted read-only policies continue to fail closed there -instead of running with weaker read enforcement. +Windows model for legacy `ReadOnly` and `WorkspaceWrite` behavior. It also +supports a narrow split-filesystem subset: full-read split policies whose +writable roots still match the legacy `WorkspaceWrite` root set, but add extra +read-only carveouts under those writable roots. New `[permissions]` / split filesystem policies remain supported on Windows only when they round-trip through the legacy `SandboxPolicy` model without -changing semantics. Richer split-only carveouts still fail closed instead of -running with weaker enforcement. +changing semantics. Policies that would require direct read restriction, +explicit unreadable carveouts, reopened writable descendants under read-only +carveouts, different writable root sets, or split carveout support in the +elevated setup/runner backend still fail closed instead of running with weaker +enforcement. ### All Platforms diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 0229e1dd57..a4399ddc46 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -1,6 +1,7 @@ #[cfg(unix)] use std::os::unix::process::ExitStatusExt; +use std::collections::BTreeSet; use std::collections::HashMap; use std::io; use std::path::Path; @@ -32,7 +33,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; use codex_network_proxy::NetworkProxy; -#[cfg(any(target_os = "windows", test))] +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::permissions::FileSystemSandboxKind; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; @@ -41,6 +42,7 @@ use codex_sandboxing::SandboxManager; use codex_sandboxing::SandboxTransformRequest; use codex_sandboxing::SandboxType; use codex_sandboxing::SandboxablePreference; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use codex_utils_pty::process_group::kill_child_process_group; @@ -91,6 +93,17 @@ pub struct ExecParams { pub arg0: Option, } +/// Extra filesystem deny-write carveouts for the non-elevated Windows +/// restricted-token backend. +/// +/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we +/// can support a narrow split-policy subset without changing legacy Windows +/// sandbox semantics. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowsRestrictedTokenFilesystemOverlay { + pub(crate) additional_deny_write_paths: Vec, +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum ExecCapturePolicy { /// Shell-like execs keep the historical output cap and timeout behavior. @@ -269,7 +282,7 @@ pub fn build_exec_request( expiration, capture_policy, }; - let exec_req = manager + let mut exec_req = manager .transform(SandboxTransformRequest { command, policy: sandbox_policy, @@ -288,6 +301,16 @@ pub fn build_exec_request( }) .map(|request| ExecRequest::from_sandbox_exec_request(request, options)) .map_err(CodexErr::from)?; + exec_req.windows_restricted_token_filesystem_overlay = + resolve_windows_restricted_token_filesystem_overlay( + exec_req.sandbox, + &exec_req.sandbox_policy, + &exec_req.file_system_sandbox_policy, + exec_req.network_sandbox_policy, + sandbox_cwd, + exec_req.windows_sandbox_level, + ) + .map_err(CodexErr::UnsupportedOperation)?; Ok(exec_req) } @@ -310,6 +333,7 @@ pub(crate) async fn execute_exec_request( sandbox_policy: _sandbox_policy_from_env, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay, arg0, } = exec_request; let _ = _sandbox_policy_from_env; @@ -334,6 +358,7 @@ pub(crate) async fn execute_exec_request( sandbox, sandbox_policy, &file_system_sandbox_policy, + windows_restricted_token_filesystem_overlay.as_ref(), network_sandbox_policy, stdout_stream, after_spawn, @@ -413,11 +438,11 @@ fn record_windows_sandbox_spawn_failure( async fn exec_windows_sandbox( params: ExecParams, sandbox_policy: &SandboxPolicy, + windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, ) -> Result { use crate::config::find_codex_home; - use codex_protocol::config_types::WindowsSandboxLevel; - use codex_windows_sandbox::run_windows_sandbox_capture; use codex_windows_sandbox::run_windows_sandbox_capture_elevated; + use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths; let ExecParams { command, @@ -456,6 +481,15 @@ async fn exec_windows_sandbox( let command_path = command.first().cloned(); let sandbox_level = windows_sandbox_level; let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated); + let additional_deny_write_paths = windows_restricted_token_filesystem_overlay + .map(|overlay| { + overlay + .additional_deny_write_paths + .iter() + .map(AbsolutePathBuf::to_path_buf) + .collect::>() + }) + .unwrap_or_default(); let spawn_res = tokio::task::spawn_blocking(move || { if use_elevated { run_windows_sandbox_capture_elevated( @@ -469,7 +503,7 @@ async fn exec_windows_sandbox( windows_sandbox_private_desktop, ) } else { - run_windows_sandbox_capture( + run_windows_sandbox_capture_with_extra_deny_write_paths( policy_str.as_str(), &sandbox_cwd, codex_home.as_ref(), @@ -477,6 +511,7 @@ async fn exec_windows_sandbox( &cwd, env, timeout_ms, + &additional_deny_write_paths, windows_sandbox_private_desktop, ) } @@ -785,29 +820,25 @@ impl Default for ExecToolCallOutput { } } -#[cfg_attr(not(target_os = "windows"), allow(unused_variables))] +#[allow(clippy::too_many_arguments)] async fn exec( params: ExecParams, - sandbox: SandboxType, - sandbox_policy: &SandboxPolicy, - file_system_sandbox_policy: &FileSystemSandboxPolicy, + _sandbox: SandboxType, + _sandbox_policy: &SandboxPolicy, + _file_system_sandbox_policy: &FileSystemSandboxPolicy, + _windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>, network_sandbox_policy: NetworkSandboxPolicy, stdout_stream: Option, after_spawn: Option>, ) -> Result { #[cfg(target_os = "windows")] - if sandbox == SandboxType::WindowsRestrictedToken { - let support = windows_restricted_token_sandbox_support( - sandbox, - params.windows_sandbox_level, - sandbox_policy, - file_system_sandbox_policy, - network_sandbox_policy, - ); - if let Some(reason) = support.unsupported_reason { - return Err(CodexErr::Io(io::Error::other(reason))); - } - return exec_windows_sandbox(params, sandbox_policy).await; + if _sandbox == SandboxType::WindowsRestrictedToken { + return exec_windows_sandbox( + params, + _sandbox_policy, + _windows_restricted_token_filesystem_overlay, + ) + .await; } let ExecParams { command, @@ -852,54 +883,175 @@ async fn exec( } #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -#[derive(Debug, PartialEq, Eq)] -struct WindowsRestrictedTokenSandboxSupport { - should_use: bool, - unsupported_reason: Option, -} - -#[cfg(any(target_os = "windows", test))] -fn windows_restricted_token_sandbox_support( +fn should_use_windows_restricted_token_sandbox( sandbox: SandboxType, - windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel, sandbox_policy: &SandboxPolicy, file_system_sandbox_policy: &FileSystemSandboxPolicy, - network_sandbox_policy: NetworkSandboxPolicy, -) -> WindowsRestrictedTokenSandboxSupport { - if sandbox != SandboxType::WindowsRestrictedToken { - return WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: None, - }; - } - - // Windows currently reuses SandboxType::WindowsRestrictedToken for both - // the legacy restricted-token backend and the elevated setup/runner path. - // The sandbox level decides whether restricted read-only policies are - // supported. - let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted +) -> bool { + sandbox == SandboxType::WindowsRestrictedToken + && file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted && !matches!( sandbox_policy, SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } ) - && (matches!( - windows_sandbox_level, - codex_protocol::config_types::WindowsSandboxLevel::Elevated - ) || sandbox_policy.has_full_disk_read_access()); +} - let unsupported_reason = if should_use { - None - } else { - Some(format!( +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) fn unsupported_windows_restricted_token_sandbox_reason( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> Option { + resolve_windows_restricted_token_filesystem_overlay( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + sandbox_policy_cwd, + windows_sandbox_level, + ) + .err() +} + +pub(crate) fn resolve_windows_restricted_token_filesystem_overlay( + sandbox: SandboxType, + sandbox_policy: &SandboxPolicy, + file_system_sandbox_policy: &FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + sandbox_policy_cwd: &Path, + windows_sandbox_level: WindowsSandboxLevel, +) -> std::result::Result, String> { + if sandbox != SandboxType::WindowsRestrictedToken { + return Ok(None); + } + + let needs_direct_runtime_enforcement = file_system_sandbox_policy + .needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd); + + if should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) && !needs_direct_runtime_enforcement + { + return Ok(None); + } + + if !should_use_windows_restricted_token_sandbox( + sandbox, + sandbox_policy, + file_system_sandbox_policy, + ) { + return Err(format!( "windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed", file_system_sandbox_policy.kind, - )) - }; - - WindowsRestrictedTokenSandboxSupport { - should_use, - unsupported_reason, + )); } + + if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken { + return Err( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy.has_full_disk_read_access() { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string(), + ); + } + + if !file_system_sandbox_policy + .get_unreadable_roots_with_cwd(sandbox_policy_cwd) + .is_empty() + { + return Err( + "windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + + let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let split_writable_roots = + file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd); + let legacy_root_paths: BTreeSet = legacy_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + let split_root_paths: BTreeSet = split_writable_roots + .iter() + .map(|root| normalize_windows_overlay_path(root.root.as_path())) + .collect::>()?; + + if legacy_root_paths != split_root_paths { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + } + + for writable_root in &split_writable_roots { + for read_only_subpath in &writable_root.read_only_subpaths { + if split_writable_roots.iter().any(|candidate| { + candidate.root.as_path() != writable_root.root.as_path() + && candidate + .root + .as_path() + .starts_with(read_only_subpath.as_path()) + }) { + return Err( + "windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed" + .to_string(), + ); + } + } + } + + let mut additional_deny_write_paths = BTreeSet::new(); + for split_root in &split_writable_roots { + let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?; + let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| { + normalize_windows_overlay_path(candidate.root.as_path()) + .is_ok_and(|candidate_path| candidate_path == split_root_path) + }) else { + return Err( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string(), + ); + }; + + for read_only_subpath in &split_root.read_only_subpaths { + if !legacy_root + .read_only_subpaths + .iter() + .any(|candidate| candidate == read_only_subpath) + { + additional_deny_write_paths + .insert(normalize_windows_overlay_path(read_only_subpath.as_path())?); + } + } + } + + if additional_deny_write_paths.is_empty() { + return Ok(None); + } + + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: additional_deny_write_paths + .into_iter() + .map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string())) + .collect::>()?, + })) +} + +fn normalize_windows_overlay_path(path: &Path) -> std::result::Result { + AbsolutePathBuf::from_absolute_path(dunce::simplified(path)) + .map(AbsolutePathBuf::into_path_buf) + .map_err(|err| err.to_string()) } /// Consumes the output of a child process according to the configured capture diff --git a/codex-rs/core/src/exec_tests.rs b/codex-rs/core/src/exec_tests.rs index 6f452d6e8a..074db95e1c 100644 --- a/codex-rs/core/src/exec_tests.rs +++ b/codex-rs/core/src/exec_tests.rs @@ -269,6 +269,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> { SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -308,6 +309,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p SandboxType::None, &SandboxPolicy::DangerFullAccess, &FileSystemSandboxPolicy::unrestricted(), + /*windows_restricted_token_filesystem_overlay*/ None, NetworkSandboxPolicy::Enabled, /*stdout_stream*/ None, /*after_spawn*/ None, @@ -376,42 +378,30 @@ fn windows_restricted_token_skips_external_sandbox_policies() { let policy = SandboxPolicy::ExternalSandbox { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() - ), - } + false ); } #[test] fn windows_restricted_token_runs_for_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); + let file_system_policy = FileSystemSandboxPolicy::from(&policy); assert_eq!( - windows_restricted_token_sandbox_support( + should_use_windows_restricted_token_sandbox( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, - NetworkSandboxPolicy::Restricted, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + true ); } @@ -421,70 +411,39 @@ fn windows_restricted_token_rejects_network_only_restrictions() { network_access: codex_protocol::protocol::NetworkAccess::Restricted, }; let file_system_policy = FileSystemSandboxPolicy::unrestricted(); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - } - ); + Some( + "windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string() + ) + ); } #[test] fn windows_restricted_token_allows_legacy_restricted_policies() { let policy = SandboxPolicy::new_read_only_policy(); - let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]); - - assert_eq!( - windows_restricted_token_sandbox_support( - SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, - &policy, - &file_system_policy, - NetworkSandboxPolicy::Restricted, - ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } - ); -} - -#[test] -fn windows_restricted_token_rejects_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, - network_access: false, - }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: false, - unsupported_reason: Some( - "windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string() - ), - }, - "restricted-token should fail closed for restricted read-only policies" + None ); } @@ -494,50 +453,213 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() { writable_roots: vec![], read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let sandbox_policy_cwd = std::env::current_dir().expect("cwd"); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Disabled, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + &sandbox_policy_cwd, + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, - } + None ); } #[test] -fn windows_elevated_sandbox_allows_restricted_read_only_policies() { - let policy = SandboxPolicy::ReadOnly { - access: codex_protocol::protocol::ReadOnlyAccess::Restricted { - include_platform_defaults: true, - readable_roots: vec![], - }, +fn windows_restricted_token_rejects_split_only_filesystem_policies() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, }; - let file_system_policy = FileSystemSandboxPolicy::from(&policy); + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); assert_eq!( - windows_restricted_token_sandbox_support( + unsupported_windows_restricted_token_sandbox_reason( SandboxType::WindowsRestrictedToken, - WindowsSandboxLevel::Elevated, &policy, &file_system_policy, NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, ), - WindowsRestrictedTokenSandboxSupport { - should_use: true, - unsupported_reason: None, + Some( + "windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_rejects_root_write_read_only_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, }, - "elevated Windows sandbox should keep restricted read-only support enabled" + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::RestrictedToken, + ), + Some( + "windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed" + .to_string() + ) + ); +} + +#[test] +fn windows_restricted_token_supports_full_read_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let cwd = dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir"); + let docs = cwd.join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + resolve_windows_restricted_token_filesystem_overlay( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + &cwd, + WindowsSandboxLevel::RestrictedToken, + ), + Ok(Some(WindowsRestrictedTokenFilesystemOverlay { + additional_deny_write_paths: vec![ + codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + ], + })) + ); +} + +#[test] +fn windows_elevated_rejects_split_write_read_carveouts() { + let temp_dir = tempfile::TempDir::new().expect("tempdir"); + let docs = temp_dir.path().join("docs"); + std::fs::create_dir_all(&docs).expect("create docs"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess, + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }; + let file_system_policy = FileSystemSandboxPolicy::restricted(vec![ + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::Root, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Special { + value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory, + }, + access: codex_protocol::permissions::FileSystemAccessMode::Write, + }, + codex_protocol::permissions::FileSystemSandboxEntry { + path: codex_protocol::permissions::FileSystemPath::Path { + path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs) + .expect("absolute docs"), + }, + access: codex_protocol::permissions::FileSystemAccessMode::Read, + }, + ]); + + assert_eq!( + unsupported_windows_restricted_token_sandbox_reason( + SandboxType::WindowsRestrictedToken, + &policy, + &file_system_policy, + NetworkSandboxPolicy::Restricted, + temp_dir.path(), + WindowsSandboxLevel::Elevated, + ), + Some( + "windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed" + .to_string() + ) ); } @@ -549,7 +671,7 @@ fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions() select_process_exec_tool_sandbox_type( &FileSystemSandboxPolicy::unrestricted(), NetworkSandboxPolicy::Restricted, - WindowsSandboxLevel::Disabled, + codex_protocol::config_types::WindowsSandboxLevel::Disabled, false, ), expected @@ -581,16 +703,17 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> "-c".to_string(), "sleep 60 & echo $!; sleep 60".to_string(), ]; + let cwd = std::env::current_dir()?; let env: HashMap = std::env::vars().collect(); let params = ExecParams { command, - cwd: std::env::current_dir()?, + cwd, expiration: 500.into(), capture_policy: ExecCapturePolicy::ShellTool, env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, @@ -601,6 +724,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()> SandboxType::None, &SandboxPolicy::new_read_only_policy(), &FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()), + None, NetworkSandboxPolicy::Restricted, None, None, @@ -648,7 +772,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { env, network: None, sandbox_permissions: SandboxPermissions::UseDefault, - windows_sandbox_level: WindowsSandboxLevel::Disabled, + windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled, windows_sandbox_private_desktop: false, justification: None, arg0: None, diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 81f2a0af2c..26f4f733c6 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -11,6 +11,7 @@ use crate::exec::ExecCapturePolicy; use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::StdoutStream; +use crate::exec::WindowsRestrictedTokenFilesystemOverlay; use crate::exec::execute_exec_request; #[cfg(target_os = "macos")] use crate::spawn::CODEX_SANDBOX_ENV_VAR; @@ -46,10 +47,46 @@ pub struct ExecRequest { pub sandbox_policy: SandboxPolicy, pub file_system_sandbox_policy: FileSystemSandboxPolicy, pub network_sandbox_policy: NetworkSandboxPolicy, + pub(crate) windows_restricted_token_filesystem_overlay: + Option, pub arg0: Option, } impl ExecRequest { + #[allow(clippy::too_many_arguments)] + pub fn new( + command: Vec, + cwd: PathBuf, + env: HashMap, + network: Option, + expiration: ExecExpiration, + capture_policy: ExecCapturePolicy, + sandbox: SandboxType, + windows_sandbox_level: WindowsSandboxLevel, + windows_sandbox_private_desktop: bool, + sandbox_policy: SandboxPolicy, + file_system_sandbox_policy: FileSystemSandboxPolicy, + network_sandbox_policy: NetworkSandboxPolicy, + arg0: Option, + ) -> Self { + Self { + command, + cwd, + env, + network, + expiration, + capture_policy, + sandbox, + windows_sandbox_level, + windows_sandbox_private_desktop, + sandbox_policy, + file_system_sandbox_policy, + network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, + arg0, + } + } + pub(crate) fn from_sandbox_exec_request( request: SandboxExecRequest, options: ExecOptions, @@ -94,6 +131,7 @@ impl ExecRequest { sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0, } } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 682902f2d6..e118d38095 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -175,6 +175,7 @@ pub(crate) async fn execute_user_shell_command( sandbox_policy: sandbox_policy.clone(), file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy), network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy), + windows_restricted_token_filesystem_overlay: None, arg0: None, }; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 8c4f24ed05..4e107232d0 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -137,6 +137,7 @@ pub(super) async fn try_run_zsh_fork( sandbox_policy, file_system_sandbox_policy, network_sandbox_policy, + windows_restricted_token_filesystem_overlay: _windows_restricted_token_filesystem_overlay, arg0, } = sandbox_exec_request; let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?; @@ -909,6 +910,7 @@ impl ShellCommandExecutor for CoreShellCommandExecutor { sandbox_policy: self.sandbox_policy.clone(), file_system_sandbox_policy: self.file_system_sandbox_policy.clone(), network_sandbox_policy: self.network_sandbox_policy, + windows_restricted_token_filesystem_overlay: None, arg0: self.arg0.clone(), }, /*stdout_stream*/ None, diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index cb4be52750..73193a207d 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -144,6 +144,8 @@ pub use token::get_current_token_for_restriction; #[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_capture; #[cfg(target_os = "windows")] +pub use windows_impl::run_windows_sandbox_capture_with_extra_deny_write_paths; +#[cfg(target_os = "windows")] pub use windows_impl::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] pub use windows_impl::CaptureResult; @@ -259,6 +261,30 @@ mod windows_impl { #[allow(clippy::too_many_arguments)] pub fn run_windows_sandbox_capture( + policy_json_or_preset: &str, + sandbox_policy_cwd: &Path, + codex_home: &Path, + command: Vec, + cwd: &Path, + env_map: HashMap, + timeout_ms: Option, + use_private_desktop: bool, + ) -> Result { + run_windows_sandbox_capture_with_extra_deny_write_paths( + policy_json_or_preset, + sandbox_policy_cwd, + codex_home, + command, + cwd, + env_map, + timeout_ms, + &[], + use_private_desktop, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn run_windows_sandbox_capture_with_extra_deny_write_paths( policy_json_or_preset: &str, sandbox_policy_cwd: &Path, codex_home: &Path, @@ -266,6 +292,7 @@ mod windows_impl { cwd: &Path, mut env_map: HashMap, timeout_ms: Option, + additional_deny_write_paths: &[PathBuf], use_private_desktop: bool, ) -> Result { let policy = parse_policy(policy_json_or_preset)?; @@ -335,8 +362,13 @@ mod windows_impl { } let persist_aces = is_workspace_write; - let AllowDenyPaths { allow, deny } = + let AllowDenyPaths { allow, mut deny } = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); + for path in additional_deny_write_paths { + if path.exists() { + deny.insert(path.clone()); + } + } let canonical_cwd = canonicalize_path(¤t_dir); let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); unsafe { From c0ffd000dd6d246be139b888b316531c80d6538a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 25 Mar 2026 00:28:07 -0600 Subject: [PATCH 24/50] Fix stale turn steering fallback in tui_app_server (#15714) This PR adds code to recover from a narrow app-server timing race where a follow-up can be sent after the previous turn has already ended but before the TUI has observed that completion. Instead of surfacing turn/steer failed: no active turn to steer, the client now treats that as a stale active-turn cache and falls back to starting a fresh turn, matching the intended submit behavior more closely. This is similar to the strategy employed by other app server clients (notably, the IDE extension and desktop app). This race exists because the current app-server API makes the client choose between two separate RPCs, turn/steer and turn/start, based on its local view of whether a turn is still active. That view is replicated from asynchronous notifications, so it can be stale for a brief window. The server may already have ended the turn while the client still believes it is in progress. Since the choice is made client-side rather than atomically on the server, tui_app_server can occasionally send turn/steer for a turn that no longer exists. --- codex-rs/tui_app_server/src/app.rs | 50 ++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 29da170b82..b1773d0ac2 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -621,6 +621,10 @@ impl ThreadEventStore { fn active_turn_id(&self) -> Option<&str> { self.active_turn_id.as_deref() } + + fn clear_active_turn_id(&mut self) { + self.active_turn_id = None; + } } #[derive(Debug)] @@ -986,6 +990,13 @@ fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option bool { + let TypedRequestError::Server { source, .. } = error else { + return false; + }; + source.message == "no active turn to steer" +} + impl App { pub fn chatwidget_init_for_forked_or_resumed_thread( &self, @@ -2021,23 +2032,32 @@ impl App { collaboration_mode, personality, } => { + let mut should_start_turn = true; if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await { match app_server .turn_steer(thread_id, turn_id, items.to_vec()) .await { - Ok(_) => {} + Ok(_) => return Ok(true), Err(error) => { if let Some(turn_error) = active_turn_not_steerable_turn_error(&error) { if !self.chat_widget.enqueue_rejected_steer() { self.chat_widget.add_error_message(turn_error.message); } + return Ok(true); + } else if active_turn_missing_steer_error(&error) { + if let Some(channel) = self.thread_event_channels.get(&thread_id) { + let mut store = channel.store.lock().await; + store.clear_active_turn_id(); + } + should_start_turn = true; } else { return Err(error.into()); } } } - } else { + } + if should_start_turn { app_server .turn_start( thread_id, @@ -8043,6 +8063,17 @@ guardian_approval = true assert_eq!(refreshed_store.active_turn_id(), Some("turn-2")); } + #[test] + fn thread_event_store_clear_active_turn_id_resets_cached_turn() { + let mut store = ThreadEventStore::new(8); + let thread_id = ThreadId::new(); + store.push_notification(turn_started_notification(thread_id, "turn-1")); + + store.clear_active_turn_id(); + + assert_eq!(store.active_turn_id(), None); + } + #[test] fn thread_event_store_rebase_preserves_resolved_request_state() { let thread_id = ThreadId::new(); @@ -8273,6 +8304,21 @@ guardian_approval = true ); } + #[test] + fn active_turn_missing_steer_error_detects_stale_turn_race() { + let error = TypedRequestError::Server { + method: "turn/steer".to_string(), + source: JSONRPCErrorError { + code: -32602, + message: "no active turn to steer".to_string(), + data: None, + }, + }; + + assert!(active_turn_missing_steer_error(&error)); + assert_eq!(active_turn_not_steerable_turn_error(&error), None); + } + #[test] fn select_model_availability_nux_uses_existing_model_order_as_priority() { let mut presets = all_model_presets(); From e590fad50b832bebc68e7f18067cad5fe13a7d85 Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 25 Mar 2026 00:00:25 -0700 Subject: [PATCH 25/50] [plugins] Add a flag for tool search. (#15722) - [x] Add a flag for tool search. --- codex-rs/app-server/src/config_api.rs | 8 ++- .../suite/v2/experimental_feature_list.rs | 43 ++++++++++++++-- codex-rs/core/config.schema.json | 6 +++ codex-rs/core/src/tools/spec.rs | 3 +- codex-rs/core/src/tools/spec_tests.rs | 25 +++++++++- codex-rs/core/tests/suite/search_tool.rs | 49 ++++++++++++++++++- codex-rs/features/src/lib.rs | 8 +++ codex-rs/features/src/tests.rs | 6 +++ 8 files changed, 141 insertions(+), 7 deletions(-) diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 9ade74914d..4aa2982a81 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -40,7 +40,13 @@ use std::sync::RwLock; use toml::Value as TomlValue; use tracing::warn; -const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &["apps", "plugins"]; +const SUPPORTED_EXPERIMENTAL_FEATURE_ENABLEMENT: &[&str] = &[ + "apps", + "plugins", + "tool_search", + "tool_suggest", + "tool_call_mcp_elicitation", +]; #[async_trait] pub(crate) trait UserConfigReloader: Send + Sync { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs index 7bbcc36ee2..3063830d5a 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_feature_list.rs @@ -163,14 +163,24 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .await?; let actual = set_experimental_feature_enablement( &mut mcp, - BTreeMap::from([("plugins".to_string(), true)]), + BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), ) .await?; assert_eq!( actual, ExperimentalFeatureEnablementSetResponse { - enablement: BTreeMap::from([("plugins".to_string(), true)]), + enablement: BTreeMap::from([ + ("plugins".to_string(), true), + ("tool_search".to_string(), true), + ("tool_suggest".to_string(), true), + ("tool_call_mcp_elicitation".to_string(), false), + ]), } ); @@ -190,6 +200,27 @@ async fn experimental_feature_enablement_set_only_updates_named_features() -> Re .and_then(|features| features.get("plugins")), Some(&json!(true)) ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_search")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_suggest")), + Some(&json!(true)) + ); + assert_eq!( + config + .additional + .get("features") + .and_then(|features| features.get("tool_call_mcp_elicitation")), + Some(&json!(false)) + ); Ok(()) } @@ -249,7 +280,13 @@ async fn experimental_feature_enablement_set_rejects_non_allowlisted_feature() - "{}", error.message ); - assert!(error.message.contains("apps, plugins"), "{}", error.message); + assert!( + error + .message + .contains("apps, plugins, tool_search, tool_suggest, tool_call_mcp_elicitation"), + "{}", + error.message + ); Ok(()) } diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 0b7cf3ced9..3e2d7ff689 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -485,6 +485,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, @@ -2099,6 +2102,9 @@ "tool_call_mcp_elicitation": { "type": "boolean" }, + "tool_search": { + "type": "boolean" + }, "tool_suggest": { "type": "boolean" }, diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 77c2954067..5686b4bb78 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -388,7 +388,8 @@ impl ToolsConfig { let include_request_user_input = !matches!(session_source, SessionSource::SubAgent(_)); let include_default_mode_request_user_input = include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); - let include_search_tool = model_info.supports_search_tool; + let include_search_tool = + model_info.supports_search_tool && features.enabled(Feature::ToolSearch); let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index c114761761..36a0990393 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1858,6 +1858,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -1976,7 +1977,7 @@ fn search_tool_description_lists_each_codex_apps_connector_once() { } #[test] -fn search_tool_requires_model_capability_only() { +fn search_tool_requires_model_capability_and_feature_flag() { let model_info = search_capable_model_info(); let app_tools = Some(HashMap::from([( "mcp__codex_apps__calendar_create_event".to_string(), @@ -2012,6 +2013,22 @@ fn search_tool_requires_model_capability_only() { }); let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build(); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2030,6 +2047,7 @@ fn search_tool_requires_model_capability_only() { fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -2073,6 +2091,7 @@ fn tool_suggest_requires_apps_and_plugins_features() { for disabled_feature in [Feature::Apps, Feature::Plugins] { let mut features = Features::with_defaults(); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); features.enable(Feature::Apps); features.enable(Feature::Plugins); @@ -2110,6 +2129,7 @@ fn search_tool_description_handles_no_enabled_apps() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2136,6 +2156,7 @@ fn search_tool_description_falls_back_to_connector_name_without_description() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2184,6 +2205,7 @@ fn search_tool_registers_namespaced_app_tool_aliases() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::Apps); + features.enable(Feature::ToolSearch); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, @@ -2250,6 +2272,7 @@ fn tool_suggest_description_lists_discoverable_tools() { let mut features = Features::with_defaults(); features.enable(Feature::Apps); features.enable(Feature::Plugins); + features.enable(Feature::ToolSearch); features.enable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { diff --git a/codex-rs/core/tests/suite/search_tool.rs b/codex-rs/core/tests/suite/search_tool.rs index 0eee2f1d3d..423c57a8c6 100644 --- a/codex-rs/core/tests/suite/search_tool.rs +++ b/codex-rs/core/tests/suite/search_tool.rs @@ -86,7 +86,7 @@ fn tool_search_output_tools(request: &ResponsesRequest, call_id: &str) -> Vec TestCodexBuilder { test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) @@ -169,6 +177,45 @@ async fn search_tool_flag_adds_tool_search() -> Result<()> { Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_search_disabled_by_default_exposes_apps_tools_directly() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount_searchable(&server).await?; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + configure_apps_without_tool_search(config, apps_server.chatgpt_base_url.as_str()) + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "list tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!(!tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME)); + assert!(tools.iter().any(|name| name == CALENDAR_CREATE_TOOL)); + assert!(tools.iter().any(|name| name == CALENDAR_LIST_TOOL)); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn search_tool_is_hidden_for_api_key_auth() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index be98fac69a..ccde6a5e54 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -142,6 +142,8 @@ pub enum Feature { SpawnCsv, /// Enable apps. Apps, + /// Enable the tool_search tool for apps. + ToolSearch, /// Enable discoverable tool suggestions for apps. ToolSuggest, /// Enable plugins. @@ -717,6 +719,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Stable, default_enabled: true, }, + FeatureSpec { + id: Feature::ToolSearch, + key: "tool_search", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ToolSuggest, key: "tool_suggest", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 7c37ef6175..ecfa87b41b 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -120,6 +120,12 @@ fn tool_suggest_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSuggest.default_enabled(), true); } +#[test] +fn tool_search_is_under_development_and_disabled_by_default() { + assert_eq!(Feature::ToolSearch.stage(), Stage::UnderDevelopment); + assert_eq!(Feature::ToolSearch.default_enabled(), false); +} + #[test] fn use_linux_sandbox_bwrap_is_a_removed_feature_key() { assert_eq!( From f5dccab5cf9674f46c8bd2cc9606d9d6cd94fadb Mon Sep 17 00:00:00 2001 From: xl-openai Date: Wed, 25 Mar 2026 01:55:10 -0700 Subject: [PATCH 26/50] Update plugin creator skill. (#15734) Add support for home-local plugin + fix policy. --- .../src/assets/samples/plugin-creator/SKILL.md | 12 ++++++++++++ .../references/plugin-json-spec.md | 10 +++++++--- .../scripts/create_basic_plugin.py | 16 +++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md index 28d47b7548..5108f549a7 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/SKILL.md @@ -26,6 +26,15 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py ` as the root and use: + +```bash +python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin \ + --path ~/plugins \ + --marketplace-path ~/.agents/plugins/marketplace.json \ + --with-marketplace +``` + 4. Generate/adjust optional companion folders as needed: ```bash @@ -37,6 +46,7 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin - ## What this skill creates +- If the user has not made the plugin location explicit, ask whether they want a repo-local plugin or a home-local plugin before generating marketplace entries. - Creates plugin root at `///`. - Always creates `///.codex-plugin/plugin.json`. - Fills the manifest with the full schema shape, placeholder values, and the complete `interface` section. @@ -58,6 +68,8 @@ python3 .agents/skills/plugin-creator/scripts/create_basic_plugin.py my-plugin - ## Marketplace workflow - `marketplace.json` always lives at `/.agents/plugins/marketplace.json`. +- For a home-local plugin, use the same convention with `` as the root: + `~/.agents/plugins/marketplace.json` plus `./plugins/`. - Marketplace root metadata supports top-level `name` plus optional `interface.displayName`. - Treat plugin order in `plugins[]` as render order in Codex. Append new entries unless a user explicitly asks to reorder the list. - `displayName` belongs inside the marketplace `interface` object, not individual `plugins[]` entries. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index 3db14d332a..6831aae9db 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -115,8 +115,10 @@ "source": "local", "path": "./plugins/linear" }, - "installPolicy": "AVAILABLE", - "authPolicy": "ON_INSTALL", + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, "category": "Productivity" } ] @@ -142,7 +144,9 @@ - `source` (`string`): Use `local` for this repo workflow. - `path` (`string`): Relative plugin path based on the marketplace root. - Repo plugin: `./plugins/` - - Local plugin in `~/.agents/plugins/marketplace.json`: `./.codex/plugins/` + - Local plugin in `~/.agents/plugins/marketplace.json`: `./plugins/` + - The same relative path convention is used for both repo-rooted and home-rooted marketplaces. + - Example: with `~/.agents/plugins/marketplace.json`, `./plugins/` resolves to `~/plugins/`. - `policy` (`object`): Marketplace policy block. Always include it. - `installation` (`string`): Availability policy. - Allowed values: `NOT_AVAILABLE`, `AVAILABLE`, `INSTALLED_BY_DEFAULT` diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index 5a61093164..87c2e17d4c 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -191,7 +191,10 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--path", default=str(DEFAULT_PLUGIN_PARENT), - help="Parent directory for plugin creation (defaults to /plugins)", + help=( + "Parent directory for plugin creation (defaults to /plugins). " + "When using a home-rooted marketplace, use /plugins." + ), ) parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory") @@ -202,12 +205,19 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--with-marketplace", action="store_true", - help="Create or update /.agents/plugins/marketplace.json", + help=( + "Create or update /.agents/plugins/marketplace.json. " + "Marketplace entries always point to ./plugins/ relative to the " + "marketplace root." + ), ) parser.add_argument( "--marketplace-path", default=str(DEFAULT_MARKETPLACE_PATH), - help="Path to marketplace.json (defaults to /.agents/plugins/marketplace.json)", + help=( + "Path to marketplace.json (defaults to /.agents/plugins/marketplace.json). " + "For a home-rooted marketplace, use /.agents/plugins/marketplace.json." + ), ) parser.add_argument( "--install-policy", From 047ea642d2989f4095a6dc5070aaa818554e550e Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 13:34:43 +0000 Subject: [PATCH 27/50] chore: tty metric (#15766) --- codex-rs/core/src/tools/handlers/unified_exec.rs | 11 +++++++++++ codex-rs/otel/src/metrics/names.rs | 1 + 2 files changed, 12 insertions(+) diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 109ac713c4..54543277f5 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -25,6 +25,8 @@ use crate::unified_exec::UnifiedExecProcessManager; use crate::unified_exec::WriteStdinRequest; use async_trait::async_trait; use codex_features::Feature; +use codex_otel::SessionTelemetry; +use codex_otel::metrics::names::TOOL_CALL_UNIFIED_EXEC_METRIC; use codex_protocol::models::PermissionProfile; use serde::Deserialize; use std::path::PathBuf; @@ -260,6 +262,7 @@ impl ToolHandler for UnifiedExecHandler { }); } + emit_unified_exec_tty_metric(&turn.session_telemetry, tty); manager .exec_command( ExecCommandRequest { @@ -323,6 +326,14 @@ impl ToolHandler for UnifiedExecHandler { } } +fn emit_unified_exec_tty_metric(session_telemetry: &SessionTelemetry, tty: bool) { + session_telemetry.counter( + TOOL_CALL_UNIFIED_EXEC_METRIC, + /*inc*/ 1, + &[("tty", if tty { "true" } else { "false" })], + ); +} + pub(crate) fn get_command( args: &ExecCommandArgs, session_shell: Arc, diff --git a/codex-rs/otel/src/metrics/names.rs b/codex-rs/otel/src/metrics/names.rs index 58a82afea5..d4f5760d5c 100644 --- a/codex-rs/otel/src/metrics/names.rs +++ b/codex-rs/otel/src/metrics/names.rs @@ -1,5 +1,6 @@ pub const TOOL_CALL_COUNT_METRIC: &str = "codex.tool.call"; pub const TOOL_CALL_DURATION_METRIC: &str = "codex.tool.call.duration_ms"; +pub const TOOL_CALL_UNIFIED_EXEC_METRIC: &str = "codex.tool.unified_exec"; pub const API_CALL_COUNT_METRIC: &str = "codex.api_request"; pub const API_CALL_DURATION_METRIC: &str = "codex.api_request.duration_ms"; pub const SSE_EVENT_COUNT_METRIC: &str = "codex.sse_event"; From 32c4993c8a17cb0608d747f268e3d543745de962 Mon Sep 17 00:00:00 2001 From: Fouad Matin <169186268+fouad-openai@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:55:41 -0700 Subject: [PATCH 28/50] fix(core): default approval behavior for mcp missing annotations (#15519) - Changed `requires_mcp_tool_approval` to apply MCP spec defaults when annotations are missing. - Unannotated tools now default to: - `readOnlyHint = false` - `destructiveHint = true` - `openWorldHint = true` - This means unannotated MCP tools now go through approval/ARC monitoring instead of silently bypassing it. - Explicitly read-only tools still skip approval unless they are also explicitly marked destructive. **Previous behavior** Failed open for missing annotations, which was unsafe for custom MCP tools that omitted or forgot annotations. --------- Co-authored-by: colby-oai <228809017+colby-oai@users.noreply.github.com> --- codex-rs/core/src/mcp_tool_call.rs | 19 +++- codex-rs/core/src/mcp_tool_call_tests.rs | 86 ++++++++++++++++++- .../core/tests/common/apps_test_server.rs | 11 +++ .../rmcp-client/src/bin/test_stdio_server.rs | 19 ++-- .../src/bin/test_streamable_http_server.rs | 7 +- 5 files changed, 127 insertions(+), 15 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index f82cc73bb5..18a4843578 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -495,7 +495,7 @@ async fn maybe_request_mcp_tool_approval( approval_mode: AppToolApproval, ) -> Option { let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref()); - let approval_required = annotations.is_some_and(requires_mcp_tool_approval); + let approval_required = requires_mcp_tool_approval(annotations); let mut monitor_reason = None; let auto_approved_by_policy = approval_mode == AppToolApproval::Approve || (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context)); @@ -1299,12 +1299,23 @@ async fn persist_codex_app_tool_approval( .await } -fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool { - if annotations.destructive_hint == Some(true) { +fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool { + let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint); + if destructive_hint == Some(true) { return true; } - annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true) + let read_only_hint = annotations + .and_then(|annotations| annotations.read_only_hint) + .unwrap_or(false); + if read_only_hint { + return false; + } + + destructive_hint.unwrap_or(true) + || annotations + .and_then(|annotations| annotations.open_world_hint) + .unwrap_or(true) } async fn notify_mcp_tool_call_skip( diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 0ac37f9ce8..9d7d1c3a41 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -64,19 +64,30 @@ fn prompt_options( #[test] fn approval_required_when_read_only_false_and_destructive() { let annotations = annotations(Some(false), Some(true), None); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_read_only_false_and_open_world() { let annotations = annotations(Some(false), None, Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); } #[test] fn approval_required_when_destructive_even_if_read_only_true() { let annotations = annotations(Some(true), Some(true), Some(true)); - assert_eq!(requires_mcp_tool_approval(&annotations), true); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true); +} + +#[test] +fn approval_required_when_annotations_are_absent() { + assert_eq!(requires_mcp_tool_approval(None), true); +} + +#[test] +fn approval_not_required_when_read_only_and_other_hints_are_absent() { + let annotations = annotations(Some(true), None, None); + assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false); } #[test] @@ -1067,6 +1078,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() { ); } +#[tokio::test] +async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() { + use wiremock::Mock; + use wiremock::MockServer; + use wiremock::ResponseTemplate; + use wiremock::matchers::method; + use wiremock::matchers::path; + + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/safety/arc")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "outcome": "steer-model", + "short_reason": "needs approval", + "rationale": "high-risk action", + "risk_score": 96, + "risk_level": "critical", + "evidence": [{ + "message": "dangerous_tool", + "why": "high-risk action", + }], + }))) + .expect(1) + .mount(&server) + .await; + + let (session, mut turn_context) = make_session_and_context().await; + turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth( + crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(), + )); + let mut config = (*turn_context.config).clone(); + config.chatgpt_base_url = server.uri(); + turn_context.config = Arc::new(config); + + let session = Arc::new(session); + let turn_context = Arc::new(turn_context); + let invocation = McpInvocation { + server: CODEX_APPS_MCP_SERVER_NAME.to_string(), + tool: "dangerous_tool".to_string(), + arguments: Some(serde_json::json!({ "id": 1 })), + }; + let metadata = McpToolApprovalMetadata { + annotations: None, + connector_id: Some("calendar".to_string()), + connector_name: Some("Calendar".to_string()), + connector_description: Some("Manage events".to_string()), + tool_title: Some("Dangerous Tool".to_string()), + tool_description: Some("Performs a risky action.".to_string()), + codex_apps_meta: None, + }; + + let decision = maybe_request_mcp_tool_approval( + &session, + &turn_context, + "call-3", + &invocation, + Some(&metadata), + AppToolApproval::Approve, + ) + .await; + + assert_eq!( + decision, + Some(McpToolApprovalDecision::BlockedBySafetyMonitor( + "Tool call was cancelled because of safety risks: high-risk action".to_string(), + )) + ); +} + #[tokio::test] async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() { use wiremock::Mock; diff --git a/codex-rs/core/tests/common/apps_test_server.rs b/codex-rs/core/tests/common/apps_test_server.rs index 450a170b2a..da5499c573 100644 --- a/codex-rs/core/tests/common/apps_test_server.rs +++ b/codex-rs/core/tests/common/apps_test_server.rs @@ -185,6 +185,11 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_create_event", "description": "Create a calendar event.", + "annotations": { + "readOnlyHint": false, + "destructiveHint": false, + "openWorldHint": false + }, "inputSchema": { "type": "object", "properties": { @@ -209,6 +214,9 @@ impl Respond for CodexAppsJsonRpcResponder { { "name": "calendar_list_events", "description": "List calendar events.", + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { @@ -241,6 +249,9 @@ impl Respond for CodexAppsJsonRpcResponder { tools.push(json!({ "name": format!("calendar_timezone_option_{index}"), "description": format!("Read timezone option {index}."), + "annotations": { + "readOnlyHint": true + }, "inputSchema": { "type": "object", "properties": { diff --git a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs index cd08307767..d9202babae 100644 --- a/codex-rs/rmcp-client/src/bin/test_stdio_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_stdio_server.rs @@ -22,6 +22,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use serde::Deserialize; use serde_json::json; use tokio::task; @@ -85,11 +86,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed(name), Cow::Borrowed(description), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn image_tool() -> Tool { @@ -101,11 +104,13 @@ impl TestToolServer { })) .expect("image tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image"), Cow::Borrowed("Return a single image content block."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } /// Tool intended for manual testing of Codex TUI rendering for MCP image tool results. @@ -154,13 +159,15 @@ impl TestToolServer { })) .expect("image_scenario tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("image_scenario"), Cow::Borrowed( "Return content blocks for manual testing of MCP image rendering scenarios.", ), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource { diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index 284e1194c3..50ea06c7fa 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -38,6 +38,7 @@ use rmcp::model::ResourceTemplate; use rmcp::model::ServerCapabilities; use rmcp::model::ServerInfo; use rmcp::model::Tool; +use rmcp::model::ToolAnnotations; use rmcp::transport::StreamableHttpServerConfig; use rmcp::transport::StreamableHttpService; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; @@ -84,11 +85,13 @@ impl TestToolServer { })) .expect("echo tool schema should deserialize"); - Tool::new( + let mut tool = Tool::new( Cow::Borrowed("echo"), Cow::Borrowed("Echo back the provided message and include environment data."), Arc::new(schema), - ) + ); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool } fn memo_resource() -> Resource { From 178c3b15b475104459815767e0561756c8cd00ed Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:01:45 +0000 Subject: [PATCH 29/50] chore: remove grep_files handler (#15775) # External (non-OpenAI) Pull Request Requirements Before opening this Pull Request, please read the dedicated "Contributing" markdown file or your PR may be closed: https://github.com/openai/codex/blob/main/docs/contributing.md If your PR conforms to our contribution guidelines, replace this text with a detailed and high quality description of your changes. Include a link to a bug report or enhancement request. --------- Co-authored-by: Codex --- .../core/src/tools/handlers/grep_files.rs | 176 ------------------ codex-rs/core/src/tools/handlers/mod.rs | 2 - codex-rs/core/src/tools/spec.rs | 68 ------- codex-rs/core/src/tools/spec_tests.rs | 21 +-- codex-rs/core/tests/common/test_codex.rs | 7 +- codex-rs/core/tests/suite/grep_files.rs | 145 --------------- codex-rs/core/tests/suite/list_dir.rs | 167 ----------------- codex-rs/core/tests/suite/mod.rs | 3 - codex-rs/core/tests/suite/read_file.rs | 42 ----- 9 files changed, 2 insertions(+), 629 deletions(-) delete mode 100644 codex-rs/core/src/tools/handlers/grep_files.rs delete mode 100644 codex-rs/core/tests/suite/grep_files.rs delete mode 100644 codex-rs/core/tests/suite/list_dir.rs delete mode 100644 codex-rs/core/tests/suite/read_file.rs diff --git a/codex-rs/core/src/tools/handlers/grep_files.rs b/codex-rs/core/src/tools/handlers/grep_files.rs deleted file mode 100644 index fdb0fce7be..0000000000 --- a/codex-rs/core/src/tools/handlers/grep_files.rs +++ /dev/null @@ -1,176 +0,0 @@ -use std::path::Path; -use std::time::Duration; - -use async_trait::async_trait; -use serde::Deserialize; -use tokio::process::Command; -use tokio::time::timeout; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct GrepFilesHandler; - -const DEFAULT_LIMIT: usize = 100; -const MAX_LIMIT: usize = 2000; -const COMMAND_TIMEOUT: Duration = Duration::from_secs(30); - -fn default_limit() -> usize { - DEFAULT_LIMIT -} - -#[derive(Deserialize)] -struct GrepFilesArgs { - pattern: String, - #[serde(default)] - include: Option, - #[serde(default)] - path: Option, - #[serde(default = "default_limit")] - limit: usize, -} - -#[async_trait] -impl ToolHandler for GrepFilesHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, turn, .. } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "grep_files handler received unsupported payload".to_string(), - )); - } - }; - - let args: GrepFilesArgs = parse_arguments(&arguments)?; - - let pattern = args.pattern.trim(); - if pattern.is_empty() { - return Err(FunctionCallError::RespondToModel( - "pattern must not be empty".to_string(), - )); - } - - if args.limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let limit = args.limit.min(MAX_LIMIT); - let search_path = turn.resolve_path(args.path.clone()); - - verify_path_exists(&search_path).await?; - - let include = args.include.as_deref().map(str::trim).and_then(|val| { - if val.is_empty() { - None - } else { - Some(val.to_string()) - } - }); - - let search_results = - run_rg_search(pattern, include.as_deref(), &search_path, limit, &turn.cwd).await?; - - if search_results.is_empty() { - Ok(FunctionToolOutput::from_text( - "No matches found.".to_string(), - Some(false), - )) - } else { - Ok(FunctionToolOutput::from_text( - search_results.join("\n"), - Some(true), - )) - } - } -} - -async fn verify_path_exists(path: &Path) -> Result<(), FunctionCallError> { - tokio::fs::metadata(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("unable to access `{}`: {err}", path.display())) - })?; - Ok(()) -} - -async fn run_rg_search( - pattern: &str, - include: Option<&str>, - search_path: &Path, - limit: usize, - cwd: &Path, -) -> Result, FunctionCallError> { - let mut command = Command::new("rg"); - command - .current_dir(cwd) - .arg("--files-with-matches") - .arg("--sortr=modified") - .arg("--regexp") - .arg(pattern) - .arg("--no-messages"); - - if let Some(glob) = include { - command.arg("--glob").arg(glob); - } - - command.arg("--").arg(search_path); - - let output = timeout(COMMAND_TIMEOUT, command.output()) - .await - .map_err(|_| { - FunctionCallError::RespondToModel("rg timed out after 30 seconds".to_string()) - })? - .map_err(|err| { - FunctionCallError::RespondToModel(format!( - "failed to launch rg: {err}. Ensure ripgrep is installed and on PATH." - )) - })?; - - match output.status.code() { - Some(0) => Ok(parse_results(&output.stdout, limit)), - Some(1) => Ok(Vec::new()), - _ => { - let stderr = String::from_utf8_lossy(&output.stderr); - Err(FunctionCallError::RespondToModel(format!( - "rg failed: {stderr}" - ))) - } - } -} - -fn parse_results(stdout: &[u8], limit: usize) -> Vec { - let mut results = Vec::new(); - for line in stdout.split(|byte| *byte == b'\n') { - if line.is_empty() { - continue; - } - if let Ok(text) = std::str::from_utf8(line) { - if text.is_empty() { - continue; - } - results.push(text.to_string()); - if results.len() == limit { - break; - } - } - } - results -} - -#[cfg(test)] -#[path = "grep_files_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 9b14f7ee50..1e9f48820b 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -2,7 +2,6 @@ pub(crate) mod agent_jobs; pub mod apply_patch; mod artifacts; mod dynamic; -mod grep_files; mod js_repl; mod list_dir; mod mcp; @@ -41,7 +40,6 @@ pub use artifacts::ArtifactsHandler; use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::AskForApproval; pub use dynamic::DynamicToolHandler; -pub use grep_files::GrepFilesHandler; pub use js_repl::JsReplHandler; pub use js_repl::JsReplResetHandler; pub use list_dir::ListDirHandler; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 5686b4bb78..d16b2567ac 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -1839,59 +1839,6 @@ fn create_test_sync_tool() -> ToolSpec { }) } -fn create_grep_files_tool() -> ToolSpec { - let properties = BTreeMap::from([ - ( - "pattern".to_string(), - JsonSchema::String { - description: Some("Regular expression pattern to search for.".to_string()), - }, - ), - ( - "include".to_string(), - JsonSchema::String { - description: Some( - "Optional glob that limits which files are searched (e.g. \"*.rs\" or \ - \"*.{ts,tsx}\")." - .to_string(), - ), - }, - ), - ( - "path".to_string(), - JsonSchema::String { - description: Some( - "Directory or file path to search. Defaults to the session's working directory." - .to_string(), - ), - }, - ), - ( - "limit".to_string(), - JsonSchema::Number { - description: Some( - "Maximum number of file paths to return (defaults to 100).".to_string(), - ), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "grep_files".to_string(), - description: "Finds files whose contents match the pattern and lists them by modification \ - time." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["pattern".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -} - fn create_tool_search_tool(app_tools: &HashMap) -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2751,7 +2698,6 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::CodeModeExecuteHandler; use crate::tools::handlers::CodeModeWaitHandler; use crate::tools::handlers::DynamicToolHandler; - use crate::tools::handlers::GrepFilesHandler; use crate::tools::handlers::JsReplHandler; use crate::tools::handlers::JsReplResetHandler; use crate::tools::handlers::ListDirHandler; @@ -3029,20 +2975,6 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } - if config - .experimental_supported_tools - .contains(&"grep_files".to_string()) - { - let grep_files_handler = Arc::new(GrepFilesHandler); - push_tool_spec( - &mut builder, - create_grep_files_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - builder.register_handler("grep_files", grep_files_handler); - } - if config .experimental_supported_tools .contains(&"read_file".to_string()) diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index 36a0990393..f12aee6fa2 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1665,21 +1665,13 @@ fn test_parallel_support_flags() { assert!(find_tool(&tools, "exec_command").supports_parallel_tool_calls); assert!(!find_tool(&tools, "write_stdin").supports_parallel_tool_calls); - assert!(find_tool(&tools, "grep_files").supports_parallel_tool_calls); - assert!(find_tool(&tools, "list_dir").supports_parallel_tool_calls); - assert!(find_tool(&tools, "read_file").supports_parallel_tool_calls); } #[test] fn test_test_model_info_includes_sync_tool() { let _config = test_config(); let mut model_info = model_info_from_models_json("gpt-5-codex"); - model_info.experimental_supported_tools = vec![ - "test_sync_tool".to_string(), - "read_file".to_string(), - "grep_files".to_string(), - "list_dir".to_string(), - ]; + model_info.experimental_supported_tools = vec!["test_sync_tool".to_string()]; let features = Features::with_defaults(); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -1698,17 +1690,6 @@ fn test_test_model_info_includes_sync_tool() { .iter() .any(|tool| tool_name(&tool.spec) == "test_sync_tool") ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "read_file") - ); - assert!( - tools - .iter() - .any(|tool| tool_name(&tool.spec) == "grep_files") - ); - assert!(tools.iter().any(|tool| tool_name(&tool.spec) == "list_dir")); } #[test] diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index c61631e5bf..7a7ae7af4b 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -616,12 +616,7 @@ fn ensure_test_model_catalog(config: &mut Config) -> Result<()> { .unwrap_or_else(|| panic!("missing bundled model gpt-5.1-codex")); model.slug = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); model.display_name = TEST_MODEL_WITH_EXPERIMENTAL_TOOLS.to_string(); - model.experimental_supported_tools = vec![ - "test_sync_tool".to_string(), - "read_file".to_string(), - "grep_files".to_string(), - "list_dir".to_string(), - ]; + model.experimental_supported_tools = vec!["test_sync_tool".to_string()]; config.model_catalog = Some(ModelsResponse { models: vec![model], }); diff --git a/codex-rs/core/tests/suite/grep_files.rs b/codex-rs/core/tests/suite/grep_files.rs deleted file mode 100644 index 1b8cc110ef..0000000000 --- a/codex-rs/core/tests/suite/grep_files.rs +++ /dev/null @@ -1,145 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use anyhow::Result; -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::TestCodex; -use core_test_support::test_codex::test_codex; -use std::collections::HashSet; -use std::path::Path; -use std::process::Command as StdCommand; - -const MODEL_WITH_TOOL: &str = "test-gpt-5.1-codex"; - -fn ripgrep_available() -> bool { - StdCommand::new("rg") - .arg("--version") - .output() - .map(|output| output.status.success()) - .unwrap_or(false) -} - -macro_rules! skip_if_ripgrep_missing { - ($ret:expr $(,)?) => {{ - if !ripgrep_available() { - eprintln!("rg not available in PATH; skipping test"); - return $ret; - } - }}; -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn grep_files_tool_collects_matches() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_ripgrep_missing!(Ok(())); - - let server = start_mock_server().await; - let test = build_test_codex(&server).await?; - - let search_dir = test.cwd.path().join("src"); - std::fs::create_dir_all(&search_dir)?; - let alpha = search_dir.join("alpha.rs"); - let beta = search_dir.join("beta.rs"); - let gamma = search_dir.join("gamma.txt"); - std::fs::write(&alpha, "alpha needle\n")?; - std::fs::write(&beta, "beta needle\n")?; - std::fs::write(&gamma, "needle in text but excluded\n")?; - - let call_id = "grep-files-collect"; - let arguments = serde_json::json!({ - "pattern": "needle", - "path": search_dir.to_string_lossy(), - "include": "*.rs", - }) - .to_string(); - - let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await; - test.submit_turn("please find uses of needle").await?; - - let req = mocks.completion.single_request(); - let (content_opt, success_opt) = req - .function_call_output_content_and_success(call_id) - .expect("tool output present"); - let content = content_opt.expect("content present"); - let success = success_opt.unwrap_or(true); - assert!( - success, - "expected success for matches, got content={content}" - ); - - let entries = collect_file_names(&content); - assert_eq!(entries.len(), 2, "content: {content}"); - assert!( - entries.contains("alpha.rs"), - "missing alpha.rs in {entries:?}" - ); - assert!( - entries.contains("beta.rs"), - "missing beta.rs in {entries:?}" - ); - assert!( - !entries.contains("gamma.txt"), - "txt file should be filtered out: {entries:?}" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn grep_files_tool_reports_empty_results() -> Result<()> { - skip_if_no_network!(Ok(())); - skip_if_ripgrep_missing!(Ok(())); - - let server = start_mock_server().await; - let test = build_test_codex(&server).await?; - - let search_dir = test.cwd.path().join("logs"); - std::fs::create_dir_all(&search_dir)?; - std::fs::write(search_dir.join("output.txt"), "no hits here")?; - - let call_id = "grep-files-empty"; - let arguments = serde_json::json!({ - "pattern": "needle", - "path": search_dir.to_string_lossy(), - "limit": 5, - }) - .to_string(); - - let mocks = - mount_function_call_agent_response(&server, call_id, &arguments, "grep_files").await; - test.submit_turn("search again").await?; - - let req = mocks.completion.single_request(); - let (content_opt, success_opt) = req - .function_call_output_content_and_success(call_id) - .expect("tool output present"); - let content = content_opt.expect("content present"); - if let Some(success) = success_opt { - assert!(!success, "expected success=false content={content}"); - } - assert_eq!(content, "No matches found."); - - Ok(()) -} - -#[allow(clippy::expect_used)] -async fn build_test_codex(server: &wiremock::MockServer) -> Result { - let mut builder = test_codex().with_model(MODEL_WITH_TOOL); - builder.build(server).await -} - -fn collect_file_names(content: &str) -> HashSet { - content - .lines() - .filter_map(|line| { - if line.trim().is_empty() { - return None; - } - Path::new(line) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - }) - .collect() -} diff --git a/codex-rs/core/tests/suite/list_dir.rs b/codex-rs/core/tests/suite/list_dir.rs deleted file mode 100644 index 802080d846..0000000000 --- a/codex-rs/core/tests/suite/list_dir.rs +++ /dev/null @@ -1,167 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_returns_entries() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("sample_dir"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "first file")?; - std::fs::create_dir(dir_path.join("nested"))?; - let dir_path = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-call"; - let arguments = json!({ - "dir_path": dir_path, - "offset": 1, - "limit": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents").await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_one_omits_children() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_one"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - std::fs::create_dir(dir_path.join("nested"))?; - std::fs::write(dir_path.join("nested").join("beta.txt"), "beta")?; - let dir_path = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth1"; - let arguments = json!({ - "dir_path": dir_path, - "offset": 1, - "limit": 10, - "depth": 1, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth one") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!(output, "E1: [file] alpha.txt\nE2: [dir] nested"); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_two_includes_children_only() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_two"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - let nested = dir_path.join("nested"); - std::fs::create_dir(&nested)?; - std::fs::write(nested.join("beta.txt"), "beta")?; - let deeper = nested.join("grand"); - std::fs::create_dir(&deeper)?; - std::fs::write(deeper.join("gamma.txt"), "gamma")?; - let dir_path_string = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth2"; - let arguments = json!({ - "dir_path": dir_path_string, - "offset": 1, - "limit": 10, - "depth": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth two") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!( - output, - "E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand" - ); - - Ok(()) -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable list_dir tool"] -async fn list_dir_tool_depth_three_includes_grandchildren() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let dir_path = test.cwd.path().join("depth_three"); - std::fs::create_dir(&dir_path)?; - std::fs::write(dir_path.join("alpha.txt"), "alpha")?; - let nested = dir_path.join("nested"); - std::fs::create_dir(&nested)?; - std::fs::write(nested.join("beta.txt"), "beta")?; - let deeper = nested.join("grand"); - std::fs::create_dir(&deeper)?; - std::fs::write(deeper.join("gamma.txt"), "gamma")?; - let dir_path_string = dir_path.to_string_lossy().to_string(); - - let call_id = "list-dir-depth3"; - let arguments = json!({ - "dir_path": dir_path_string, - "offset": 1, - "limit": 10, - "depth": 3, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "list_dir").await; - test.submit_turn("list directory contents depth three") - .await?; - let req = mocks.completion.single_request(); - let (content_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("function_call_output present"); - let output = content_opt.expect("output content present in tool output"); - assert_eq!( - output, - "E1: [file] alpha.txt\nE2: [dir] nested\nE3: [file] nested/beta.txt\nE4: [dir] nested/grand\nE5: [file] nested/grand/gamma.txt" - ); - - Ok(()) -} diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 5f7e50f061..f4891d58c5 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -75,7 +75,6 @@ mod deprecation_notice; mod exec; mod exec_policy; mod fork_thread; -mod grep_files; mod hierarchical_agents; #[cfg(not(target_os = "windows"))] mod hooks; @@ -83,7 +82,6 @@ mod image_rollout; mod items; mod js_repl; mod json_result; -mod list_dir; mod live_cli; mod live_reload; mod memories; @@ -101,7 +99,6 @@ mod personality_migration; mod plugins; mod prompt_caching; mod quota_exceeded; -mod read_file; mod realtime_conversation; mod remote_env; mod remote_models; diff --git a/codex-rs/core/tests/suite/read_file.rs b/codex-rs/core/tests/suite/read_file.rs deleted file mode 100644 index 57be21f9f7..0000000000 --- a/codex-rs/core/tests/suite/read_file.rs +++ /dev/null @@ -1,42 +0,0 @@ -#![cfg(not(target_os = "windows"))] - -use core_test_support::responses::mount_function_call_agent_response; -use core_test_support::responses::start_mock_server; -use core_test_support::skip_if_no_network; -use core_test_support::test_codex::test_codex; -use pretty_assertions::assert_eq; -use serde_json::json; - -#[tokio::test(flavor = "multi_thread", worker_threads = 2)] -#[ignore = "disabled until we enable read_file tool"] -async fn read_file_tool_returns_requested_lines() -> anyhow::Result<()> { - skip_if_no_network!(Ok(())); - - let server = start_mock_server().await; - let test = test_codex().build(&server).await?; - - let file_path = test.cwd.path().join("sample.txt"); - std::fs::write(&file_path, "first\nsecond\nthird\nfourth\n")?; - let file_path = file_path.to_string_lossy().to_string(); - - let call_id = "read-file-call"; - let arguments = json!({ - "file_path": file_path, - "offset": 2, - "limit": 2, - }) - .to_string(); - - let mocks = mount_function_call_agent_response(&server, call_id, &arguments, "read_file").await; - - test.submit_turn("please inspect sample.txt").await?; - - let req = mocks.completion.single_request(); - let (output_text_opt, _) = req - .function_call_output_content_and_success(call_id) - .expect("output present"); - let output_text = output_text_opt.expect("output text present"); - assert_eq!(output_text, "L2: second\nL3: third"); - - Ok(()) -} From 504aeb0e09bbc40771d4f23fc89c8571c27289cd Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 25 Mar 2026 09:02:22 -0700 Subject: [PATCH 30/50] Use AbsolutePathBuf for cwd state (#15710) Migrate `cwd` and related session/config state to `AbsolutePathBuf` so downstream consumers consistently see absolute working directories. Add test-only `.abs()` helpers for `Path`, `PathBuf`, and `TempDir`, and update branch-local tests to use them instead of `AbsolutePathBuf::try_from(...)`. For the remaining TUI/app-server snapshot coverage that renders absolute cwd values, keep the snapshots unchanged and skip the Windows-only cases where the platform-specific absolute path layout differs. --- .../app-server/src/codex_message_processor.rs | 8 +- codex-rs/cli/src/debug_sandbox.rs | 4 +- codex-rs/core/src/agent/role.rs | 2 +- codex-rs/core/src/codex.rs | 77 ++++++++++--------- .../src/codex/rollout_reconstruction_tests.rs | 16 ++-- codex-rs/core/src/codex_delegate.rs | 5 +- codex-rs/core/src/codex_tests.rs | 47 ++++++++--- codex-rs/core/src/codex_tests_guardian.rs | 10 +-- codex-rs/core/src/config/config_tests.rs | 64 ++++++++++----- codex-rs/core/src/config/mod.rs | 39 ++++++---- codex-rs/core/src/environment_context.rs | 6 +- codex-rs/core/src/guardian/review_session.rs | 4 +- codex-rs/core/src/guardian/tests.rs | 11 +-- codex-rs/core/src/hook_runtime.rs | 6 +- codex-rs/core/src/memories/phase2.rs | 11 ++- codex-rs/core/src/memories/tests.rs | 9 ++- codex-rs/core/src/project_doc.rs | 2 +- codex-rs/core/src/project_doc_tests.rs | 14 ++-- codex-rs/core/src/tasks/user_shell.rs | 10 +-- codex-rs/core/src/thread_manager_tests.rs | 11 +-- codex-rs/core/src/tools/handlers/artifacts.rs | 6 +- codex-rs/core/src/tools/handlers/js_repl.rs | 4 +- .../core/src/tools/handlers/js_repl_tests.rs | 2 +- .../src/tools/handlers/multi_agents_tests.rs | 3 +- codex-rs/core/src/tools/js_repl/mod.rs | 2 +- codex-rs/core/src/tools/js_repl/mod_tests.rs | 38 ++++----- codex-rs/core/src/tools/network_approval.rs | 2 +- codex-rs/core/src/tools/registry.rs | 2 +- .../tools/runtimes/shell/unix_escalation.rs | 4 +- .../core/src/unified_exec/process_manager.rs | 2 +- codex-rs/core/tests/common/lib.rs | 31 ++++++++ codex-rs/core/tests/common/test_codex.rs | 12 +-- codex-rs/core/tests/suite/client.rs | 7 +- .../tests/suite/collaboration_instructions.rs | 4 +- .../core/tests/suite/compact_resume_fork.rs | 4 +- .../core/tests/suite/hierarchical_agents.rs | 9 ++- codex-rs/core/tests/suite/prompt_caching.rs | 12 +-- codex-rs/core/tests/suite/resume_warning.rs | 2 +- codex-rs/core/tests/suite/review.rs | 3 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 3 +- codex-rs/core/tests/suite/view_image.rs | 58 ++++++-------- codex-rs/tui/src/app.rs | 70 +++++++++++------ codex-rs/tui/src/chatwidget.rs | 34 +++++--- codex-rs/tui/src/chatwidget/plugins.rs | 28 +++---- .../tui/src/chatwidget/status_surfaces.rs | 4 +- codex-rs/tui/src/chatwidget/tests.rs | 68 ++++++++-------- codex-rs/tui/src/history_cell.rs | 12 ++- codex-rs/tui/src/lib.rs | 2 + .../tui/src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui/src/status/card.rs | 2 +- codex-rs/tui/src/status/helpers.rs | 2 +- codex-rs/tui/src/status/tests.rs | 23 +++--- codex-rs/tui/src/test_support.rs | 28 +++++++ codex-rs/tui_app_server/src/app.rs | 70 +++++++++++------ codex-rs/tui_app_server/src/chatwidget.rs | 36 ++++++--- .../tui_app_server/src/chatwidget/plugins.rs | 28 +++---- .../tui_app_server/src/chatwidget/tests.rs | 66 ++++++++-------- codex-rs/tui_app_server/src/history_cell.rs | 12 ++- codex-rs/tui_app_server/src/lib.rs | 4 +- .../src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui_app_server/src/status/card.rs | 2 +- codex-rs/tui_app_server/src/status/helpers.rs | 2 +- codex-rs/tui_app_server/src/status/tests.rs | 23 +++--- codex-rs/tui_app_server/src/test_support.rs | 28 +++++++ codex-rs/utils/absolute-path/src/lib.rs | 25 ++++++ 65 files changed, 717 insertions(+), 422 deletions(-) create mode 100644 codex-rs/tui/src/test_support.rs create mode 100644 codex-rs/tui_app_server/src/test_support.rs diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 402dd51ddd..6e42f18e4c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1645,7 +1645,7 @@ impl CodexMessageProcessor { return; } - let cwd = cwd.unwrap_or_else(|| self.config.cwd.clone()); + let cwd = cwd.unwrap_or_else(|| self.config.cwd.to_path_buf()); let mut env = create_env( &self.config.permissions.shell_environment_policy, /*thread_id*/ None, @@ -5464,7 +5464,7 @@ impl CodexMessageProcessor { per_cwd_extra_user_roots, } = params; let cwds = if cwds.is_empty() { - vec![self.config.cwd.clone()] + vec![self.config.cwd.to_path_buf()] } else { cwds }; @@ -7258,7 +7258,7 @@ impl CodexMessageProcessor { let command_cwd = params .cwd .map(PathBuf::from) - .unwrap_or_else(|| config.cwd.clone()); + .unwrap_or_else(|| config.cwd.to_path_buf()); let cli_overrides = self.current_cli_overrides(); let runtime_feature_enablement = self.current_runtime_feature_enablement(); let outgoing = Arc::clone(&self.outgoing); @@ -7283,7 +7283,7 @@ impl CodexMessageProcessor { let setup_request = WindowsSandboxSetupRequest { mode, policy: config.permissions.sandbox_policy.get().clone(), - policy_cwd: config.cwd.clone(), + policy_cwd: config.cwd.to_path_buf(), command_cwd, env_map: std::env::vars().collect(), codex_home: config.codex_home.clone(), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 03226cced4..d519bd5f67 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -260,7 +260,7 @@ async fn run_command_under_sandbox( PathBuf::from("/usr/bin/sandbox-exec"), args, /*arg0*/ None, - cwd, + cwd.to_path_buf(), network_policy, env, |env_map| { @@ -293,7 +293,7 @@ async fn run_command_under_sandbox( codex_linux_sandbox_exe, args, Some("codex-linux-sandbox"), - cwd, + cwd.to_path_buf(), network_policy, env, |env_map| { diff --git a/codex-rs/core/src/agent/role.rs b/codex-rs/core/src/agent/role.rs index 06c6eae1e6..b7d7b55ab1 100644 --- a/codex-rs/core/src/agent/role.rs +++ b/codex-rs/core/src/agent/role.rs @@ -252,7 +252,7 @@ mod reload { fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides { ConfigOverrides { - cwd: Some(config.cwd.clone()), + cwd: Some(config.cwd.to_path_buf()), model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 168965e1ea..1e874b4d66 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -33,6 +33,7 @@ use crate::models_manager::manager::ModelsManager; use crate::models_manager::manager::RefreshStrategy; use crate::parse_command::parse_command; use crate::parse_turn_item; +use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio; use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; @@ -835,10 +836,10 @@ pub(crate) struct TurnContext { pub(crate) reasoning_summary: ReasoningSummaryConfig, pub(crate) session_source: SessionSource, pub(crate) environment: Arc, - /// The session's current working directory. All relative paths provided by - /// the model as well as sandbox policies are resolved against this path + /// The session's absolute working directory. All relative paths provided + /// by the model as well as sandbox policies are resolved against this path /// instead of `std::env::current_dir()`. - pub(crate) cwd: PathBuf, + pub(crate) cwd: AbsolutePathBuf, pub(crate) current_date: Option, pub(crate) timezone: Option, pub(crate) app_server_client_name: Option, @@ -979,7 +980,7 @@ impl TurnContext { pub(crate) fn resolve_path(&self, path: Option) -> PathBuf { path.as_ref() .map(PathBuf::from) - .map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p)) + .map_or_else(|| self.cwd.to_path_buf(), |p| self.cwd.as_path().join(p)) } pub(crate) fn compact_prompt(&self) -> &str { @@ -992,7 +993,7 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), approval_policy: self.approval_policy.value(), @@ -1068,14 +1069,11 @@ pub(crate) struct SessionConfiguration { network_sandbox_policy: NetworkSandboxPolicy, windows_sandbox_level: WindowsSandboxLevel, - /// Working directory that should be treated as the *root* of the + /// Absolute working directory that should be treated as the *root* of the /// session. All relative paths supplied by the model as well as the - /// execution sandbox are resolved against this directory **instead** - /// of the process-wide current working directory. CLI front-ends are - /// expected to expand this to an absolute path before sending the - /// `ConfigureSession` operation so that the business-logic layer can - /// operate deterministically. - cwd: PathBuf, + /// execution sandbox are resolved against this directory **instead** of + /// the process-wide current working directory. + cwd: AbsolutePathBuf, /// Directory containing all Codex state for this session. codex_home: PathBuf, /// Optional user-facing name for the thread, updated during the session. @@ -1107,7 +1105,7 @@ impl SessionConfiguration { approval_policy: self.approval_policy.value(), approvals_reviewer: self.approvals_reviewer, sandbox_policy: self.sandbox_policy.get().clone(), - cwd: self.cwd.clone(), + cwd: self.cwd.to_path_buf(), ephemeral: self.original_config_do_not_use.ephemeral, reasoning_effort: self.collaboration_mode.reasoning_effort(), personality: self.personality, @@ -1150,11 +1148,23 @@ impl SessionConfiguration { if let Some(windows_sandbox_level) = updates.windows_sandbox_level { next_configuration.windows_sandbox_level = windows_sandbox_level; } - let mut cwd_changed = false; - if let Some(cwd) = updates.cwd.clone() { - next_configuration.cwd = cwd; - cwd_changed = true; - } + + let absolute_cwd = updates + .cwd + .as_ref() + .map(|cwd| { + AbsolutePathBuf::relative_to_current_dir(normalize_for_native_workdir( + cwd.as_path(), + )) + .unwrap_or_else(|e| { + warn!("failed to normalize update cwd: {cwd:?}: {e}"); + self.cwd.clone() + }) + }) + .unwrap_or_else(|| self.cwd.clone()); + + let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path(); + next_configuration.cwd = absolute_cwd; if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) { // Preserve richer split policies across cwd-only updates; only // rederive when the session is already using the legacy bridge. @@ -1351,8 +1361,6 @@ impl Session { let auth_manager_for_context = auth_manager; let provider_for_context = provider; let session_telemetry_for_context = session_telemetry; - let per_turn_config = Arc::new(per_turn_config); - let tools_config = ToolsConfig::new(&ToolsConfigParams { model_info: &model_info, available_models: &models_manager.try_list_models().unwrap_or_default(), @@ -1372,10 +1380,12 @@ impl Session { .with_agent_roles(per_turn_config.agent_roles.clone()); let cwd = session_configuration.cwd.clone(); + + let per_turn_config = Arc::new(per_turn_config); let turn_metadata_state = Arc::new(TurnMetadataState::new( conversation_id.to_string(), sub_id.clone(), - cwd.clone(), + cwd.to_path_buf(), session_configuration.sandbox_policy.get(), session_configuration.windows_sandbox_level, )); @@ -1447,13 +1457,6 @@ impl Session { session_configuration.collaboration_mode.model(), session_configuration.provider ); - if !session_configuration.cwd.is_absolute() { - return Err(anyhow::anyhow!( - "cwd is not absolute: {:?}", - session_configuration.cwd - )); - } - let forked_from_id = initial_history.forked_from_id(); let (conversation_id, rollout_params) = match &initial_history { @@ -1723,7 +1726,7 @@ impl Session { ShellSnapshot::start_snapshotting( config.codex_home.clone(), conversation_id, - session_configuration.cwd.clone(), + session_configuration.cwd.to_path_buf(), &mut default_shell, session_telemetry.clone(), ) @@ -1922,7 +1925,7 @@ impl Session { approval_policy: session_configuration.approval_policy.value(), approvals_reviewer: session_configuration.approvals_reviewer, sandbox_policy: session_configuration.sandbox_policy.get().clone(), - cwd: session_configuration.cwd.clone(), + cwd: session_configuration.cwd.to_path_buf(), reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(), history_log_id, history_entry_count, @@ -1943,7 +1946,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: session_configuration.sandbox_policy.get().clone(), codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(), - sandbox_cwd: session_configuration.cwd.clone(), + sandbox_cwd: session_configuration.cwd.to_path_buf(), use_legacy_landlock: config.features.use_legacy_landlock(), }; let mut required_mcp_servers: Vec = mcp_servers @@ -2407,7 +2410,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(), codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(), - sandbox_cwd: per_turn_config.cwd.clone(), + sandbox_cwd: per_turn_config.cwd.to_path_buf(), use_legacy_landlock: per_turn_config.features.use_legacy_landlock(), }; if let Err(e) = self @@ -4138,7 +4141,7 @@ impl Session { let sandbox_state = SandboxState { sandbox_policy: turn_context.sandbox_policy.get().clone(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), - sandbox_cwd: turn_context.cwd.clone(), + sandbox_cwd: turn_context.cwd.to_path_buf(), use_legacy_landlock: turn_context.features.use_legacy_landlock(), }; { @@ -4919,7 +4922,7 @@ mod handlers { ) { let cwds = if cwds.is_empty() { let state = sess.state.lock().await; - vec![state.session_configuration.cwd.clone()] + vec![state.session_configuration.cwd.to_path_buf()] } else { cwds }; @@ -5363,7 +5366,7 @@ async fn spawn_review_thread( let turn_metadata_state = Arc::new(TurnMetadataState::new( sess.conversation_id.to_string(), review_turn_id.clone(), - parent_turn_context.cwd.clone(), + parent_turn_context.cwd.to_path_buf(), parent_turn_context.sandbox_policy.get(), parent_turn_context.windows_sandbox_level, )); @@ -5852,7 +5855,7 @@ pub(crate) async fn run_turn( let stop_request = codex_hooks::StopRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: stop_hook_permission_mode, @@ -5902,7 +5905,7 @@ pub(crate) async fn run_turn( .hooks() .dispatch(HookPayload { session_id: sess.conversation_id, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterAgent { diff --git a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs index 1ed668d56f..e468bfceb5 100644 --- a/codex-rs/core/src/codex/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/codex/rollout_reconstruction_tests.rs @@ -62,7 +62,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -101,7 +101,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif let mut previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -851,7 +851,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -923,7 +923,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis serde_json::to_value(Some(TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -952,7 +952,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1058,7 +1058,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo let current_context_item = TurnContextItem { turn_id: Some(current_turn_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1160,7 +1160,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -1304,7 +1304,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index a81970756b..d87dd070d5 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -22,7 +22,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse; use codex_protocol::request_user_input::RequestUserInputArgs; use codex_protocol::request_user_input::RequestUserInputResponse; use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; use serde_json::Value; use std::time::Duration; use tokio::sync::Mutex; @@ -518,7 +517,7 @@ async fn handle_patch_approval( let change_count = changes.len(); let maybe_files = changes .keys() - .map(|path| AbsolutePathBuf::from_absolute_path(parent_ctx.cwd.join(path)).ok()) + .map(|path| parent_ctx.cwd.join(path).ok()) .collect::>>(); if let Some(files) = maybe_files { let review_cancel = cancel_token.child_token(); @@ -554,7 +553,7 @@ async fn handle_patch_approval( Arc::clone(parent_ctx), GuardianApprovalRequest::ApplyPatch { id: approval_id.clone(), - cwd: parent_ctx.cwd.clone(), + cwd: parent_ctx.cwd.to_path_buf(), files, change_count, patch, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 12720f2437..34399a000c 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -80,6 +80,7 @@ use codex_protocol::protocol::ConversationAudioParams; use codex_protocol::protocol::RealtimeAudioFrame; use codex_protocol::protocol::Submission; use codex_protocol::protocol::W3cTraceContext; +use core_test_support::PathBufExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::context_snapshot::ContextSnapshotRenderMode; @@ -1266,7 +1267,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() { let previous_context_item = TurnContextItem { turn_id: Some(turn_context.sub_id.clone()), trace_id: turn_context.trace_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), approval_policy: turn_context.approval_policy.value(), @@ -2282,10 +2283,9 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); - let docs_dir = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd; + session_configuration.cwd = original_cwd.abs(); session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), @@ -2407,10 +2407,9 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ let original_cwd = project_root.join("subdir"); let docs_dir = original_cwd.join("docs"); std::fs::create_dir_all(&docs_dir).expect("create docs dir"); - let docs_dir = - codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs"); + let docs_dir = docs_dir.abs(); - session_configuration.cwd = original_cwd; + session_configuration.cwd = original_cwd.abs(); session_configuration.sandbox_policy = codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite { writable_roots: Vec::new(), @@ -2444,6 +2443,36 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_ ); } +#[tokio::test] +async fn session_update_settings_keeps_runtime_cwds_absolute() { + let (session, turn_context) = make_session_and_context().await; + let updated_cwd = turn_context + .cwd + .join("project") + .expect("resolve project dir"); + std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); + + session + .update_settings(SessionSettingsUpdate { + cwd: Some(PathBuf::from("project")), + ..Default::default() + }) + .await + .expect("cwd update should succeed"); + + let session_cwd = { + let state = session.state.lock().await; + state.session_configuration.cwd.clone() + }; + let config = session.get_config().await; + let next_turn = session.new_default_turn().await; + + assert_eq!(session_cwd, updated_cwd); + assert_eq!(config.cwd, turn_context.cwd); + assert_eq!(next_turn.cwd, updated_cwd); + assert_eq!(next_turn.config.cwd, updated_cwd); +} + #[tokio::test] async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let codex_home = tempfile::tempdir().expect("create temp dir"); @@ -3058,7 +3087,7 @@ async fn user_turn_updates_approvals_reviewer() { text: "hello".to_string(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent), sandbox_policy: config.permissions.sandbox_policy.get().clone(), @@ -5060,7 +5089,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { "echo hi".to_string(), ] }, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), expiration: timeout_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index e9050024eb..b576ff3eba 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -23,7 +23,8 @@ use codex_protocol::models::ResponseItem; use codex_protocol::models::function_call_output_content_items_to_text; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::permissions::NetworkSandboxPolicy; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathExt; +use core_test_support::TempDirExt; use core_test_support::codex_linux_sandbox_exe_or_skip; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_completed; @@ -123,7 +124,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid "echo hi".to_string(), ] }, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), expiration: expiration_ms.into(), capture_policy: ExecCapturePolicy::ShellTool, env: HashMap::new(), @@ -388,12 +389,11 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { .expect("write policy file"); let mut config = build_test_config(codex_home.path()).await; - config.cwd = project_dir.path().to_path_buf(); + config.cwd = project_dir.abs(); config.config_layer_stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new( ConfigLayerSource::Project { - dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_dir.path()) - .expect("absolute project path"), + dot_codex_folder: project_dir.path().abs(), }, toml::Value::Table(Default::default()), )], diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 7d3af80249..f67c532341 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -27,6 +27,9 @@ use serde::Deserialize; use tempfile::tempdir; use super::*; +use core_test_support::PathBufExt; +use core_test_support::PathExt; +use core_test_support::TempDirExt; use core_test_support::test_absolute_path; use pretty_assertions::assert_eq; @@ -77,6 +80,23 @@ fn http_mcp(url: &str) -> McpServerConfig { } } +#[test] +fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { + let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?; + let codex_home = tempdir()?; + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides { + cwd: Some(PathBuf::from("nested")), + ..Default::default() + }, + codex_home.abs().into_path_buf(), + )?; + + assert_eq!(config.cwd, expected_cwd); + Ok(()) +} + #[test] fn test_toml_parsing() { let history_with_persistence = r#" @@ -460,7 +480,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re codex_home.path().to_path_buf(), )?; - let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap(); + let memories_root = codex_home.path().join("memories").abs(); assert_eq!( config.permissions.file_system_sandbox_policy, FileSystemSandboxPolicy::restricted(vec![ @@ -496,9 +516,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re writable_roots: vec![memories_root], read_only_access: ReadOnlyAccess::Restricted { include_platform_defaults: true, - readable_roots: vec![ - AbsolutePathBuf::try_from(cwd.path().join("docs")).expect("absolute docs path"), - ], + readable_roots: vec![cwd.path().join("docs").abs(),], }, network_access: false, exclude_tmpdir_env_var: true, @@ -1277,7 +1295,7 @@ fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> { temp_dir.path().to_path_buf(), )?; - let expected_backend = AbsolutePathBuf::try_from(backend).unwrap(); + let expected_backend = backend.abs(); if cfg!(target_os = "windows") { match config.permissions.sandbox_policy.get() { SandboxPolicy::ReadOnly { .. } => {} @@ -1327,7 +1345,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { let config = Config::load_from_base_config_with_overrides( ConfigToml { sandbox_workspace_write: Some(SandboxWorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(&memories_root)?], + writable_roots: vec![memories_root.abs()], ..Default::default() }), ..Default::default() @@ -1350,7 +1368,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> { "expected memories root directory to exist at {}", memories_root.display() ); - let expected_memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?; + let expected_memories_root = memories_root.abs(); match config.permissions.sandbox_policy.get() { SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { assert_eq!( @@ -1768,7 +1786,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> { macos_managed_config_requirements_base64: None, }; - let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( codex_home.path(), Some(cwd), @@ -1897,7 +1915,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> { macos_managed_config_requirements_base64: None, }; - let cwd = AbsolutePathBuf::try_from(codex_home.path())?; + let cwd = codex_home.path().abs(); let config_layer_stack = load_config_layers_state( codex_home.path(), Some(cwd), @@ -2933,7 +2951,11 @@ struct PrecedenceTestFixture { } impl PrecedenceTestFixture { - fn cwd(&self) -> PathBuf { + fn cwd(&self) -> AbsolutePathBuf { + self.cwd.abs() + } + + fn cwd_path(&self) -> PathBuf { self.cwd.path().to_path_buf() } @@ -2974,7 +2996,7 @@ fn loads_compact_prompt_from_file() -> std::io::Result<()> { std::fs::write(&prompt_path, " summarize differently ")?; let cfg = ConfigToml { - experimental_compact_prompt_file: Some(AbsolutePathBuf::from_absolute_path(prompt_path)?), + experimental_compact_prompt_file: Some(prompt_path.abs()), ..Default::default() }; @@ -3071,7 +3093,7 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> { "researcher".to_string(), AgentRoleToml { description: Some("Research role".to_string()), - config_file: Some(AbsolutePathBuf::from_absolute_path(missing_path)?), + config_file: Some(missing_path.abs()), nickname_candidates: None, }, )]), @@ -4082,7 +4104,7 @@ fn model_catalog_json_loads_from_path() -> std::io::Result<()> { )?; let cfg = ConfigToml { - model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; @@ -4103,7 +4125,7 @@ fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> { std::fs::write(&catalog_path, r#"{"models":[]}"#)?; let cfg = ConfigToml { - model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?), + model_catalog_json: Some(catalog_path.abs()), ..Default::default() }; @@ -4240,7 +4262,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { let o3_profile_overrides = ConfigOverrides { config_profile: Some("o3".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let o3_profile_config: Config = Config::load_from_base_config_with_overrides( @@ -4368,7 +4390,7 @@ fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> { let config = Config::load_from_base_config_with_overrides( fixture.cfg.clone(), ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), @@ -4384,7 +4406,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { let gpt3_profile_overrides = ConfigOverrides { config_profile: Some("gpt3".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let gpt3_profile_config = Config::load_from_base_config_with_overrides( @@ -4505,7 +4527,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { // Verify that loading without specifying a profile in ConfigOverrides // uses the default profile from the config file (which is "gpt3"). let default_profile_overrides = ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; @@ -4525,7 +4547,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { let zdr_profile_overrides = ConfigOverrides { config_profile: Some("zdr".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let zdr_profile_config = Config::load_from_base_config_with_overrides( @@ -4652,7 +4674,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { let gpt5_profile_overrides = ConfigOverrides { config_profile: Some("gpt5".to_string()), - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }; let gpt5_profile_config = Config::load_from_base_config_with_overrides( @@ -4820,7 +4842,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any let config = Config::load_config_with_layer_stack( fixture.cfg.clone(), ConfigOverrides { - cwd: Some(fixture.cwd()), + cwd: Some(fixture.cwd_path()), ..Default::default() }, fixture.codex_home(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 539663c90a..c785b04618 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -395,10 +395,10 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, - /// The directory that should be treated as the current working directory - /// for the session. All relative paths inside the business-logic layer are - /// resolved against this path. - pub cwd: PathBuf, + /// The absolute directory that should be treated as the current working + /// directory for the session. All relative paths inside the business-logic + /// layer are resolved against this path. + pub cwd: AbsolutePathBuf, /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. @@ -683,7 +683,7 @@ impl ConfigBuilder { let loader_overrides = loader_overrides.unwrap_or_default(); let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref()); let cwd = match cwd_override { - Some(path) => AbsolutePathBuf::try_from(path)?, + Some(path) => AbsolutePathBuf::relative_to_current_dir(path)?, None => AbsolutePathBuf::current_dir()?, }; harness_overrides.cwd = Some(cwd.to_path_buf()); @@ -2104,7 +2104,7 @@ impl Config { let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile); let windows_sandbox_private_desktop = resolve_windows_sandbox_private_desktop(&cfg, &config_profile); - let resolved_cwd = normalize_for_native_workdir({ + let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({ use std::env; match cwd { @@ -2121,13 +2121,13 @@ impl Config { current } } - }); + }))?; let mut additional_writable_roots: Vec = additional_writable_roots .into_iter() - .map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd)) + .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect::, _>>()?; let active_project = cfg - .get_active_project(&resolved_cwd) + .get_active_project(resolved_cwd.as_path()) .unwrap_or(ProjectConfig { trust_level: None }); let permission_config_syntax = resolve_permission_config_syntax( &config_layer_stack, @@ -2200,12 +2200,15 @@ impl Config { &mut startup_warnings, )?; let mut sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots(&resolved_cwd, &additional_writable_roots); + .with_additional_writable_roots( + resolved_cwd.as_path(), + &additional_writable_roots, + ); sandbox_policy = file_system_sandbox_policy - .to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?; + .to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?; } ( configured_network_proxy_config, @@ -2219,7 +2222,7 @@ impl Config { sandbox_mode, config_profile.sandbox_mode, windows_sandbox_level, - &resolved_cwd, + resolved_cwd.as_path(), Some(&constrained_sandbox_policy), ); if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy { @@ -2229,8 +2232,10 @@ impl Config { } } } - let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &resolved_cwd); + let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy( + &sandbox_policy, + resolved_cwd.as_path(), + ); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); ( configured_network_proxy_config, @@ -2566,11 +2571,11 @@ impl Config { } else { FileSystemSandboxPolicy::from_legacy_sandbox_policy( &effective_sandbox_policy, - &resolved_cwd, + resolved_cwd.as_path(), ) }; let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy - .with_additional_readable_roots(&resolved_cwd, &helper_readable_roots); + .with_additional_readable_roots(resolved_cwd.as_path(), &helper_readable_roots); let effective_network_sandbox_policy = if effective_sandbox_policy == original_sandbox_policy { network_sandbox_policy diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index e744fd5478..bbb39da113 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -70,8 +70,8 @@ impl EnvironmentContext { ) -> Self { let before_network = Self::network_from_turn_context_item(before); let after_network = Self::network_from_turn_context(after); - let cwd = if before.cwd != after.cwd { - Some(after.cwd.clone()) + let cwd = if before.cwd.as_path() != after.cwd.as_path() { + Some(after.cwd.to_path_buf()) } else { None }; @@ -94,7 +94,7 @@ impl EnvironmentContext { pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self { Self::new( - Some(turn_context.cwd.clone()), + Some(turn_context.cwd.to_path_buf()), shell.clone(), turn_context.current_date.clone(), turn_context.timezone.clone(), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 50bf2ed843..64e27c42cd 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -140,7 +140,7 @@ impl GuardianReviewSessionReuseKey { base_instructions: spawn_config.base_instructions.clone(), user_instructions: spawn_config.user_instructions.clone(), compact_prompt: spawn_config.compact_prompt.clone(), - cwd: spawn_config.cwd.clone(), + cwd: spawn_config.cwd.to_path_buf(), mcp_servers: spawn_config.mcp_servers.clone(), codex_linux_sandbox_exe: spawn_config.codex_linux_sandbox_exe.clone(), main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(), @@ -512,7 +512,7 @@ async fn run_review_on_session( .codex .submit(Op::UserTurn { items: params.prompt_items.clone(), - cwd: params.parent_turn.cwd.clone(), + cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::new_read_only_policy(), diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 89c22528e8..3e6d409e76 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -25,7 +25,8 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::GuardianAssessmentStatus; use codex_protocol::protocol::GuardianRiskLevel; use codex_protocol::protocol::ReviewDecision; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use core_test_support::context_snapshot; use core_test_support::context_snapshot::ContextSnapshotOptions; use core_test_support::responses::ev_assistant_message; @@ -322,7 +323,7 @@ fn guardian_assessment_action_value_redacts_apply_patch_patch_text() { ("/tmp", "/tmp/guardian.txt") }; let cwd = PathBuf::from(cwd); - let file = AbsolutePathBuf::try_from(file).expect("absolute path"); + let file = PathBuf::from(file).abs(); let action = GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: cwd.clone(), @@ -356,7 +357,7 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() { let apply_patch = GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), - files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")], + files: vec![PathBuf::from("/tmp/guardian.txt").abs()], change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), @@ -384,7 +385,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() { GuardianApprovalRequest::ApplyPatch { id: "patch-1".to_string(), cwd: PathBuf::from("/tmp"), - files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")], + files: vec![PathBuf::from("/tmp/guardian.txt").abs()], change_count: 1usize, patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch" .to_string(), @@ -512,7 +513,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot() let (mut session, mut turn) = crate::codex::make_session_and_context().await; let temp_cwd = TempDir::new()?; let mut config = (*turn.config).clone(); - config.cwd = temp_cwd.path().to_path_buf(); + config.cwd = temp_cwd.abs(); config.model_provider.base_url = Some(format!("{}/v1", server.uri())); let config = Arc::new(config); let models_manager = Arc::new(test_support::models_manager_with_provider( diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 7e6ecaca10..744db4886b 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -92,7 +92,7 @@ pub(crate) async fn run_pending_session_start_hooks( let request = codex_hooks::SessionStartRequest { session_id: sess.conversation_id, - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), @@ -120,7 +120,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let request = PreToolUseRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), @@ -149,7 +149,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( let request = UserPromptSubmitRequest { session_id: sess.conversation_id, turn_id: turn_context.sub_id.clone(), - cwd: turn_context.cwd.clone(), + cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), permission_mode: hook_permission_mode(turn_context), diff --git a/codex-rs/core/src/memories/phase2.rs b/codex-rs/core/src/memories/phase2.rs index 2e0d7c4add..321b314abb 100644 --- a/codex-rs/core/src/memories/phase2.rs +++ b/codex-rs/core/src/memories/phase2.rs @@ -266,7 +266,16 @@ mod agent { let root = memory_root(&config.codex_home); let mut agent_config = config.as_ref().clone(); - agent_config.cwd = root; + match AbsolutePathBuf::from_absolute_path(root) { + Ok(root) => agent_config.cwd = root, + Err(err) => { + warn!( + "memory phase-2 consolidation could not set cwd from codex_home {}: {err}", + agent_config.codex_home.display() + ); + return None; + } + } // Consolidation threads must never feed back into phase-1 memory generation. agent_config.memories.generate_memories = false; // Approval policy diff --git a/codex-rs/core/src/memories/tests.rs b/codex-rs/core/src/memories/tests.rs index e04a018a84..a929401b96 100644 --- a/codex-rs/core/src/memories/tests.rs +++ b/codex-rs/core/src/memories/tests.rs @@ -435,6 +435,7 @@ mod phase2 { use codex_state::Phase2JobClaimOutcome; use codex_state::Stage1Output; use codex_state::ThreadMetadataBuilder; + use core_test_support::PathBufExt; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -469,7 +470,7 @@ mod phase2 { let codex_home = tempfile::tempdir().expect("create temp codex home"); let mut config = test_config(); config.codex_home = codex_home.path().to_path_buf(); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); let config = Arc::new(config); let state_db = codex_state::StateRuntime::init( @@ -507,7 +508,7 @@ mod phase2 { Utc::now(), SessionSource::Cli, ); - metadata_builder.cwd = self.config.cwd.clone(); + metadata_builder.cwd = self.config.cwd.to_path_buf(); metadata_builder.model_provider = Some(self.config.model_provider_id.clone()); let metadata = metadata_builder.build(&self.config.model_provider_id); @@ -882,7 +883,7 @@ mod phase2 { let codex_home = tempfile::tempdir().expect("create temp codex home"); let mut config = test_config(); config.codex_home = codex_home.path().to_path_buf(); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); let config = Arc::new(config); let state_db = codex_state::StateRuntime::init( @@ -904,7 +905,7 @@ mod phase2 { Utc::now(), SessionSource::Cli, ); - metadata_builder.cwd = config.cwd.clone(); + metadata_builder.cwd = config.cwd.to_path_buf(); metadata_builder.model_provider = Some(config.model_provider_id.clone()); let metadata = metadata_builder.build(&config.model_provider_id); state_db diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 7ad122c404..1b72f64610 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -184,7 +184,7 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result std::io::Result> { - let mut dir = config.cwd.clone(); + let mut dir = config.cwd.to_path_buf(); if let Ok(canon) = normalize_path(&dir) { dir = canon; } diff --git a/codex-rs/core/src/project_doc_tests.rs b/codex-rs/core/src/project_doc_tests.rs index 4cea541be3..866831d8b1 100644 --- a/codex-rs/core/src/project_doc_tests.rs +++ b/codex-rs/core/src/project_doc_tests.rs @@ -1,6 +1,8 @@ use super::*; use crate::config::ConfigBuilder; use codex_features::Feature; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -18,7 +20,7 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) - .await .expect("defaults for test should always succeed"); - config.cwd = root.path().to_path_buf(); + config.cwd = root.abs(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(ToOwned::to_owned); @@ -62,7 +64,7 @@ async fn make_config_with_project_root_markers( .await .expect("defaults for test should always succeed"); - config.cwd = root.path().to_path_buf(); + config.cwd = root.abs(); config.project_doc_max_bytes = limit; config.user_instructions = instructions.map(ToOwned::to_owned); config @@ -136,7 +138,7 @@ async fn finds_doc_in_repo_root() { // Build config pointing at the nested dir. let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "root level doc"); @@ -261,7 +263,7 @@ async fn concatenates_root_and_cwd_docs() { fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); let mut cfg = make_config(&repo, 4096, None).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let res = get_user_instructions(&cfg).await.expect("doc expected"); assert_eq!(res, "root doc\n\ncrate doc"); @@ -278,13 +280,13 @@ async fn project_root_markers_are_honored_for_agents_discovery() { fs::write(nested.join("AGENTS.md"), "child doc").unwrap(); let mut cfg = make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await; - cfg.cwd = nested; + cfg.cwd = nested.abs(); let discovery = discover_project_doc_paths(&cfg).expect("discover paths"); let expected_parent = dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"); let expected_child = - dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path"); + dunce::canonicalize(cfg.cwd.as_path().join("AGENTS.md")).expect("canonical child doc path"); assert_eq!(discovery.len(), 2); assert_eq!(discovery[0], expected_parent); assert_eq!(discovery[1], expected_child); diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index e118d38095..b90717e715 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -145,7 +145,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -156,7 +156,7 @@ pub(crate) async fn execute_user_shell_command( let sandbox_policy = SandboxPolicy::DangerFullAccess; let exec_env = ExecRequest { command: exec_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), env: create_env( &turn_context.shell_environment_policy, Some(session.conversation_id), @@ -221,7 +221,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -245,7 +245,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command.clone(), - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), parsed_cmd: parsed_cmd.clone(), source: ExecCommandSource::UserShell, interaction_input: None, @@ -289,7 +289,7 @@ pub(crate) async fn execute_user_shell_command( process_id: None, turn_id: turn_context.sub_id.clone(), command: display_command, - cwd, + cwd: cwd.to_path_buf(), parsed_cmd, source: ExecCommandSource::UserShell, interaction_input: None, diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index 40e7439ccc..64830fa727 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -12,6 +12,7 @@ use codex_protocol::openai_models::ModelsResponse; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; +use core_test_support::PathExt; use core_test_support::responses::mount_models_once; use pretty_assertions::assert_eq; use std::time::Duration; @@ -236,7 +237,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let manager = ThreadManager::with_models_provider_and_home_for_tests( @@ -275,7 +276,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); config.model_catalog = None; config @@ -408,7 +409,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let auth_manager = @@ -505,7 +506,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() { let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let auth_manager = @@ -591,7 +592,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_ let temp_dir = tempdir().expect("tempdir"); let mut config = test_config(); config.codex_home = temp_dir.path().join("codex-home"); - config.cwd = config.codex_home.clone(); + config.cwd = config.codex_home.abs(); std::fs::create_dir_all(&config.codex_home).expect("create codex home"); let auth_manager = diff --git a/codex-rs/core/src/tools/handlers/artifacts.rs b/codex-rs/core/src/tools/handlers/artifacts.rs index 875fcd486b..910dcbb470 100644 --- a/codex-rs/core/src/tools/handlers/artifacts.rs +++ b/codex-rs/core/src/tools/handlers/artifacts.rs @@ -89,7 +89,7 @@ impl ToolHandler for ArtifactsHandler { let result = client .execute_build(ArtifactBuildRequest { source: args.source, - cwd: turn.cwd.clone(), + cwd: turn.cwd.to_path_buf(), timeout: Some(Duration::from_millis( args.timeout_ms .unwrap_or(DEFAULT_EXECUTION_TIMEOUT.as_millis() as u64), @@ -221,7 +221,7 @@ fn default_runtime_manager(codex_home: std::path::PathBuf) -> ArtifactRuntimeMan async fn emit_exec_begin(session: &Session, turn: &TurnContext, call_id: &str) { let emitter = ToolEmitter::shell( vec![ARTIFACTS_TOOL_NAME.to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ true, ); @@ -247,7 +247,7 @@ async fn emit_exec_end( }; let emitter = ToolEmitter::shell( vec![ARTIFACTS_TOOL_NAME.to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ true, ); diff --git a/codex-rs/core/src/tools/handlers/js_repl.rs b/codex-rs/core/src/tools/handlers/js_repl.rs index b380a7107d..43b986e0d8 100644 --- a/codex-rs/core/src/tools/handlers/js_repl.rs +++ b/codex-rs/core/src/tools/handlers/js_repl.rs @@ -61,7 +61,7 @@ async fn emit_js_repl_exec_begin( ) { let emitter = ToolEmitter::shell( vec!["js_repl".to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ false, ); @@ -80,7 +80,7 @@ async fn emit_js_repl_exec_end( let exec_output = build_js_repl_exec_output(output, error, duration); let emitter = ToolEmitter::shell( vec!["js_repl".to_string()], - turn.cwd.clone(), + turn.cwd.to_path_buf(), ExecCommandSource::Agent, /*freeform*/ false, ); diff --git a/codex-rs/core/src/tools/handlers/js_repl_tests.rs b/codex-rs/core/src/tools/handlers/js_repl_tests.rs index 14dc222ffe..9613a8303f 100644 --- a/codex-rs/core/src/tools/handlers/js_repl_tests.rs +++ b/codex-rs/core/src/tools/handlers/js_repl_tests.rs @@ -77,7 +77,7 @@ async fn emit_js_repl_exec_end_sends_event() { assert_eq!(event.call_id, "call-1"); assert_eq!(event.turn_id, turn.sub_id); assert_eq!(event.command, vec!["js_repl".to_string()]); - assert_eq!(event.cwd, turn.cwd); + assert_eq!(event.cwd, turn.cwd.to_path_buf()); assert_eq!(event.source, ExecCommandSource::Agent); assert_eq!(event.interaction_input, None); assert_eq!(event.stdout, "hello"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index 4a6aa26ff4..77ed20dd8e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -39,6 +39,7 @@ use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::InterAgentCommunication; use codex_protocol::protocol::RolloutItem; use codex_protocol::user_input::UserInput; +use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde::Deserialize; use serde_json::json; @@ -2236,7 +2237,7 @@ async fn build_agent_spawn_config_uses_turn_context_values() { ..ShellEnvironmentPolicy::default() }; let temp_dir = tempfile::tempdir().expect("temp dir"); - turn.cwd = temp_dir.path().to_path_buf(); + turn.cwd = temp_dir.abs(); turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); let sandbox_policy = pick_allowed_sandbox_policy( &turn.config.permissions.sandbox_policy, diff --git a/codex-rs/core/src/tools/js_repl/mod.rs b/codex-rs/core/src/tools/js_repl/mod.rs index 0a29fb1200..266aa2a1ce 100644 --- a/codex-rs/core/src/tools/js_repl/mod.rs +++ b/codex-rs/core/src/tools/js_repl/mod.rs @@ -1050,7 +1050,7 @@ impl JsReplManager { "--experimental-vm-modules".to_string(), kernel_path.to_string_lossy().to_string(), ], - cwd: turn.cwd.clone(), + cwd: turn.cwd.to_path_buf(), env, additional_permissions: None, }; diff --git a/codex-rs/core/src/tools/js_repl/mod_tests.rs b/codex-rs/core/src/tools/js_repl/mod_tests.rs index 413e171d41..c759554009 100644 --- a/codex-rs/core/src/tools/js_repl/mod_tests.rs +++ b/codex-rs/core/src/tools/js_repl/mod_tests.rs @@ -14,6 +14,8 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::ResponseInputItem; use codex_protocol::openai_models::InputModality; +use core_test_support::PathBufExt; +use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use std::fs; use std::path::Path; @@ -739,7 +741,7 @@ async fn interrupt_active_exec_stops_aborted_kernel_before_later_exec() -> anyho let dir = tempdir()?; let (session, mut turn) = make_session_and_context().await; - turn.cwd = dir.path().to_path_buf(); + turn.cwd = dir.abs(); set_danger_full_access(&mut turn); let session = Arc::new(session); let turn = Arc::new(turn); @@ -1017,7 +1019,7 @@ async fn js_repl_waits_for_unawaited_tool_calls_before_completion() -> anyhow::R let marker = turn .cwd - .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-unawaited-marker-{}.txt", Uuid::new_v4()))?; let marker_json = serde_json::to_string(&marker.to_string_lossy().to_string())?; let result = manager .execute( @@ -1062,10 +1064,10 @@ async fn js_repl_persisted_tool_helpers_work_across_cells() -> anyhow::Result<() let global_marker = turn .cwd - .join(format!("js-repl-global-helper-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-global-helper-{}.txt", Uuid::new_v4()))?; let lexical_marker = turn .cwd - .join(format!("js-repl-lexical-helper-{}.txt", Uuid::new_v4())); + .join(format!("js-repl-lexical-helper-{}.txt", Uuid::new_v4()))?; let global_marker_json = serde_json::to_string(&global_marker.to_string_lossy().to_string())?; let lexical_marker_json = serde_json::to_string(&lexical_marker.to_string_lossy().to_string())?; @@ -2101,7 +2103,7 @@ async fn js_repl_prefers_env_node_module_dirs_over_config() -> anyhow::Result<() "CODEX_JS_REPL_NODE_MODULE_DIRS".to_string(), env_base.path().to_string_lossy().to_string(), ); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![config_base.path().to_path_buf()], @@ -2145,7 +2147,7 @@ async fn js_repl_resolves_from_first_config_dir() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![ @@ -2189,7 +2191,7 @@ async fn js_repl_falls_back_to_cwd_node_modules() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![config_base.path().to_path_buf()], @@ -2230,7 +2232,7 @@ async fn js_repl_accepts_node_modules_dir_entries() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), vec![base_dir.path().join("node_modules")], @@ -2284,7 +2286,7 @@ async fn js_repl_supports_relative_file_imports() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2331,7 +2333,7 @@ async fn js_repl_supports_absolute_file_imports() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2385,7 +2387,7 @@ async fn js_repl_imported_local_files_can_access_repl_globals() -> anyhow::Resul turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2429,7 +2431,7 @@ async fn js_repl_reimports_local_files_after_edit() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2485,7 +2487,7 @@ async fn js_repl_reimports_local_files_after_fixing_failure() -> anyhow::Result< turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2563,7 +2565,7 @@ async fn js_repl_local_files_expose_node_like_import_meta() -> anyhow::Result<() turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2648,7 +2650,7 @@ async fn js_repl_local_files_reject_static_bare_imports() -> anyhow::Result<()> turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2693,7 +2695,7 @@ async fn js_repl_rejects_unsupported_file_specifiers() -> anyhow::Result<()> { turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2795,7 +2797,7 @@ async fn js_repl_blocks_sensitive_builtin_imports_from_local_files() -> anyhow:: turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.path().to_path_buf(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), @@ -2845,7 +2847,7 @@ async fn js_repl_local_files_do_not_escape_node_module_search_roots() -> anyhow: turn.shell_environment_policy .r#set .remove("CODEX_JS_REPL_NODE_MODULE_DIRS"); - turn.cwd = cwd_dir.clone(); + turn.cwd = cwd_dir.abs(); turn.js_repl = Arc::new(JsReplHandle::with_node_path( turn.config.js_repl_node_path.clone(), Vec::new(), diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 9e92a6b00f..02b6bbe601 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -379,7 +379,7 @@ impl NetworkApprovalService { approval_id, /*approval_id*/ None, prompt_command, - turn_context.cwd.clone(), + turn_context.cwd.to_path_buf(), Some(prompt_reason), Some(network_approval_context.clone()), /*proposed_execpolicy_amendment*/ None, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 37b7d015b3..df97d7128d 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -530,7 +530,7 @@ async fn dispatch_after_tool_use_hook( .hooks() .dispatch(HookPayload { session_id: session.conversation_id, - cwd: turn.cwd.clone(), + cwd: turn.cwd.to_path_buf(), client: turn.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), hook_event: HookEvent::AfterToolUse { diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 4e107232d0..9df94e828f 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -159,7 +159,7 @@ pub(super) async fn try_run_zsh_fork( network: sandbox_network, windows_sandbox_level, arg0, - sandbox_policy_cwd: ctx.turn.cwd.clone(), + sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), macos_seatbelt_profile_extensions: ctx .turn .config @@ -263,7 +263,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork( network: exec_request.network.clone(), windows_sandbox_level: exec_request.windows_sandbox_level, arg0: exec_request.arg0.clone(), - sandbox_policy_cwd: ctx.turn.cwd.clone(), + sandbox_policy_cwd: ctx.turn.cwd.to_path_buf(), macos_seatbelt_profile_extensions: ctx .turn .config diff --git a/codex-rs/core/src/unified_exec/process_manager.rs b/codex-rs/core/src/unified_exec/process_manager.rs index a760249d61..8631a20955 100644 --- a/codex-rs/core/src/unified_exec/process_manager.rs +++ b/codex-rs/core/src/unified_exec/process_manager.rs @@ -160,7 +160,7 @@ impl UnifiedExecProcessManager { let cwd = request .workdir .clone() - .unwrap_or_else(|| context.turn.cwd.clone()); + .unwrap_or_else(|| context.turn.cwd.to_path_buf()); let process = self .open_session_with_sandbox(&request, cwd.clone(), context) .await; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 0b31793402..484bd189ef 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -14,6 +14,7 @@ use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; use codex_utils_absolute_path::AbsolutePathBuf; use regex_lite::Regex; +use std::path::Path; use std::path::PathBuf; pub mod apps_test_server; @@ -104,6 +105,36 @@ pub fn test_absolute_path(unix_path: &str) -> AbsolutePathBuf { test_absolute_path_with_windows(unix_path, /*windows_path*/ None) } +pub trait PathExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathExt for Path { + fn abs(&self) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(self.to_path_buf()).expect("path should already be absolute") + } +} + +pub trait PathBufExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathBufExt for PathBuf { + fn abs(&self) -> AbsolutePathBuf { + self.as_path().abs() + } +} + +pub trait TempDirExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl TempDirExt for TempDir { + fn abs(&self) -> AbsolutePathBuf { + self.path().abs() + } +} + pub fn test_tmp_path() -> AbsolutePathBuf { test_absolute_path_with_windows("/tmp", Some(r"C:\Users\codex\AppData\Local\Temp")) } diff --git a/codex-rs/core/tests/common/test_codex.rs b/codex-rs/core/tests/common/test_codex.rs index 7a7ae7af4b..7b0d412786 100644 --- a/codex-rs/core/tests/common/test_codex.rs +++ b/codex-rs/core/tests/common/test_codex.rs @@ -37,7 +37,10 @@ use serde_json::Value; use tempfile::TempDir; use wiremock::MockServer; +use crate::PathBufExt; +use crate::PathExt; use crate::RemoteEnvConfig; +use crate::TempDirExt; use crate::get_remote_test_env; use crate::load_default_config_for_test; use crate::responses::WebSocketTestServer; @@ -297,8 +300,7 @@ fn docker_command_capture_stdout(args: [&str; N]) -> Result Result { - AbsolutePathBuf::try_from(path.to_path_buf()) - .map_err(|err| anyhow!("invalid absolute path {}: {err}", path.display())) + Ok(path.abs()) } /// A collection of different ways the model can output an apply_patch call @@ -393,7 +395,7 @@ impl TestCodexBuilder { let cwd = test_env.cwd.to_path_buf(); self.config_mutators.push(Box::new(move |config| { config.experimental_exec_server_url = experimental_exec_server_url; - config.cwd = cwd; + config.cwd = cwd.abs(); })); let mut test = self.build(server).await?; @@ -556,7 +558,7 @@ impl TestCodexBuilder { }; let cwd = Arc::new(TempDir::new()?); let mut config = load_default_config_for_test(home).await; - config.cwd = cwd.path().to_path_buf(); + config.cwd = cwd.abs(); config.model_provider = model_provider; for hook in self.pre_build_hooks.drain(..) { hook(home.path()); @@ -716,7 +718,7 @@ impl TestCodex { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), approval_policy, approvals_reviewer: None, sandbox_policy, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 5da71f556b..869acc49f1 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -42,6 +42,7 @@ use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::apps_test_server::AppsTestServer; use core_test_support::load_default_config_for_test; use core_test_support::responses::ev_completed; @@ -1105,7 +1106,7 @@ async fn skills_append_to_developer_message() { .with_home(codex_home.clone()) .with_auth(CodexAuth::from_api_key("Test API Key")) .with_config(move |config| { - config.cwd = codex_home_path; + config.cwd = codex_home_path.abs(); }); let codex = builder .build(&server) @@ -1308,7 +1309,7 @@ async fn user_turn_collaboration_mode_overrides_model_and_effort() -> anyhow::Re text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), @@ -1426,7 +1427,7 @@ async fn user_turn_explicit_reasoning_summary_overrides_model_catalog_default() text: "hello".into(), text_elements: Vec::new(), }], - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: config.permissions.sandbox_policy.get().clone(), diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index 1517d9968c..df8d6e4fe4 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -176,7 +176,7 @@ async fn collaboration_instructions_added_on_user_turn() -> Result<()> { text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.clone(), + cwd: test.config.cwd.to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), @@ -292,7 +292,7 @@ async fn user_turn_overrides_collaboration_instructions_after_override() -> Resu text: "hello".into(), text_elements: Vec::new(), }], - cwd: test.config.cwd.clone(), + cwd: test.config.cwd.to_path_buf(), approval_policy: test.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: test.config.permissions.sandbox_policy.get().clone(), diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 7da4d4d774..5f01e10d58 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -567,11 +567,11 @@ async fn snapshot_rollback_followup_turn_trims_context_updates() -> Result<()> { user_turn(&conversation, TURN_ONE_USER).await; - let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD); + let override_cwd = config.cwd.join(PRETURN_CONTEXT_DIFF_CWD)?; std::fs::create_dir_all(&override_cwd)?; conversation .submit(Op::OverrideTurnContext { - cwd: Some(override_cwd), + cwd: Some(override_cwd.to_path_buf()), approval_policy: None, approvals_reviewer: None, sandbox_policy: None, diff --git a/codex-rs/core/tests/suite/hierarchical_agents.rs b/codex-rs/core/tests/suite/hierarchical_agents.rs index e1c45d6418..8a3fadfbb0 100644 --- a/codex-rs/core/tests/suite/hierarchical_agents.rs +++ b/codex-rs/core/tests/suite/hierarchical_agents.rs @@ -23,7 +23,14 @@ async fn hierarchical_agents_appends_to_project_doc_in_user_instructions() { .features .enable(Feature::ChildAgentsMd) .expect("test config should allow feature update"); - std::fs::write(config.cwd.join("AGENTS.md"), "be nice").expect("write AGENTS.md"); + std::fs::write( + config + .cwd + .join("AGENTS.md") + .expect("absolute AGENTS.md path"), + "be nice", + ) + .expect("write AGENTS.md"); }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index 9be83e6bb0..1bcd068614 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -16,7 +16,7 @@ use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; -use codex_utils_absolute_path::AbsolutePathBuf; +use core_test_support::TempDirExt; use core_test_support::responses::ev_completed; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_sse_once; @@ -689,7 +689,7 @@ async fn per_turn_overrides_keep_cached_prefix_and_key_constant() -> anyhow::Res let new_cwd = TempDir::new().unwrap(); let writable = TempDir::new().unwrap(); let new_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from(writable.path()).unwrap()], + writable_roots: vec![writable.abs()], read_only_access: Default::default(), network_access: true, exclude_tmpdir_env_var: true, @@ -814,7 +814,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), @@ -835,7 +835,7 @@ async fn send_user_turn_with_no_changes_does_not_send_environment_context() -> a text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), @@ -940,7 +940,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 1".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: default_approval_policy, approvals_reviewer: None, sandbox_policy: default_sandbox_policy.clone(), @@ -961,7 +961,7 @@ async fn send_user_turn_with_changes_sends_environment_context() -> anyhow::Resu text: "hello 2".into(), text_elements: Vec::new(), }], - cwd: default_cwd.clone(), + cwd: default_cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/core/tests/suite/resume_warning.rs b/codex-rs/core/tests/suite/resume_warning.rs index b1e6a694ca..e609005436 100644 --- a/codex-rs/core/tests/suite/resume_warning.rs +++ b/codex-rs/core/tests/suite/resume_warning.rs @@ -28,7 +28,7 @@ fn resume_history( let turn_ctx = TurnContextItem { turn_id: Some(turn_id.clone()), trace_id: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), current_date: None, timezone: None, approval_policy: config.permissions.approval_policy.value(), diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 81d3d1e6da..7aa05f9720 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -17,6 +17,7 @@ use codex_protocol::protocol::ReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::RolloutLine; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::load_sse_fixture_with_id_from_str; use core_test_support::responses::ResponseMock; use core_test_support::responses::mount_sse_sequence; @@ -817,7 +818,7 @@ async fn review_uses_overridden_cwd_for_base_branch_merge_base() { let codex_home = Arc::new(TempDir::new().unwrap()); let initial_cwd_path = initial_cwd.path().to_path_buf(); let codex = new_conversation_for_server(&server, codex_home.clone(), move |config| { - config.cwd = initial_cwd_path; + config.cwd = initial_cwd_path.abs(); }) .await; diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index 801ff76274..c0fe8b5352 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -10,6 +10,7 @@ use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::user_input::UserInput; +use core_test_support::PathBufExt; use core_test_support::assert_regex_match; use core_test_support::responses; use core_test_support::responses::ev_assistant_message; @@ -46,7 +47,7 @@ async fn user_shell_cmd_ls_and_cat_in_temp_dir() { let server = start_mock_server().await; let cwd_path = cwd.path().to_path_buf(); let mut builder = test_codex().with_config(move |config| { - config.cwd = cwd_path; + config.cwd = cwd_path.abs(); }); let codex = builder .build(&server) diff --git a/codex-rs/core/tests/suite/view_image.rs b/codex-rs/core/tests/suite/view_image.rs index 732d6b29eb..4041ca1d19 100644 --- a/codex-rs/core/tests/suite/view_image.rs +++ b/codex-rs/core/tests/suite/view_image.rs @@ -41,7 +41,6 @@ use image::load_from_memory; use pretty_assertions::assert_eq; use serde_json::Value; use std::io::Cursor; -use std::path::Path; use std::path::PathBuf; use tokio::time::Duration; use wiremock::BodyPrintLimit; @@ -78,11 +77,6 @@ fn find_image_message(body: &Value) -> Option<&Value> { image_messages(body).into_iter().next() } -fn absolute_path(path: &Path) -> anyhow::Result { - codex_utils_absolute_path::AbsolutePathBuf::try_from(path.to_path_buf()) - .map_err(|err| anyhow::anyhow!("invalid absolute path {}: {err}", path.display())) -} - fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> { let image = ImageBuffer::from_pixel(width, height, Rgba(rgba)); let mut cursor = Cursor::new(Vec::new()); @@ -91,14 +85,11 @@ fn png_bytes(width: u32, height: u32, rgba: [u8; 4]) -> anyhow::Result> } async fn create_workspace_directory(test: &TestCodex, rel_path: &str) -> anyhow::Result { - let abs_path = test.config.cwd.join(rel_path); + let abs_path = test.config.cwd.join(rel_path)?; test.fs() - .create_directory( - &absolute_path(&abs_path)?, - CreateDirectoryOptions { recursive: true }, - ) + .create_directory(&abs_path, CreateDirectoryOptions { recursive: true }) .await?; - Ok(abs_path) + Ok(abs_path.into_path_buf()) } async fn write_workspace_file( @@ -106,19 +97,14 @@ async fn write_workspace_file( rel_path: &str, contents: Vec, ) -> anyhow::Result { - let abs_path = test.config.cwd.join(rel_path); + let abs_path = test.config.cwd.join(rel_path)?; if let Some(parent) = abs_path.parent() { test.fs() - .create_directory( - &absolute_path(parent)?, - CreateDirectoryOptions { recursive: true }, - ) + .create_directory(&parent, CreateDirectoryOptions { recursive: true }) .await?; } - test.fs() - .write_file(&absolute_path(&abs_path)?, contents) - .await?; - Ok(abs_path) + test.fs().write_file(&abs_path, contents).await?; + Ok(abs_path.into_path_buf()) } async fn write_workspace_png( @@ -168,7 +154,7 @@ async fn user_turn_with_local_image_attaches_image() -> anyhow::Result<()> { path: abs_path.clone(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -240,7 +226,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { let cwd = config.cwd.clone(); let rel_path = "assets/example.png"; - let abs_path = cwd.join(rel_path); + let abs_path = cwd.join(rel_path)?; let original_width = 2304; let original_height = 864; write_workspace_png( @@ -277,7 +263,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: cwd.clone(), + cwd: cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -312,7 +298,7 @@ async fn view_image_tool_attaches_local_image() -> anyhow::Result<()> { _ => unreachable!("stored event must be ViewImageToolCall"), }; assert_eq!(tool_event.call_id, call_id); - assert_eq!(tool_event.path, abs_path); + assert_eq!(tool_event.path, abs_path.to_path_buf()); let req = mock.single_request(); let body = req.body_json(); @@ -418,7 +404,7 @@ async fn view_image_tool_can_preserve_original_resolution_when_requested_on_gpt5 text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -517,7 +503,7 @@ async fn view_image_tool_errors_clearly_for_unsupported_detail_values() -> anyho text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -609,7 +595,7 @@ async fn view_image_tool_treats_null_detail_as_omitted() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -709,7 +695,7 @@ async fn view_image_tool_resizes_when_model_lacks_original_detail_support() -> a text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -820,7 +806,7 @@ async fn view_image_tool_does_not_force_original_resolution_with_capability_feat text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1135,7 +1121,7 @@ async fn view_image_tool_errors_when_path_is_directory() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1211,7 +1197,7 @@ async fn view_image_tool_errors_for_non_image_files() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1265,7 +1251,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { } = &test; let rel_path = "missing/example.png"; - let abs_path = config.cwd.join(rel_path); + let abs_path = config.cwd.join(rel_path)?; let call_id = "view-image-missing"; let arguments = serde_json::json!({ "path": rel_path }).to_string(); @@ -1292,7 +1278,7 @@ async fn view_image_tool_errors_when_file_missing() -> anyhow::Result<()> { text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1415,7 +1401,7 @@ async fn view_image_tool_returns_unsupported_message_for_text_only_model() -> an text_elements: Vec::new(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, @@ -1490,7 +1476,7 @@ async fn replaces_invalid_local_image_after_bad_request() -> anyhow::Result<()> path: abs_path.clone(), }], final_output_json_schema: None, - cwd: config.cwd.clone(), + cwd: config.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, sandbox_policy: SandboxPolicy::DangerFullAccess, diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 56e542f597..43ccc78657 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -34,6 +34,8 @@ use crate::pager_overlay::Overlay; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -1029,7 +1031,7 @@ impl App { async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> { let mut config = self - .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.clone()) + .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config); self.config = config; @@ -1528,7 +1530,7 @@ impl App { self.chat_widget.current_model(), self.chat_widget.current_service_tier(), ), - self.config.cwd.clone(), + self.config.cwd.to_path_buf(), version, ) .display_lines(width) @@ -1781,7 +1783,7 @@ impl App { cwd: self .thread_cwd(thread_id) .await - .unwrap_or_else(|| self.config.cwd.clone()), + .unwrap_or_else(|| self.config.cwd.to_path_buf()), changes: ev.changes.clone(), }, )), @@ -2545,7 +2547,7 @@ impl App { chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -2612,7 +2614,13 @@ impl App { let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); let sandbox_policy = app.config.permissions.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); + Self::spawn_world_writable_scan( + cwd.to_path_buf(), + env_map, + logs_base_dir, + sandbox_policy, + tx, + ); } } @@ -2815,7 +2823,7 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - let current_cwd = self.config.cwd.clone(); + let current_cwd = self.config.cwd.to_path_buf(); let resume_cwd = match crate::resolve_cwd_for_resume_or_fork( tui, &self.config, @@ -2865,7 +2873,8 @@ impl App { self.shutdown_current_thread().await; self.config = resume_config; tui.set_notification_method(self.config.tui_notification_method); - self.file_search.update_search_dir(self.config.cwd.clone()); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); let init = self.chatwidget_init_for_forked_or_resumed_thread( tui, self.config.clone(), @@ -3192,7 +3201,8 @@ impl App { plugin_display_name, result, ); - if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + if install_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(cwd.clone()); if should_refresh_plugin_detail { self.fetch_plugin_detail( @@ -3644,7 +3654,9 @@ impl App { plugin_display_name, result, ); - if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + if uninstall_succeeded + && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(cwd); } } @@ -3831,7 +3843,7 @@ impl App { let logs_base_dir = self.config.codex_home.clone(); let sandbox_policy = self.config.permissions.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( - cwd, + cwd.to_path_buf(), env_map, logs_base_dir, sandbox_policy, @@ -4981,7 +4993,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, @@ -6121,7 +6133,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6213,7 +6225,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6280,7 +6292,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6341,7 +6353,7 @@ mod tests { app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6411,7 +6423,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = r#" profile = "guardian" approvals_reviewer = "user" @@ -6499,7 +6511,7 @@ guardian_approval = true let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"guardian_subagent\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6833,7 +6845,7 @@ guardian_approval = true async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-test"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); @@ -6944,21 +6956,33 @@ guardian_approval = true } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_header_shows_fast_status_only_for_gpt54() { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-5.4"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); @@ -6993,7 +7017,7 @@ guardian_approval = true let auth_manager = codex_core::test_support::auth_manager_from_auth( CodexAuth::from_api_key("Test API Key"), ); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -7056,7 +7080,7 @@ guardian_approval = true let auth_manager = codex_core::test_support::auth_manager_from_auth( CodexAuth::from_api_key("Test API Key"), ); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -7555,7 +7579,7 @@ guardian_approval = true }), }); - assert_eq!(app.chat_widget.config_ref().cwd, next_cwd); + assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); assert_eq!(app.config.cwd, original_cwd); app.refresh_in_memory_config_from_disk().await?; @@ -7575,7 +7599,7 @@ guardian_approval = true let current_cwd = current_config.cwd.clone(); let resume_config = app - .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.clone()) + .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) .await?; assert_eq!(resume_config, current_config); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4c3c30e81c..a4a7155ed0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -163,6 +163,7 @@ use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -1416,7 +1417,12 @@ impl ChatWidget { self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.clone()); - self.config.cwd = event.cwd.clone(); + match AbsolutePathBuf::try_from(event.cwd.clone()) { + Ok(cwd) => self.config.cwd = cwd, + Err(err) => { + tracing::warn!(path = %event.cwd.display(), %err, "session cwd should be absolute"); + } + } if let Err(err) = self .config .permissions @@ -3475,13 +3481,13 @@ impl ChatWidget { id: ev.call_id, reason: ev.reason, changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), changes: ev.changes.keys().cloned().collect(), }); } @@ -3711,7 +3717,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); - let current_cwd = Some(config.cwd.clone()); + let current_cwd = Some(config.cwd.to_path_buf()); let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -3914,7 +3920,7 @@ impl ChatWidget { }; let active_cell = Some(Self::placeholder_session_header_cell(&config)); - let current_cwd = Some(config.cwd.clone()); + let current_cwd = Some(config.cwd.to_path_buf()); let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { @@ -4537,7 +4543,15 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { - let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { + Ok(path) => path, + Err(err) => { + self.add_error_message(format!( + "Failed to prepare {DEFAULT_PROJECT_DOC_FILENAME}: {err}", + )); + return; + } + }; if init_target.exists() { let message = format!( "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." @@ -5268,7 +5282,7 @@ impl ChatWidget { let service_tier = self.config.service_tier.map(Some); let op = Op::UserTurn { items, - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), approval_policy: self.config.permissions.approval_policy.value(), approvals_reviewer: None, sandbox_policy: self.config.permissions.sandbox_policy.get().clone(), @@ -8409,7 +8423,7 @@ impl ChatWidget { placeholder_style, /*reasoning_effort*/ None, /*show_fast_status*/ false, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, )) } @@ -9158,7 +9172,7 @@ impl ChatWidget { name: "Review against a base branch".to_string(), description: Some("(PR Style)".into()), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); } @@ -9185,7 +9199,7 @@ impl ChatWidget { items.push(SelectionItem { name: "Review a commit".to_string(), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); } diff --git a/codex-rs/tui/src/chatwidget/plugins.rs b/codex-rs/tui/src/chatwidget/plugins.rs index 120b7237ee..588884639d 100644 --- a/codex-rs/tui/src/chatwidget/plugins.rs +++ b/codex-rs/tui/src/chatwidget/plugins.rs @@ -159,11 +159,11 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { self.plugins_fetch_state.in_flight_cwd = None; } - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -191,13 +191,13 @@ impl ChatWidget { } fn prefetch_plugins(&mut self) { - let cwd = self.config.cwd.clone(); - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + let cwd = self.config.cwd.to_path_buf(); + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { return; } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); - if self.plugins_fetch_state.cache_cwd.as_ref() != Some(&cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -205,7 +205,7 @@ impl ChatWidget { } fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { - if self.plugins_fetch_state.cache_cwd.as_ref() == Some(&self.config.cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { PluginsCacheState::Uninitialized @@ -253,7 +253,7 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -288,7 +288,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) -> bool { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return true; } @@ -346,7 +346,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -676,7 +676,7 @@ impl ChatWidget { ..Default::default() }]; if let Some(plugins_response) = plugins_response.cloned() { - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); items.push(SelectionItem { name: "Back to plugins".to_string(), description: Some("Return to the plugin list.".to_string()), @@ -770,7 +770,7 @@ impl ChatWidget { "{display_name} {} {} {}", plugin.id, plugin.name, marketplace_label ); - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugin_display_name = display_name.clone(); let marketplace_path = marketplace.path.clone(); let plugin_name = plugin.name.clone(); @@ -861,7 +861,7 @@ impl ChatWidget { header.push(Line::from(description.dim())); } - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugins_response = plugins_response.clone(); let mut items = vec![SelectionItem { name: "Back to plugins".to_string(), @@ -877,7 +877,7 @@ impl ChatWidget { }]; if plugin.summary.installed { - let uninstall_cwd = self.config.cwd.clone(); + let uninstall_cwd = self.config.cwd.to_path_buf(); let plugin_id = plugin.summary.id.clone(); let plugin_display_name = display_name; items.push(SelectionItem { @@ -906,7 +906,7 @@ impl ChatWidget { ..Default::default() }); } else { - let install_cwd = self.config.cwd.clone(); + let install_cwd = self.config.cwd.to_path_buf(); let marketplace_path = plugin.marketplace_path.clone(); let plugin_name = plugin.summary.name.clone(); let plugin_display_name = display_name; diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs index 5888b3b1b9..837ef05043 100644 --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs @@ -326,7 +326,9 @@ impl ChatWidget { } fn status_line_cwd(&self) -> &Path { - self.current_cwd.as_ref().unwrap_or(&self.config.cwd) + self.current_cwd + .as_deref() + .unwrap_or(self.config.cwd.as_path()) } /// Resolves the project root associated with `cwd`. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index e1314e27f0..41e9b49897 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -16,6 +16,8 @@ use crate::bottom_pane::MentionBinding; use crate::chatwidget::realtime::RealtimeConversationPhase; use crate::history_cell::UserHistoryCell; use crate::test_backend::VT100Backend; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_display; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_app_server_protocol::AppSummary; @@ -434,10 +436,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { .sandbox_policy .set(SandboxPolicy::new_workspace_write_policy()) .expect("set sandbox policy"); - chat.config.cwd = PathBuf::from("/home/user/main"); + chat.config.cwd = PathBuf::from("/home/user/main").abs(); let expected_sandbox = SandboxPolicy::new_read_only_policy(); - let expected_cwd = PathBuf::from("/home/user/sub-agent"); + let expected_cwd = PathBuf::from("/home/user/sub-agent").abs(); let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, @@ -448,7 +450,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), - cwd: expected_cwd.clone(), + cwd: expected_cwd.to_path_buf(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, @@ -5801,7 +5803,7 @@ async fn slash_init_skips_when_project_doc_exists() { let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); - chat.config.cwd = tempdir.path().to_path_buf(); + chat.config.cwd = tempdir.path().to_path_buf().abs(); chat.dispatch_command(SlashCommand::Init); @@ -6822,13 +6824,17 @@ async fn custom_prompt_enter_empty_does_not_send() { #[tokio::test] async fn view_image_tool_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let image_path = chat.config.cwd.join("example.png"); + let image_path = chat + .config + .cwd + .join("example.png") + .expect("absolute image path"); chat.handle_codex_event(Event { id: "sub-image".into(), msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { call_id: "call-image".into(), - path: image_path, + path: image_path.to_path_buf(), }), }); @@ -7118,12 +7124,10 @@ fn strip_osc8_for_snapshot(text: &str) -> String { } fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { - AbsolutePathBuf::try_from( - std::env::temp_dir() - .join("codex-plugin-menu-tests") - .join(path), - ) - .expect("expected absolute test path") + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path) + .abs() } fn plugins_test_interface( @@ -7205,7 +7209,7 @@ fn plugins_test_response(marketplaces: Vec) -> PluginLis fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); render_bottom_popup(chat, 100) } @@ -7356,10 +7360,10 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -7396,10 +7400,10 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -7486,7 +7490,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -7555,7 +7559,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -8271,8 +8275,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() { chat.bottom_pane.set_connectors_enabled(true); let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); let user_config = toml::from_str::( "[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n", ) @@ -8336,8 +8339,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove ..Default::default() }; let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); chat.config.config_layer_stack = ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) .expect("requirements stack") @@ -8912,7 +8914,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() { .find(|p| p.id == "auto") .expect("auto preset exists"); let current_sandbox = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()], + writable_roots: vec![PathBuf::from("C:\\extra").abs()], read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, @@ -9716,8 +9718,7 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work .features .set_enabled(Feature::GuardianApproval, true); - let extra_root = AbsolutePathBuf::try_from("/tmp/guardian-approvals-extra") - .expect("absolute extra writable root"); + let extra_root = PathBuf::from("/tmp/guardian-approvals-extra").abs(); chat.handle_codex_event(Event { id: "session-configured-custom-workspace".to_string(), @@ -11627,7 +11628,7 @@ async fn default_terminal_title_refreshes_when_spinner_state_changes() { let cwd = chat .current_cwd .clone() - .unwrap_or_else(|| chat.config.cwd.clone()); + .unwrap_or_else(|| chat.config.cwd.to_path_buf()); let project = get_git_repo_root(&cwd) .map(|root| { root.file_name() @@ -11866,7 +11867,7 @@ async fn status_line_fast_mode_footer_snapshot() { #[tokio::test] async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), @@ -11876,10 +11877,11 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); chat.refresh_status_surfaces(); + let test_cwd = test_path_display("/tmp/project"); assert_eq!( status_line_text(&chat), - Some("gpt-5.4 xhigh fast · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.4 xhigh fast · 100% left · {test_cwd}")) ); chat.set_model("gpt-5.3-codex"); @@ -11887,18 +11889,22 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { assert_eq!( status_line_text(&chat), - Some("gpt-5.3-codex xhigh · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.3-codex xhigh · 100% left · {test_cwd}")) ); } #[tokio::test] +#[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" +)] async fn status_line_model_with_reasoning_fast_footer_snapshot() { use ratatui::Terminal; use ratatui::backend::TestBackend; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.show_welcome_banner = false; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 8192724da0..157e5feec9 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -27,6 +27,8 @@ use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; use crate::style::proposed_plan_style; use crate::style::user_message_style; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::tooltips; @@ -1137,7 +1139,7 @@ pub(crate) fn new_session_info( model.clone(), reasoning_effort, show_fast_status, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -2654,7 +2656,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, @@ -2768,9 +2770,13 @@ mod tests { } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn session_info_availability_nux_tooltip_snapshot() { let mut config = test_config().await; - config.cwd = PathBuf::from("/tmp/project"); + config.cwd = PathBuf::from("/tmp/project").abs(); let cell = new_session_info( &config, "gpt-5", diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 039148a718..2fcf2e3dc3 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -251,6 +251,8 @@ mod wrapping; #[cfg(test)] pub mod test_backend; +#[cfg(test)] +pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 127ac9f667..fe3cab73b5 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -82,7 +82,7 @@ impl OnboardingScreen { auth_manager, config, } = args; - let cwd = config.cwd.clone(); + let cwd = config.cwd.to_path_buf(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_login_method = config.forced_login_method; let codex_home = config.codex_home.clone(); diff --git a/codex-rs/tui/src/status/card.rs b/codex-rs/tui/src/status/card.rs index a7546230bf..cadd3383a3 100644 --- a/codex-rs/tui/src/status/card.rs +++ b/codex-rs/tui/src/status/card.rs @@ -256,7 +256,7 @@ impl StatusHistoryCell { Self { model_name, model_details, - directory: config.cwd.clone(), + directory: config.cwd.to_path_buf(), permissions, agents_summary, collaboration_mode: collaboration_mode.map(ToString::to_string), diff --git a/codex-rs/tui/src/status/helpers.rs b/codex-rs/tui/src/status/helpers.rs index e09073d138..e14f27fdcc 100644 --- a/codex-rs/tui/src/status/helpers.rs +++ b/codex-rs/tui/src/status/helpers.rs @@ -46,7 +46,7 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "".to_string()); let display = if let Some(parent) = p.parent() { - if parent == config.cwd { + if parent == config.cwd.as_path() { file_name.clone() } else { let mut cur = config.cwd.as_path(); diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 16ab60bd72..10f26445e5 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -1,6 +1,7 @@ use super::new_status_output; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; +use crate::test_support::PathBufExt; use chrono::Duration as ChronoDuration; use chrono::TimeZone; use chrono::Utc; @@ -109,7 +110,7 @@ async fn status_snapshot_includes_reasoning_details() { }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -193,7 +194,7 @@ async fn status_permissions_non_default_workspace_write_is_custom() { exclude_slash_tmp: false, }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage::default(); @@ -242,7 +243,7 @@ async fn status_snapshot_includes_forked_from() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -296,7 +297,7 @@ async fn status_snapshot_includes_monthly_limit() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -549,7 +550,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -597,7 +598,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -660,7 +661,7 @@ async fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -708,7 +709,7 @@ async fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -777,7 +778,7 @@ async fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -834,7 +835,7 @@ async fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { @@ -900,7 +901,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let auth_manager = test_auth_manager(&config); let usage = TokenUsage { diff --git a/codex-rs/tui/src/test_support.rs b/codex-rs/tui/src/test_support.rs new file mode 100644 index 0000000000..790fc805ea --- /dev/null +++ b/codex-rs/tui/src/test_support.rs @@ -0,0 +1,28 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) trait PathExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathExt for Path { + fn abs(&self) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(self.to_path_buf()) + .unwrap_or_else(|_| panic!("path should already be absolute")) + } +} + +pub(crate) trait PathBufExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathBufExt for PathBuf { + fn abs(&self) -> AbsolutePathBuf { + self.as_path().abs() + } +} + +pub(crate) fn test_path_display(path: &str) -> String { + Path::new(path).abs().display().to_string() +} diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index b1773d0ac2..e00d5ebc05 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -42,6 +42,8 @@ use crate::read_session_model; use crate::render::highlight::highlight_bash_to_lines; use crate::render::renderable::Renderable; use crate::resume_picker::SessionSelection; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -1039,7 +1041,7 @@ impl App { async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> { let mut config = self - .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.clone()) + .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) .await?; self.apply_runtime_policy_overrides(&mut config); self.config = config; @@ -1406,7 +1408,7 @@ impl App { self.chat_widget.current_model(), self.chat_widget.current_service_tier(), ), - self.config.cwd.clone(), + self.config.cwd.to_path_buf(), version, ) .display_lines(width) @@ -1702,7 +1704,7 @@ impl App { cwd: self .thread_cwd(thread_id) .await - .unwrap_or_else(|| self.config.cwd.clone()), + .unwrap_or_else(|| self.config.cwd.to_path_buf()), changes: HashMap::new(), }), ), @@ -3077,7 +3079,7 @@ impl App { chat_widget .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); #[cfg(not(debug_assertions))] let upgrade_version = crate::updates::get_upgrade_version(&config); @@ -3144,7 +3146,13 @@ impl App { let tx = app.app_event_tx.clone(); let logs_base_dir = app.config.codex_home.clone(); let sandbox_policy = app.config.permissions.sandbox_policy.get().clone(); - Self::spawn_world_writable_scan(cwd, env_map, logs_base_dir, sandbox_policy, tx); + Self::spawn_world_writable_scan( + cwd.to_path_buf(), + env_map, + logs_base_dir, + sandbox_policy, + tx, + ); } } @@ -3369,7 +3377,7 @@ impl App { .await? { SessionSelection::Resume(target_session) => { - let current_cwd = self.config.cwd.clone(); + let current_cwd = self.config.cwd.to_path_buf(); let resume_cwd = if self.remote_app_server_url.is_some() { current_cwd.clone() } else { @@ -3417,7 +3425,8 @@ impl App { self.shutdown_current_thread(app_server).await; self.config = resume_config; tui.set_notification_method(self.config.tui_notification_method); - self.file_search.update_search_dir(self.config.cwd.clone()); + self.file_search + .update_search_dir(self.config.cwd.to_path_buf()); match self .replace_chat_widget_with_app_server_thread(tui, resumed) .await @@ -3715,7 +3724,8 @@ impl App { plugin_display_name, result, ); - if install_succeeded && self.chat_widget.config_ref().cwd == cwd { + if install_succeeded && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(app_server, cwd.clone()); if should_refresh_plugin_detail { self.fetch_plugin_detail( @@ -4190,7 +4200,9 @@ impl App { plugin_display_name, result, ); - if uninstall_succeeded && self.chat_widget.config_ref().cwd == cwd { + if uninstall_succeeded + && self.chat_widget.config_ref().cwd.as_path() == cwd.as_path() + { self.fetch_plugins_list(app_server, cwd); } } @@ -4377,7 +4389,7 @@ impl App { let logs_base_dir = self.config.codex_home.clone(); let sandbox_policy = self.config.permissions.sandbox_policy.get().clone(); Self::spawn_world_writable_scan( - cwd, + cwd.to_path_buf(), env_map, logs_base_dir, sandbox_policy, @@ -6693,7 +6705,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"guardian_subagent\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6785,7 +6797,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6852,7 +6864,7 @@ mod tests { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "approvals_reviewer = \"user\"\napproval_policy = \"on-request\"\nsandbox_mode = \"workspace-write\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6913,7 +6925,7 @@ mod tests { app.config.codex_home = codex_home.path().to_path_buf(); let guardian_approvals = guardian_approvals_mode(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"user\"\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -6983,7 +6995,7 @@ mod tests { let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = r#" profile = "guardian" approvals_reviewer = "user" @@ -7071,7 +7083,7 @@ guardian_approval = true let codex_home = tempdir()?; app.config.codex_home = codex_home.path().to_path_buf(); app.active_profile = Some("guardian".to_string()); - let config_toml_path = AbsolutePathBuf::try_from(codex_home.path().join("config.toml"))?; + let config_toml_path = codex_home.path().join("config.toml").abs(); let config_toml = "profile = \"guardian\"\napprovals_reviewer = \"guardian_subagent\"\n\n[features]\nguardian_approval = true\n"; std::fs::write(config_toml_path.as_path(), config_toml)?; let user_config = toml::from_str::(config_toml)?; @@ -7652,7 +7664,7 @@ guardian_approval = true async fn render_clear_ui_header_after_long_transcript_for_snapshot() -> String { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-test"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::High)); @@ -7705,7 +7717,7 @@ guardian_approval = true approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: Some(ReasoningEffortConfig::High), history_log_id: 0, history_entry_count: 0, @@ -7763,21 +7775,33 @@ guardian_approval = true } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_after_long_transcript_snapshots_fresh_header_only() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn ctrl_l_clear_ui_after_long_transcript_reuses_clear_header_snapshot() { let rendered = render_clear_ui_header_after_long_transcript_for_snapshot().await; assert_snapshot!("clear_ui_after_long_transcript_fresh_header_only", rendered); } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn clear_ui_header_shows_fast_status_only_for_gpt54() { let mut app = make_test_app().await; - app.config.cwd = PathBuf::from("/tmp/project"); + app.config.cwd = PathBuf::from("/tmp/project").abs(); app.chat_widget.set_model("gpt-5.4"); app.chat_widget .set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); @@ -7803,7 +7827,7 @@ guardian_approval = true async fn make_test_app() -> App { let (chat_widget, app_event_tx, _rx, _op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -7853,7 +7877,7 @@ guardian_approval = true ) { let (chat_widget, app_event_tx, rx, op_rx) = make_chatwidget_manual_with_sender().await; let config = chat_widget.config_ref().clone(); - let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); let model = codex_core::test_support::get_model_offline(config.model.as_deref()); let session_telemetry = test_session_telemetry(&config, model.as_str()); @@ -8598,7 +8622,7 @@ guardian_approval = true }), }); - assert_eq!(app.chat_widget.config_ref().cwd, next_cwd); + assert_eq!(app.chat_widget.config_ref().cwd.to_path_buf(), next_cwd); assert_eq!(app.config.cwd, original_cwd); app.refresh_in_memory_config_from_disk().await?; @@ -8618,7 +8642,7 @@ guardian_approval = true let current_cwd = current_config.cwd.clone(); let resume_config = app - .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.clone()) + .rebuild_config_for_resume_or_fallback(¤t_cwd, current_cwd.to_path_buf()) .await?; assert_eq!(resume_config, current_config); diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index df73ef20a3..7a636ededc 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -207,6 +207,7 @@ use codex_terminal_detection::Multiplexer; use codex_terminal_detection::TerminalInfo; use codex_terminal_detection::TerminalName; use codex_terminal_detection::terminal_info; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_sleep_inhibitor::SleepInhibitor; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -1778,7 +1779,12 @@ impl ChatWidget { self.forked_from = event.forked_from_id; self.current_rollout_path = event.rollout_path.clone(); self.current_cwd = Some(event.cwd.clone()); - self.config.cwd = event.cwd.clone(); + match AbsolutePathBuf::try_from(event.cwd.clone()) { + Ok(cwd) => self.config.cwd = cwd, + Err(err) => { + tracing::warn!(path = %event.cwd.display(), %err, "session cwd should be absolute"); + } + } if let Err(err) = self .config .permissions @@ -4026,13 +4032,13 @@ impl ChatWidget { id: ev.call_id, reason: ev.reason, changes: ev.changes.clone(), - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), }; self.bottom_pane .push_approval_request(request, &self.config.features); self.request_redraw(); self.notify(Notification::EditApprovalRequested { - cwd: self.config.cwd.clone(), + cwd: self.config.cwd.to_path_buf(), changes: ev.changes.keys().cloned().collect(), }); } @@ -4274,7 +4280,7 @@ impl ChatWidget { let active_cell = Some(Self::placeholder_session_header_cell(&config)); - let current_cwd = Some(config.cwd.clone()); + let current_cwd = Some(config.cwd.to_path_buf()); let queued_message_edit_binding = queued_message_edit_binding_for_terminal(terminal_info()); let mut widget = Self { app_event_tx: app_event_tx.clone(), @@ -4693,7 +4699,15 @@ impl ChatWidget { self.app_event_tx.send(AppEvent::ForkCurrentSession); } SlashCommand::Init => { - let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME); + let init_target = match self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME) { + Ok(path) => path, + Err(err) => { + self.add_error_message(format!( + "Failed to prepare {DEFAULT_PROJECT_DOC_FILENAME}: {err}", + )); + return; + } + }; if init_target.exists() { let message = format!( "{DEFAULT_PROJECT_DOC_FILENAME} already exists here. Skipping /init to avoid overwriting it." @@ -5415,7 +5429,7 @@ impl ChatWidget { let service_tier = self.config.service_tier.map(Some); let op = AppCommand::user_turn( items, - self.config.cwd.clone(), + self.config.cwd.to_path_buf(), self.config.permissions.approval_policy.value(), self.config.permissions.sandbox_policy.get().clone(), effective_mode.model().to_string(), @@ -7022,7 +7036,9 @@ impl ChatWidget { } fn status_line_cwd(&self) -> &Path { - self.current_cwd.as_ref().unwrap_or(&self.config.cwd) + self.current_cwd + .as_deref() + .unwrap_or(self.config.cwd.as_path()) } fn status_line_project_root(&self) -> Option { @@ -9617,7 +9633,7 @@ impl ChatWidget { placeholder_style, /*reasoning_effort*/ None, /*show_fast_status*/ false, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, )) } @@ -10331,7 +10347,7 @@ impl ChatWidget { name: "Review against a base branch".to_string(), description: Some("(PR Style)".into()), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewBranchPicker(cwd.clone())); } @@ -10356,7 +10372,7 @@ impl ChatWidget { items.push(SelectionItem { name: "Review a commit".to_string(), actions: vec![Box::new({ - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); move |tx| { tx.send(AppEvent::OpenReviewCommitPicker(cwd.clone())); } diff --git a/codex-rs/tui_app_server/src/chatwidget/plugins.rs b/codex-rs/tui_app_server/src/chatwidget/plugins.rs index 120b7237ee..588884639d 100644 --- a/codex-rs/tui_app_server/src/chatwidget/plugins.rs +++ b/codex-rs/tui_app_server/src/chatwidget/plugins.rs @@ -159,11 +159,11 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { self.plugins_fetch_state.in_flight_cwd = None; } - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -191,13 +191,13 @@ impl ChatWidget { } fn prefetch_plugins(&mut self) { - let cwd = self.config.cwd.clone(); - if self.plugins_fetch_state.in_flight_cwd.as_ref() == Some(&cwd) { + let cwd = self.config.cwd.to_path_buf(); + if self.plugins_fetch_state.in_flight_cwd.as_deref() == Some(cwd.as_path()) { return; } self.plugins_fetch_state.in_flight_cwd = Some(cwd.clone()); - if self.plugins_fetch_state.cache_cwd.as_ref() != Some(&cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() != Some(cwd.as_path()) { self.plugins_cache = PluginsCacheState::Loading; } @@ -205,7 +205,7 @@ impl ChatWidget { } fn plugins_cache_for_current_cwd(&self) -> PluginsCacheState { - if self.plugins_fetch_state.cache_cwd.as_ref() == Some(&self.config.cwd) { + if self.plugins_fetch_state.cache_cwd.as_deref() == Some(self.config.cwd.as_path()) { self.plugins_cache.clone() } else { PluginsCacheState::Uninitialized @@ -253,7 +253,7 @@ impl ChatWidget { cwd: PathBuf, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -288,7 +288,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) -> bool { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return true; } @@ -346,7 +346,7 @@ impl ChatWidget { plugin_display_name: String, result: Result, ) { - if self.config.cwd != cwd { + if self.config.cwd.as_path() != cwd.as_path() { return; } @@ -676,7 +676,7 @@ impl ChatWidget { ..Default::default() }]; if let Some(plugins_response) = plugins_response.cloned() { - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); items.push(SelectionItem { name: "Back to plugins".to_string(), description: Some("Return to the plugin list.".to_string()), @@ -770,7 +770,7 @@ impl ChatWidget { "{display_name} {} {} {}", plugin.id, plugin.name, marketplace_label ); - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugin_display_name = display_name.clone(); let marketplace_path = marketplace.path.clone(); let plugin_name = plugin.name.clone(); @@ -861,7 +861,7 @@ impl ChatWidget { header.push(Line::from(description.dim())); } - let cwd = self.config.cwd.clone(); + let cwd = self.config.cwd.to_path_buf(); let plugins_response = plugins_response.clone(); let mut items = vec![SelectionItem { name: "Back to plugins".to_string(), @@ -877,7 +877,7 @@ impl ChatWidget { }]; if plugin.summary.installed { - let uninstall_cwd = self.config.cwd.clone(); + let uninstall_cwd = self.config.cwd.to_path_buf(); let plugin_id = plugin.summary.id.clone(); let plugin_display_name = display_name; items.push(SelectionItem { @@ -906,7 +906,7 @@ impl ChatWidget { ..Default::default() }); } else { - let install_cwd = self.config.cwd.clone(); + let install_cwd = self.config.cwd.to_path_buf(); let marketplace_path = plugin.marketplace_path.clone(); let plugin_name = plugin.summary.name.clone(); let plugin_display_name = display_name; diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index c3c2ef7692..6eefc52a87 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -17,6 +17,8 @@ use crate::chatwidget::realtime::RealtimeConversationPhase; use crate::history_cell::UserHistoryCell; use crate::model_catalog::ModelCatalog; use crate::test_backend::VT100Backend; +use crate::test_support::PathBufExt; +use crate::test_support::test_path_display; use crate::tui::FrameRequester; use assert_matches::assert_matches; use codex_app_server_protocol::AppSummary; @@ -458,10 +460,10 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { .sandbox_policy .set(SandboxPolicy::new_workspace_write_policy()) .expect("set sandbox policy"); - chat.config.cwd = PathBuf::from("/home/user/main"); + chat.config.cwd = PathBuf::from("/home/user/main").abs(); let expected_sandbox = SandboxPolicy::new_read_only_policy(); - let expected_cwd = PathBuf::from("/home/user/sub-agent"); + let expected_cwd = PathBuf::from("/home/user/sub-agent").abs(); let configured = codex_protocol::protocol::SessionConfiguredEvent { session_id: ThreadId::new(), forked_from_id: None, @@ -472,7 +474,7 @@ async fn session_configured_syncs_widget_config_permissions_and_cwd() { approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, sandbox_policy: expected_sandbox.clone(), - cwd: expected_cwd.clone(), + cwd: expected_cwd.to_path_buf(), reasoning_effort: Some(ReasoningEffortConfig::default()), history_log_id: 0, history_entry_count: 0, @@ -6426,7 +6428,7 @@ async fn slash_init_skips_when_project_doc_exists() { let tempdir = tempdir().unwrap(); let existing_path = tempdir.path().join(DEFAULT_PROJECT_DOC_FILENAME); std::fs::write(&existing_path, "existing instructions").unwrap(); - chat.config.cwd = tempdir.path().to_path_buf(); + chat.config.cwd = tempdir.path().to_path_buf().abs(); chat.dispatch_command(SlashCommand::Init); @@ -7419,13 +7421,17 @@ async fn custom_prompt_enter_empty_does_not_send() { #[tokio::test] async fn view_image_tool_call_adds_history_cell() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await; - let image_path = chat.config.cwd.join("example.png"); + let image_path = chat + .config + .cwd + .join("example.png") + .expect("absolute image path"); chat.handle_codex_event(Event { id: "sub-image".into(), msg: EventMsg::ViewImageToolCall(ViewImageToolCallEvent { call_id: "call-image".into(), - path: image_path, + path: image_path.to_path_buf(), }), }); @@ -7715,12 +7721,10 @@ fn strip_osc8_for_snapshot(text: &str) -> String { } fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { - AbsolutePathBuf::try_from( - std::env::temp_dir() - .join("codex-plugin-menu-tests") - .join(path), - ) - .expect("expected absolute test path") + std::env::temp_dir() + .join("codex-plugin-menu-tests") + .join(path) + .abs() } fn plugins_test_interface( @@ -7802,7 +7806,7 @@ fn plugins_test_response(marketplaces: Vec) -> PluginLis fn render_loaded_plugins_popup(chat: &mut ChatWidget, response: PluginListResponse) -> String { let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); render_bottom_popup(chat, 100) } @@ -7953,10 +7957,10 @@ async fn plugin_detail_popup_snapshot_shows_install_actions_and_capability_summa summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -7993,10 +7997,10 @@ async fn plugin_detail_popup_hides_disclosure_for_installed_plugins() { summary.clone(), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd.clone(), Ok(response)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); chat.on_plugin_detail_loaded( - cwd, + cwd.to_path_buf(), Ok(PluginReadResponse { plugin: plugins_test_detail( summary, @@ -8083,7 +8087,7 @@ async fn plugins_popup_refresh_replaces_selection_with_first_row() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -8152,7 +8156,7 @@ async fn plugins_popup_refreshes_installed_counts_after_install() { ), ])]); let cwd = chat.config.cwd.clone(); - chat.on_plugins_loaded(cwd, Ok(refreshed)); + chat.on_plugins_loaded(cwd.to_path_buf(), Ok(refreshed)); let after = render_bottom_popup(&chat, 100); assert!( @@ -8824,8 +8828,7 @@ async fn apps_initial_load_applies_enabled_state_from_config() { chat.bottom_pane.set_connectors_enabled(true); let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); let user_config = toml::from_str::( "[apps.connector_1]\nenabled = false\ndisabled_reason = \"user\"\n", ) @@ -8889,8 +8892,7 @@ async fn apps_initial_load_applies_enabled_state_from_requirements_with_user_ove ..Default::default() }; let temp = tempdir().expect("tempdir"); - let config_toml_path = - AbsolutePathBuf::try_from(temp.path().join("config.toml")).expect("absolute config path"); + let config_toml_path = temp.path().join("config.toml").abs(); chat.config.config_layer_stack = ConfigLayerStack::new(Vec::new(), ConfigRequirements::default(), requirements) .expect("requirements stack") @@ -9465,7 +9467,7 @@ async fn preset_matching_accepts_workspace_write_with_extra_roots() { .find(|p| p.id == "auto") .expect("auto preset exists"); let current_sandbox = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::try_from("C:\\extra").unwrap()], + writable_roots: vec![PathBuf::from("C:\\extra").abs()], read_only_access: Default::default(), network_access: false, exclude_tmpdir_env_var: false, @@ -10265,8 +10267,7 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work .features .set_enabled(Feature::GuardianApproval, true); - let extra_root = AbsolutePathBuf::try_from("/tmp/guardian-approvals-extra") - .expect("absolute extra writable root"); + let extra_root = PathBuf::from("/tmp/guardian-approvals-extra").abs(); chat.handle_codex_event(Event { id: "session-configured-custom-workspace".to_string(), @@ -12279,7 +12280,7 @@ async fn status_line_fast_mode_footer_snapshot() { #[tokio::test] async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), @@ -12289,10 +12290,11 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { chat.set_service_tier(Some(ServiceTier::Fast)); set_chatgpt_auth(&mut chat); chat.refresh_status_line(); + let test_cwd = test_path_display("/tmp/project"); assert_eq!( status_line_text(&chat), - Some("gpt-5.4 xhigh fast · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.4 xhigh fast · 100% left · {test_cwd}")) ); chat.set_model("gpt-5.3-codex"); @@ -12300,18 +12302,22 @@ async fn status_line_model_with_reasoning_includes_fast_for_gpt54_only() { assert_eq!( status_line_text(&chat), - Some("gpt-5.3-codex xhigh · 100% left · /tmp/project".to_string()) + Some(format!("gpt-5.3-codex xhigh · 100% left · {test_cwd}")) ); } #[tokio::test] +#[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" +)] async fn status_line_model_with_reasoning_fast_footer_snapshot() { use ratatui::Terminal; use ratatui::backend::TestBackend; let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.show_welcome_banner = false; - chat.config.cwd = PathBuf::from("/tmp/project"); + chat.config.cwd = PathBuf::from("/tmp/project").abs(); chat.config.tui_status_line = Some(vec![ "model-with-reasoning".to_string(), "context-remaining".to_string(), diff --git a/codex-rs/tui_app_server/src/history_cell.rs b/codex-rs/tui_app_server/src/history_cell.rs index 38937406b7..445d684469 100644 --- a/codex-rs/tui_app_server/src/history_cell.rs +++ b/codex-rs/tui_app_server/src/history_cell.rs @@ -27,6 +27,8 @@ use crate::render::line_utils::push_owned_lines; use crate::render::renderable::Renderable; use crate::style::proposed_plan_style; use crate::style::user_message_style; +#[cfg(test)] +use crate::test_support::PathBufExt; use crate::text_formatting::format_and_truncate_tool_result; use crate::text_formatting::truncate_text; use crate::tooltips; @@ -1143,7 +1145,7 @@ pub(crate) fn new_session_info( model.clone(), reasoning_effort, show_fast_status, - config.cwd.clone(), + config.cwd.to_path_buf(), CODEX_CLI_VERSION, ); let mut parts: Vec> = vec![Box::new(header)]; @@ -2883,7 +2885,7 @@ mod tests { approval_policy: AskForApproval::Never, approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, sandbox_policy: SandboxPolicy::new_read_only_policy(), - cwd: PathBuf::from("/tmp/project"), + cwd: PathBuf::from("/tmp/project").abs().to_path_buf(), reasoning_effort: None, history_log_id: 0, history_entry_count: 0, @@ -2997,9 +2999,13 @@ mod tests { } #[tokio::test] + #[cfg_attr( + target_os = "windows", + ignore = "snapshot path rendering differs on Windows" + )] async fn session_info_availability_nux_tooltip_snapshot() { let mut config = test_config().await; - config.cwd = PathBuf::from("/tmp/project"); + config.cwd = PathBuf::from("/tmp/project").abs(); let cell = new_session_info( &config, "gpt-5", diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 790a7c6f55..4a36eb58c6 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -248,6 +248,8 @@ mod wrapping; #[cfg(test)] pub mod test_backend; +#[cfg(test)] +pub(crate) mod test_support; use crate::onboarding::onboarding_screen::OnboardingScreenArgs; use crate::onboarding::onboarding_screen::run_onboarding_app; @@ -1221,7 +1223,7 @@ async fn run_ratatui_app( let fallback_cwd = match action_and_target_session_if_resume_or_fork { Some((action, target_session)) => { if remote_mode { - Some(current_cwd.clone()) + Some(current_cwd.to_path_buf()) } else { match resolve_cwd_for_resume_or_fork( &mut tui, diff --git a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs index d092d06e2f..ec8e428947 100644 --- a/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui_app_server/src/onboarding/onboarding_screen.rs @@ -85,7 +85,7 @@ impl OnboardingScreen { app_server_request_handle, config, } = args; - let cwd = config.cwd.clone(); + let cwd = config.cwd.to_path_buf(); let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); let forced_login_method = config.forced_login_method; let codex_home = config.codex_home.clone(); diff --git a/codex-rs/tui_app_server/src/status/card.rs b/codex-rs/tui_app_server/src/status/card.rs index 4b5a6474bd..d7556154d5 100644 --- a/codex-rs/tui_app_server/src/status/card.rs +++ b/codex-rs/tui_app_server/src/status/card.rs @@ -255,7 +255,7 @@ impl StatusHistoryCell { Self { model_name, model_details, - directory: config.cwd.clone(), + directory: config.cwd.to_path_buf(), permissions, agents_summary, collaboration_mode: collaboration_mode.map(ToString::to_string), diff --git a/codex-rs/tui_app_server/src/status/helpers.rs b/codex-rs/tui_app_server/src/status/helpers.rs index 7272125560..90c045fb9c 100644 --- a/codex-rs/tui_app_server/src/status/helpers.rs +++ b/codex-rs/tui_app_server/src/status/helpers.rs @@ -42,7 +42,7 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String { .map(|name| name.to_string_lossy().to_string()) .unwrap_or_else(|| "".to_string()); let display = if let Some(parent) = p.parent() { - if parent == config.cwd { + if parent == config.cwd.as_path() { file_name.clone() } else { let mut cur = config.cwd.as_path(); diff --git a/codex-rs/tui_app_server/src/status/tests.rs b/codex-rs/tui_app_server/src/status/tests.rs index a224eb7803..fbfbca9128 100644 --- a/codex-rs/tui_app_server/src/status/tests.rs +++ b/codex-rs/tui_app_server/src/status/tests.rs @@ -2,6 +2,7 @@ use super::new_status_output; use super::rate_limit_snapshot_display; use crate::history_cell::HistoryCell; use crate::status::StatusAccountDisplay; +use crate::test_support::PathBufExt; use chrono::Duration as ChronoDuration; use chrono::TimeZone; use chrono::Utc; @@ -105,7 +106,7 @@ async fn status_snapshot_includes_reasoning_details() { }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -189,7 +190,7 @@ async fn status_permissions_non_default_workspace_write_is_custom() { exclude_slash_tmp: false, }) .expect("set sandbox policy"); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage::default(); @@ -238,7 +239,7 @@ async fn status_snapshot_includes_forked_from() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -292,7 +293,7 @@ async fn status_snapshot_includes_monthly_limit() { let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -545,7 +546,7 @@ async fn status_card_token_usage_excludes_cached_tokens() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -593,7 +594,7 @@ async fn status_snapshot_truncates_in_narrow_terminal() { config.model = Some("gpt-5.1-codex-max".to_string()); config.model_provider_id = "openai".to_string(); config.model_reasoning_summary = Some(ReasoningSummary::Detailed); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -656,7 +657,7 @@ async fn status_snapshot_shows_missing_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -704,7 +705,7 @@ async fn status_snapshot_includes_credits_and_limits() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -773,7 +774,7 @@ async fn status_snapshot_shows_empty_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -830,7 +831,7 @@ async fn status_snapshot_shows_stale_limits_message() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex-max".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { @@ -896,7 +897,7 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() { let temp_home = TempDir::new().expect("temp home"); let mut config = test_config(&temp_home).await; config.model = Some("gpt-5.1-codex".to_string()); - config.cwd = PathBuf::from("/workspace/tests"); + config.cwd = PathBuf::from("/workspace/tests").abs(); let account_display = test_status_account_display(); let usage = TokenUsage { diff --git a/codex-rs/tui_app_server/src/test_support.rs b/codex-rs/tui_app_server/src/test_support.rs new file mode 100644 index 0000000000..790fc805ea --- /dev/null +++ b/codex-rs/tui_app_server/src/test_support.rs @@ -0,0 +1,28 @@ +use codex_utils_absolute_path::AbsolutePathBuf; +use std::path::Path; +use std::path::PathBuf; + +pub(crate) trait PathExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathExt for Path { + fn abs(&self) -> AbsolutePathBuf { + AbsolutePathBuf::try_from(self.to_path_buf()) + .unwrap_or_else(|_| panic!("path should already be absolute")) + } +} + +pub(crate) trait PathBufExt { + fn abs(&self) -> AbsolutePathBuf; +} + +impl PathBufExt for PathBuf { + fn abs(&self) -> AbsolutePathBuf { + self.as_path().abs() + } +} + +pub(crate) fn test_path_display(path: &str) -> String { + Path::new(path).abs().display().to_string() +} diff --git a/codex-rs/utils/absolute-path/src/lib.rs b/codex-rs/utils/absolute-path/src/lib.rs index 1ba1c40cfb..69691ac752 100644 --- a/codex-rs/utils/absolute-path/src/lib.rs +++ b/codex-rs/utils/absolute-path/src/lib.rs @@ -63,6 +63,12 @@ impl AbsolutePathBuf { Self::from_absolute_path(current_dir) } + /// Construct an absolute path from `path`, resolving relative paths against + /// the process current working directory. + pub fn relative_to_current_dir>(path: P) -> std::io::Result { + Self::resolve_path_against_base(path, std::env::current_dir()?) + } + pub fn join>(&self, path: P) -> std::io::Result { Self::resolve_path_against_base(path, &self.0) } @@ -104,6 +110,14 @@ impl AsRef for AbsolutePathBuf { } } +impl std::ops::Deref for AbsolutePathBuf { + type Target = Path; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + impl From for PathBuf { fn from(path: AbsolutePathBuf) -> Self { path.into_path_buf() @@ -216,6 +230,17 @@ mod tests { assert_eq!(abs_path_buf.as_path(), base_dir.join("file.txt").as_path()); } + #[test] + fn relative_to_current_dir_resolves_relative_path() -> std::io::Result<()> { + let current_dir = std::env::current_dir()?; + let abs_path_buf = AbsolutePathBuf::relative_to_current_dir("file.txt")?; + assert_eq!( + abs_path_buf.as_path(), + current_dir.join("file.txt").as_path() + ); + Ok(()) + } + #[test] fn guard_used_in_deserialization() { let temp_dir = tempdir().expect("base dir"); From f190a95a4f0d7195c1c5cff48157b6c451be7c27 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:07:04 +0000 Subject: [PATCH 31/50] feat: rendering library v1 (#15778) The goal will be to replace askama --- codex-rs/Cargo.lock | 7 + codex-rs/Cargo.toml | 4 +- codex-rs/utils/template/BUILD.bazel | 6 + codex-rs/utils/template/Cargo.toml | 11 + codex-rs/utils/template/README.md | 41 +++ codex-rs/utils/template/src/lib.rs | 442 ++++++++++++++++++++++++++++ 6 files changed, 510 insertions(+), 1 deletion(-) create mode 100644 codex-rs/utils/template/BUILD.bazel create mode 100644 codex-rs/utils/template/Cargo.toml create mode 100644 codex-rs/utils/template/README.md create mode 100644 codex-rs/utils/template/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 9ecf9b1de4..33244c1185 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3006,6 +3006,13 @@ dependencies = [ "regex-lite", ] +[[package]] +name = "codex-utils-template" +version = "0.0.0" +dependencies = [ + "pretty_assertions", +] + [[package]] name = "codex-v8-poc" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 5854c1c46d..3d2ee99482 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -70,6 +70,7 @@ members = [ "utils/path-utils", "utils/fuzzy-match", "utils/stream-parser", + "utils/template", "codex-client", "codex-api", "state", @@ -166,6 +167,7 @@ codex-utils-rustls-provider = { path = "utils/rustls-provider" } codex-utils-sandbox-summary = { path = "utils/sandbox-summary" } codex-utils-sleep-inhibitor = { path = "utils/sleep-inhibitor" } codex-utils-stream-parser = { path = "utils/stream-parser" } +codex-utils-template = { path = "utils/template" } codex-utils-string = { path = "utils/string" } codex-windows-sandbox = { path = "windows-sandbox-rs" } core_test_support = { path = "core/tests/common" } @@ -383,7 +385,7 @@ ignored = [ "icu_provider", "openssl-sys", "codex-utils-readiness", - "codex-secrets", + "codex-utils-template", "codex-v8-poc", ] diff --git a/codex-rs/utils/template/BUILD.bazel b/codex-rs/utils/template/BUILD.bazel new file mode 100644 index 0000000000..4b3376de4e --- /dev/null +++ b/codex-rs/utils/template/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "template", + crate_name = "codex_utils_template", +) diff --git a/codex-rs/utils/template/Cargo.toml b/codex-rs/utils/template/Cargo.toml new file mode 100644 index 0000000000..60a0c5b984 --- /dev/null +++ b/codex-rs/utils/template/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "codex-utils-template" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lints] +workspace = true + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/utils/template/README.md b/codex-rs/utils/template/README.md new file mode 100644 index 0000000000..3197e115eb --- /dev/null +++ b/codex-rs/utils/template/README.md @@ -0,0 +1,41 @@ +# codex-utils-template + +Small, strict string templating for prompt and text assets. + +Supported syntax: + +- `{{ name }}` placeholder interpolation +- `{{{{` for a literal `{{` +- `}}}}` for a literal `}}` + +The library is intentionally strict: + +- parsing fails on malformed placeholders +- rendering fails on missing values +- rendering fails on duplicate values +- rendering fails on extra values not used by the template + +## Example + +```rust +use codex_utils_template::Template; +use codex_utils_template::render; + +let template = Template::parse( + "Hello, {{ name }}.\nLiteral braces: {{{{ and }}}}.\nMode: {{ mode }}", +)?; + +let rendered = template.render([ + ("name", "Codex"), + ("mode", "strict"), +])?; + +assert_eq!( + rendered, + "Hello, Codex.\nLiteral braces: {{ and }}.\nMode: strict" +); + +let one_shot = render("Hi {{ who }}!", [("who", "there")])?; +assert_eq!(one_shot, "Hi there!"); +# Ok::<(), Box>(()) +``` diff --git a/codex-rs/utils/template/src/lib.rs b/codex-rs/utils/template/src/lib.rs new file mode 100644 index 0000000000..62d2314ffb --- /dev/null +++ b/codex-rs/utils/template/src/lib.rs @@ -0,0 +1,442 @@ +//! Minimal strict templating for prompt and text assets. +//! +//! Supported syntax: +//! - `{{ name }}` placeholder interpolation +//! - `{{{{` for a literal `{{` +//! - `}}}}` for a literal `}}` + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateParseError { + EmptyPlaceholder { start: usize }, + NestedPlaceholder { start: usize }, + UnmatchedClosingDelimiter { start: usize }, + UnterminatedPlaceholder { start: usize }, +} + +impl fmt::Display for TemplateParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPlaceholder { start } => { + write!(f, "template placeholder at byte {start} is empty") + } + Self::NestedPlaceholder { start } => { + write!( + f, + "template placeholder starting at byte {start} contains a nested `{{`" + ) + } + Self::UnmatchedClosingDelimiter { start } => { + write!(f, "template contains an unmatched `}}` at byte {start}") + } + Self::UnterminatedPlaceholder { start } => { + write!( + f, + "template placeholder starting at byte {start} is missing `}}`" + ) + } + } + } +} + +impl Error for TemplateParseError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateRenderError { + DuplicateValue { name: String }, + ExtraValue { name: String }, + MissingValue { name: String }, +} + +impl fmt::Display for TemplateRenderError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DuplicateValue { name } => { + write!(f, "template value `{name}` was provided more than once") + } + Self::ExtraValue { name } => { + write!(f, "template value `{name}` is not used by this template") + } + Self::MissingValue { name } => { + write!(f, "template placeholder `{name}` is missing a value") + } + } + } +} + +impl Error for TemplateRenderError {} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TemplateError { + Parse(TemplateParseError), + Render(TemplateRenderError), +} + +impl fmt::Display for TemplateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Parse(err) => err.fmt(f), + Self::Render(err) => err.fmt(f), + } + } +} + +impl Error for TemplateError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Parse(err) => Some(err), + Self::Render(err) => Some(err), + } + } +} + +impl From for TemplateError { + fn from(value: TemplateParseError) -> Self { + Self::Parse(value) + } +} + +impl From for TemplateError { + fn from(value: TemplateRenderError) -> Self { + Self::Render(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum Segment { + Literal(String), + Placeholder(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Template { + placeholders: BTreeSet, + segments: Vec, +} + +impl Template { + pub fn parse(source: &str) -> Result { + let mut placeholders = BTreeSet::new(); + let mut segments = Vec::new(); + let mut literal_start = 0usize; + let mut cursor = 0usize; + + while cursor < source.len() { + let rest = &source[cursor..]; + if rest.starts_with("{{{{") { + push_literal(&mut segments, &source[literal_start..cursor]); + push_literal(&mut segments, "{{"); + cursor += "{{{{".len(); + literal_start = cursor; + continue; + } + if rest.starts_with("}}}}") { + push_literal(&mut segments, &source[literal_start..cursor]); + push_literal(&mut segments, "}}"); + cursor += "}}}}".len(); + literal_start = cursor; + continue; + } + if rest.starts_with("{{") { + push_literal(&mut segments, &source[literal_start..cursor]); + let (placeholder, next_cursor) = parse_placeholder(source, cursor)?; + placeholders.insert(placeholder.clone()); + segments.push(Segment::Placeholder(placeholder)); + cursor = next_cursor; + literal_start = cursor; + continue; + } + if rest.starts_with("}}") { + return Err(TemplateParseError::UnmatchedClosingDelimiter { start: cursor }); + } + + let Some(ch) = rest.chars().next() else { + break; + }; + cursor += ch.len_utf8(); + } + + push_literal(&mut segments, &source[literal_start..]); + Ok(Self { + placeholders, + segments, + }) + } + + pub fn placeholders(&self) -> impl ExactSizeIterator { + self.placeholders.iter().map(String::as_str) + } + + pub fn render(&self, variables: I) -> Result + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + let variables = build_variable_map(variables)?; + + for placeholder in &self.placeholders { + if !variables.contains_key(placeholder.as_str()) { + return Err(TemplateRenderError::MissingValue { + name: placeholder.clone(), + }); + } + } + + for name in variables.keys() { + if !self.placeholders.contains(name.as_str()) { + return Err(TemplateRenderError::ExtraValue { name: name.clone() }); + } + } + + let mut rendered = String::new(); + for segment in &self.segments { + match segment { + Segment::Literal(literal) => rendered.push_str(literal), + Segment::Placeholder(name) => { + let Some(value) = variables.get(name.as_str()) else { + return Err(TemplateRenderError::MissingValue { name: name.clone() }); + }; + rendered.push_str(value); + } + } + } + Ok(rendered) + } +} + +pub fn render(template: &str, variables: I) -> Result +where + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + Template::parse(template)? + .render(variables) + .map_err(Into::into) +} + +fn push_literal(segments: &mut Vec, literal: &str) { + if literal.is_empty() { + return; + } + + if let Some(Segment::Literal(existing)) = segments.last_mut() { + existing.push_str(literal); + } else { + segments.push(Segment::Literal(literal.to_string())); + } +} + +fn parse_placeholder(source: &str, start: usize) -> Result<(String, usize), TemplateParseError> { + let placeholder_start = start + "{{".len(); + let mut cursor = placeholder_start; + + while cursor < source.len() { + let rest = &source[cursor..]; + if rest.starts_with("{{") { + return Err(TemplateParseError::NestedPlaceholder { start }); + } + if rest.starts_with("}}") { + let placeholder = source[placeholder_start..cursor].trim(); + if placeholder.is_empty() { + return Err(TemplateParseError::EmptyPlaceholder { start }); + } + return Ok((placeholder.to_string(), cursor + "}}".len())); + } + + let Some(ch) = rest.chars().next() else { + break; + }; + cursor += ch.len_utf8(); + } + + Err(TemplateParseError::UnterminatedPlaceholder { start }) +} + +fn build_variable_map( + variables: I, +) -> Result, TemplateRenderError> +where + I: IntoIterator, + K: AsRef, + V: AsRef, +{ + let mut map = BTreeMap::new(); + for (name, value) in variables { + let name = name.as_ref().to_string(); + if map + .insert(name.clone(), value.as_ref().to_string()) + .is_some() + { + return Err(TemplateRenderError::DuplicateValue { name }); + } + } + Ok(map) +} + +#[cfg(test)] +mod tests { + use super::Template; + use super::TemplateError; + use super::TemplateParseError; + use super::TemplateRenderError; + use super::render; + use pretty_assertions::assert_eq; + + #[test] + fn render_replaces_placeholders_with_and_without_whitespace() { + let rendered = render( + "Hello, {{ name }}. You are in {{place}}. {{ name }} is repeated.", + [("name", "Codex"), ("place", "codex-rs")], + ) + .unwrap(); + + assert_eq!( + rendered, + "Hello, Codex. You are in codex-rs. Codex is repeated." + ); + } + + #[test] + fn parsed_templates_can_be_reused() { + let template = Template::parse("{{greeting}}, {{ name }}!").unwrap(); + + assert_eq!( + template.render([("greeting", "Hello"), ("name", "Codex")]), + Ok("Hello, Codex!".to_string()) + ); + assert_eq!( + template.render([("greeting", "Hi"), ("name", "builder")]), + Ok("Hi, builder!".to_string()) + ); + } + + #[test] + fn placeholders_are_sorted_and_unique() { + let template = Template::parse("{{ b }} {{ a }} {{ b }}").unwrap(); + + assert_eq!(template.placeholders().collect::>(), vec!["a", "b"]); + } + + #[test] + fn render_supports_multiline_templates_and_adjacent_placeholders() { + let rendered = render( + "Line 1: {{first}}{{second}}\nLine 2: {{ third }}", + [("first", "A"), ("second", "B"), ("third", "C")], + ) + .unwrap(); + + assert_eq!(rendered, "Line 1: AB\nLine 2: C"); + } + + #[test] + fn render_supports_literal_delimiter_escapes() { + let rendered = render( + "literal open: {{{{, literal close: }}}}, value: {{ name }}", + [("name", "Codex")], + ) + .unwrap(); + + assert_eq!( + rendered, + "literal open: {{, literal close: }}, value: Codex" + ); + } + + #[test] + fn parse_errors_when_placeholder_is_empty() { + let err = Template::parse("Hello, {{ }}.").unwrap_err(); + + assert_eq!(err, TemplateParseError::EmptyPlaceholder { start: 7 }); + } + + #[test] + fn parse_errors_when_placeholder_is_unterminated() { + let err = Template::parse("Hello, {{ name.").unwrap_err(); + + assert_eq!( + err, + TemplateParseError::UnterminatedPlaceholder { start: 7 } + ); + } + + #[test] + fn parse_errors_when_placeholder_is_nested() { + let err = Template::parse("Hello, {{ outer {{ inner }} }}.").unwrap_err(); + + assert_eq!(err, TemplateParseError::NestedPlaceholder { start: 7 }); + } + + #[test] + fn parse_errors_when_closing_delimiter_is_unmatched() { + let err = Template::parse("Hello, }} world.").unwrap_err(); + + assert_eq!( + err, + TemplateParseError::UnmatchedClosingDelimiter { start: 7 } + ); + } + + #[test] + fn render_errors_when_placeholder_is_missing() { + let template = Template::parse("Hello, {{ name }}.").unwrap(); + + assert_eq!( + template.render(Vec::<(&str, &str)>::new()), + Err(TemplateRenderError::MissingValue { + name: "name".to_string() + }) + ); + } + + #[test] + fn render_errors_when_extra_value_is_provided() { + let template = Template::parse("Hello, {{ name }}.").unwrap(); + + assert_eq!( + template.render([("name", "Codex"), ("unused", "extra")]), + Err(TemplateRenderError::ExtraValue { + name: "unused".to_string() + }) + ); + } + + #[test] + fn render_errors_when_duplicate_value_is_provided() { + let template = Template::parse("Hello, {{ name }}.").unwrap(); + + assert_eq!( + template.render([("name", "Codex"), ("name", "other")]), + Err(TemplateRenderError::DuplicateValue { + name: "name".to_string() + }) + ); + } + + #[test] + fn render_function_wraps_parse_errors() { + let err = render("Hello, }} world.", [("name", "Codex")]).unwrap_err(); + + assert_eq!( + err, + TemplateError::Parse(TemplateParseError::UnmatchedClosingDelimiter { start: 7 }) + ); + } + + #[test] + fn render_function_wraps_render_errors() { + let err = render("Hello, {{ name }}.", [("extra", "Codex")]).unwrap_err(); + + assert_eq!( + err, + TemplateError::Render(TemplateRenderError::MissingValue { + name: "name".to_string() + }) + ); + } +} From c6ffe9abab04bd3349ecc49fffc0fbf9551826e6 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 25 Mar 2026 13:14:39 -0300 Subject: [PATCH 32/50] fix(tui): avoid duplicate live reasoning summaries (#15758) ## TL;DR Fix duplicated reasoning summaries in `tui_app_server`. image During live turns, reasoning text is already rendered incrementally from `ReasoningSummaryTextDelta`. When the same reasoning item later arrives via `ItemCompleted`, we should only finalize the reasoning block, not render the same summary again. ## What changed - only replay rendered reasoning summaries from completed `ThreadItem::Reasoning` items - kept live completed reasoning items as finalize-only - added a regression test covering the live streaming + completion path ## Why Without this, the first reasoning summary often appears twice in the transcript when `model_reasoning_summary = "detailed"` and `features.tui_app_server = true`. --- codex-rs/tui_app_server/src/chatwidget.rs | 12 +++-- .../tui_app_server/src/chatwidget/tests.rs | 51 +++++++++++++++++++ 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index 7a636ededc..f3c272d62b 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -5675,13 +5675,15 @@ impl ChatWidget { ThreadItem::Reasoning { summary, content, .. } => { - for delta in summary { - self.on_agent_reasoning_delta(delta); - } - if self.config.show_raw_agent_reasoning { - for delta in content { + if from_replay { + for delta in summary { self.on_agent_reasoning_delta(delta); } + if self.config.show_raw_agent_reasoning { + for delta in content { + self.on_agent_reasoning_delta(delta); + } + } } self.on_agent_reasoning_final(); } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 6eefc52a87..98509303ea 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -47,6 +47,7 @@ use codex_app_server_protocol::PluginMarketplaceEntry; use codex_app_server_protocol::PluginReadResponse; use codex_app_server_protocol::PluginSource; use codex_app_server_protocol::PluginSummary; +use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::ThreadClosedNotification; @@ -5135,6 +5136,56 @@ async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { assert!(rendered.contains("Raw reasoning")); } +#[tokio::test] +async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(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: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + }, + }), + 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, + }), + None, + ); + + chat.handle_server_notification( + ServerNotification::ItemCompleted(ItemCompletedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: AppServerThreadItem::Reasoning { + id: "reasoning-1".to_string(), + summary: vec!["Summary only".to_string()], + content: Vec::new(), + }, + }), + None, + ); + + let rendered = match rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => lines_to_single_string(&cell.transcript_lines(80)), + other => panic!("expected InsertHistoryCell, got {other:?}"), + }; + assert_eq!(rendered.matches("Summary only").count(), 1); +} + #[test] fn rendered_user_message_event_from_inputs_matches_flattened_user_message_shape() { let local_image = PathBuf::from("/tmp/local.png"); From 14c35a16a8a41cc16c5e36c2c4287b7b2db6e975 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:27:32 +0000 Subject: [PATCH 33/50] chore: remove read_file handler (#15773) Co-authored-by: Codex --- codex-rs/core/src/tools/handlers/mod.rs | 2 - codex-rs/core/src/tools/handlers/read_file.rs | 489 ------------------ codex-rs/core/src/tools/spec.rs | 120 ----- 3 files changed, 611 deletions(-) delete mode 100644 codex-rs/core/src/tools/handlers/read_file.rs diff --git a/codex-rs/core/src/tools/handlers/mod.rs b/codex-rs/core/src/tools/handlers/mod.rs index 1e9f48820b..ba0a0eb50d 100644 --- a/codex-rs/core/src/tools/handlers/mod.rs +++ b/codex-rs/core/src/tools/handlers/mod.rs @@ -10,7 +10,6 @@ pub(crate) mod multi_agents; pub(crate) mod multi_agents_common; pub(crate) mod multi_agents_v2; mod plan; -mod read_file; mod request_permissions; mod request_user_input; mod shell; @@ -46,7 +45,6 @@ pub use list_dir::ListDirHandler; pub use mcp::McpHandler; pub use mcp_resource::McpResourceHandler; pub use plan::PlanHandler; -pub use read_file::ReadFileHandler; pub use request_permissions::RequestPermissionsHandler; pub(crate) use request_permissions::request_permissions_tool_description; pub use request_user_input::RequestUserInputHandler; diff --git a/codex-rs/core/src/tools/handlers/read_file.rs b/codex-rs/core/src/tools/handlers/read_file.rs deleted file mode 100644 index b868edf5b9..0000000000 --- a/codex-rs/core/src/tools/handlers/read_file.rs +++ /dev/null @@ -1,489 +0,0 @@ -use std::collections::VecDeque; -use std::path::PathBuf; - -use async_trait::async_trait; -use codex_utils_string::take_bytes_at_char_boundary; -use serde::Deserialize; - -use crate::function_tool::FunctionCallError; -use crate::tools::context::FunctionToolOutput; -use crate::tools::context::ToolInvocation; -use crate::tools::context::ToolPayload; -use crate::tools::handlers::parse_arguments; -use crate::tools::registry::ToolHandler; -use crate::tools::registry::ToolKind; - -pub struct ReadFileHandler; - -const MAX_LINE_LENGTH: usize = 500; -const TAB_WIDTH: usize = 4; - -// TODO(jif) add support for block comments -const COMMENT_PREFIXES: &[&str] = &["#", "//", "--"]; - -/// JSON arguments accepted by the `read_file` tool handler. -#[derive(Deserialize)] -struct ReadFileArgs { - /// Absolute path to the file that will be read. - file_path: String, - /// 1-indexed line number to start reading from; defaults to 1. - #[serde(default = "defaults::offset")] - offset: usize, - /// Maximum number of lines to return; defaults to 2000. - #[serde(default = "defaults::limit")] - limit: usize, - /// Determines whether the handler reads a simple slice or indentation-aware block. - #[serde(default)] - mode: ReadMode, - /// Optional indentation configuration used when `mode` is `Indentation`. - #[serde(default)] - indentation: Option, -} - -#[derive(Deserialize, Default)] -#[serde(rename_all = "snake_case")] -enum ReadMode { - #[default] - Slice, - Indentation, -} -/// Additional configuration for indentation-aware reads. -#[derive(Deserialize, Clone)] -struct IndentationArgs { - /// Optional explicit anchor line; defaults to `offset` when omitted. - #[serde(default)] - anchor_line: Option, - /// Maximum indentation depth to collect; `0` means unlimited. - #[serde(default = "defaults::max_levels")] - max_levels: usize, - /// Whether to include sibling blocks at the same indentation level. - #[serde(default = "defaults::include_siblings")] - include_siblings: bool, - /// Whether to include header lines above the anchor block. This made on a best effort basis. - #[serde(default = "defaults::include_header")] - include_header: bool, - /// Optional hard cap on returned lines; defaults to the global `limit`. - #[serde(default)] - max_lines: Option, -} - -#[derive(Clone, Debug)] -struct LineRecord { - number: usize, - raw: String, - display: String, - indent: usize, -} - -impl LineRecord { - fn trimmed(&self) -> &str { - self.raw.trim_start() - } - - fn is_blank(&self) -> bool { - self.trimmed().is_empty() - } - - fn is_comment(&self) -> bool { - COMMENT_PREFIXES - .iter() - .any(|prefix| self.raw.trim().starts_with(prefix)) - } -} - -#[async_trait] -impl ToolHandler for ReadFileHandler { - type Output = FunctionToolOutput; - - fn kind(&self) -> ToolKind { - ToolKind::Function - } - - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { payload, .. } = invocation; - - let arguments = match payload { - ToolPayload::Function { arguments } => arguments, - _ => { - return Err(FunctionCallError::RespondToModel( - "read_file handler received unsupported payload".to_string(), - )); - } - }; - - let args: ReadFileArgs = parse_arguments(&arguments)?; - - let ReadFileArgs { - file_path, - offset, - limit, - mode, - indentation, - } = args; - - if offset == 0 { - return Err(FunctionCallError::RespondToModel( - "offset must be a 1-indexed line number".to_string(), - )); - } - - if limit == 0 { - return Err(FunctionCallError::RespondToModel( - "limit must be greater than zero".to_string(), - )); - } - - let path = PathBuf::from(&file_path); - if !path.is_absolute() { - return Err(FunctionCallError::RespondToModel( - "file_path must be an absolute path".to_string(), - )); - } - - let collected = match mode { - ReadMode::Slice => slice::read(&path, offset, limit).await?, - ReadMode::Indentation => { - let indentation = indentation.unwrap_or_default(); - indentation::read_block(&path, offset, limit, indentation).await? - } - }; - Ok(FunctionToolOutput::from_text( - collected.join("\n"), - Some(true), - )) - } -} - -mod slice { - use crate::function_tool::FunctionCallError; - use crate::tools::handlers::read_file::format_line; - use std::path::Path; - use tokio::fs::File; - use tokio::io::AsyncBufReadExt; - use tokio::io::BufReader; - - pub async fn read( - path: &Path, - offset: usize, - limit: usize, - ) -> Result, FunctionCallError> { - let file = File::open(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - let mut reader = BufReader::new(file); - let mut collected = Vec::new(); - let mut seen = 0usize; - let mut buffer = Vec::new(); - - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - if bytes_read == 0 { - break; - } - - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { - buffer.pop(); - } - } - - seen += 1; - - if seen < offset { - continue; - } - - if collected.len() == limit { - break; - } - - let formatted = format_line(&buffer); - collected.push(format!("L{seen}: {formatted}")); - - if collected.len() == limit { - break; - } - } - - if seen < offset { - return Err(FunctionCallError::RespondToModel( - "offset exceeds file length".to_string(), - )); - } - - Ok(collected) - } -} - -mod indentation { - use crate::function_tool::FunctionCallError; - use crate::tools::handlers::read_file::IndentationArgs; - use crate::tools::handlers::read_file::LineRecord; - use crate::tools::handlers::read_file::TAB_WIDTH; - use crate::tools::handlers::read_file::format_line; - use crate::tools::handlers::read_file::trim_empty_lines; - use std::collections::VecDeque; - use std::path::Path; - use tokio::fs::File; - use tokio::io::AsyncBufReadExt; - use tokio::io::BufReader; - - pub async fn read_block( - path: &Path, - offset: usize, - limit: usize, - options: IndentationArgs, - ) -> Result, FunctionCallError> { - let anchor_line = options.anchor_line.unwrap_or(offset); - if anchor_line == 0 { - return Err(FunctionCallError::RespondToModel( - "anchor_line must be a 1-indexed line number".to_string(), - )); - } - - let guard_limit = options.max_lines.unwrap_or(limit); - if guard_limit == 0 { - return Err(FunctionCallError::RespondToModel( - "max_lines must be greater than zero".to_string(), - )); - } - - let collected = collect_file_lines(path).await?; - if collected.is_empty() || anchor_line > collected.len() { - return Err(FunctionCallError::RespondToModel( - "anchor_line exceeds file length".to_string(), - )); - } - - let anchor_index = anchor_line - 1; - let effective_indents = compute_effective_indents(&collected); - let anchor_indent = effective_indents[anchor_index]; - - // Compute the min indent - let min_indent = if options.max_levels == 0 { - 0 - } else { - anchor_indent.saturating_sub(options.max_levels * TAB_WIDTH) - }; - - // Cap requested lines by guard_limit and file length - let final_limit = limit.min(guard_limit).min(collected.len()); - - if final_limit == 1 { - return Ok(vec![format!( - "L{}: {}", - collected[anchor_index].number, collected[anchor_index].display - )]); - } - - // Cursors - let mut i: isize = anchor_index as isize - 1; // up (inclusive) - let mut j: usize = anchor_index + 1; // down (inclusive) - let mut i_counter_min_indent = 0; - let mut j_counter_min_indent = 0; - - let mut out = VecDeque::with_capacity(limit); - out.push_back(&collected[anchor_index]); - - while out.len() < final_limit { - let mut progressed = 0; - - // Up. - if i >= 0 { - let iu = i as usize; - if effective_indents[iu] >= min_indent { - out.push_front(&collected[iu]); - progressed += 1; - i -= 1; - - // We do not include the siblings (not applied to comments). - if effective_indents[iu] == min_indent && !options.include_siblings { - let allow_header_comment = - options.include_header && collected[iu].is_comment(); - let can_take_line = allow_header_comment || i_counter_min_indent == 0; - - if can_take_line { - i_counter_min_indent += 1; - } else { - // This line shouldn't have been taken. - out.pop_front(); - progressed -= 1; - i = -1; // consider using Option or a control flag instead of a sentinel - } - } - - // Short-cut. - if out.len() >= final_limit { - break; - } - } else { - // Stop moving up. - i = -1; - } - } - - // Down. - if j < collected.len() { - let ju = j; - if effective_indents[ju] >= min_indent { - out.push_back(&collected[ju]); - progressed += 1; - j += 1; - - // We do not include the siblings (applied to comments). - if effective_indents[ju] == min_indent && !options.include_siblings { - if j_counter_min_indent > 0 { - // This line shouldn't have been taken. - out.pop_back(); - progressed -= 1; - j = collected.len(); - } - j_counter_min_indent += 1; - } - } else { - // Stop moving down. - j = collected.len(); - } - } - - if progressed == 0 { - break; - } - } - - // Trim empty lines - trim_empty_lines(&mut out); - - Ok(out - .into_iter() - .map(|record| format!("L{}: {}", record.number, record.display)) - .collect()) - } - - async fn collect_file_lines(path: &Path) -> Result, FunctionCallError> { - let file = File::open(path).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - let mut reader = BufReader::new(file); - let mut buffer = Vec::new(); - let mut lines = Vec::new(); - let mut number = 0usize; - - loop { - buffer.clear(); - let bytes_read = reader.read_until(b'\n', &mut buffer).await.map_err(|err| { - FunctionCallError::RespondToModel(format!("failed to read file: {err}")) - })?; - - if bytes_read == 0 { - break; - } - - if buffer.last() == Some(&b'\n') { - buffer.pop(); - if buffer.last() == Some(&b'\r') { - buffer.pop(); - } - } - - number += 1; - let raw = String::from_utf8_lossy(&buffer).into_owned(); - let indent = measure_indent(&raw); - let display = format_line(&buffer); - lines.push(LineRecord { - number, - raw, - display, - indent, - }); - } - - Ok(lines) - } - - fn compute_effective_indents(records: &[LineRecord]) -> Vec { - let mut effective = Vec::with_capacity(records.len()); - let mut previous_indent = 0usize; - for record in records { - if record.is_blank() { - effective.push(previous_indent); - } else { - previous_indent = record.indent; - effective.push(previous_indent); - } - } - effective - } - - fn measure_indent(line: &str) -> usize { - line.chars() - .take_while(|c| matches!(c, ' ' | '\t')) - .map(|c| if c == '\t' { TAB_WIDTH } else { 1 }) - .sum() - } -} - -fn format_line(bytes: &[u8]) -> String { - let decoded = String::from_utf8_lossy(bytes); - if decoded.len() > MAX_LINE_LENGTH { - take_bytes_at_char_boundary(&decoded, MAX_LINE_LENGTH).to_string() - } else { - decoded.into_owned() - } -} - -fn trim_empty_lines(out: &mut VecDeque<&LineRecord>) { - while matches!(out.front(), Some(line) if line.raw.trim().is_empty()) { - out.pop_front(); - } - while matches!(out.back(), Some(line) if line.raw.trim().is_empty()) { - out.pop_back(); - } -} - -mod defaults { - use super::*; - - impl Default for IndentationArgs { - fn default() -> Self { - Self { - anchor_line: None, - max_levels: max_levels(), - include_siblings: include_siblings(), - include_header: include_header(), - max_lines: None, - } - } - } - - pub fn offset() -> usize { - 1 - } - - pub fn limit() -> usize { - 2000 - } - - pub fn max_levels() -> usize { - 0 - } - - pub fn include_siblings() -> bool { - false - } - - pub fn include_header() -> bool { - true - } -} - -#[cfg(test)] -#[path = "read_file_tests.rs"] -mod tests; diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index d16b2567ac..120493dcbb 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -2046,111 +2046,6 @@ fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String { } } -fn create_read_file_tool() -> ToolSpec { - let indentation_properties = BTreeMap::from([ - ( - "anchor_line".to_string(), - JsonSchema::Number { - description: Some( - "Anchor line to center the indentation lookup on (defaults to offset)." - .to_string(), - ), - }, - ), - ( - "max_levels".to_string(), - JsonSchema::Number { - description: Some( - "How many parent indentation levels (smaller indents) to include.".to_string(), - ), - }, - ), - ( - "include_siblings".to_string(), - JsonSchema::Boolean { - description: Some( - "When true, include additional blocks that share the anchor indentation." - .to_string(), - ), - }, - ), - ( - "include_header".to_string(), - JsonSchema::Boolean { - description: Some( - "Include doc comments or attributes directly above the selected block." - .to_string(), - ), - }, - ), - ( - "max_lines".to_string(), - JsonSchema::Number { - description: Some( - "Hard cap on the number of lines returned when using indentation mode." - .to_string(), - ), - }, - ), - ]); - - let properties = BTreeMap::from([ - ( - "file_path".to_string(), - JsonSchema::String { - description: Some("Absolute path to the file".to_string()), - }, - ), - ( - "offset".to_string(), - JsonSchema::Number { - description: Some( - "The line number to start reading from. Must be 1 or greater.".to_string(), - ), - }, - ), - ( - "limit".to_string(), - JsonSchema::Number { - description: Some("The maximum number of lines to return.".to_string()), - }, - ), - ( - "mode".to_string(), - JsonSchema::String { - description: Some( - "Optional mode selector: \"slice\" for simple ranges (default) or \"indentation\" \ - to expand around an anchor line." - .to_string(), - ), - }, - ), - ( - "indentation".to_string(), - JsonSchema::Object { - properties: indentation_properties, - required: None, - additional_properties: Some(false.into()), - }, - ), - ]); - - ToolSpec::Function(ResponsesApiTool { - name: "read_file".to_string(), - description: - "Reads a local file with 1-indexed line numbers, supporting slice and indentation-aware block modes." - .to_string(), - strict: false, - defer_loading: None, - parameters: JsonSchema::Object { - properties, - required: Some(vec!["file_path".to_string()]), - additional_properties: Some(false.into()), - }, - output_schema: None, - }) -} - fn create_list_dir_tool() -> ToolSpec { let properties = BTreeMap::from([ ( @@ -2704,7 +2599,6 @@ pub(crate) fn build_specs_with_discoverable_tools( use crate::tools::handlers::McpHandler; use crate::tools::handlers::McpResourceHandler; use crate::tools::handlers::PlanHandler; - use crate::tools::handlers::ReadFileHandler; use crate::tools::handlers::RequestPermissionsHandler; use crate::tools::handlers::RequestUserInputHandler; use crate::tools::handlers::ShellCommandHandler; @@ -2975,20 +2869,6 @@ pub(crate) fn build_specs_with_discoverable_tools( builder.register_handler("apply_patch", apply_patch_handler); } - if config - .experimental_supported_tools - .contains(&"read_file".to_string()) - { - let read_file_handler = Arc::new(ReadFileHandler); - push_tool_spec( - &mut builder, - create_read_file_tool(), - /*supports_parallel_tool_calls*/ true, - config.code_mode_enabled, - ); - builder.register_handler("read_file", read_file_handler); - } - if config .experimental_supported_tools .iter() From 303d0190c5f3818146c917e58ece725178e49ebf Mon Sep 17 00:00:00 2001 From: jif-oai Date: Wed, 25 Mar 2026 16:30:04 +0000 Subject: [PATCH 34/50] feat: add multi-thread log query (#15776) Required for multi-agent v2 --- codex-rs/state/src/runtime/logs.rs | 189 ++++++++++++++++++++++++++--- 1 file changed, 171 insertions(+), 18 deletions(-) diff --git a/codex-rs/state/src/runtime/logs.rs b/codex-rs/state/src/runtime/logs.rs index 6c6009e96b..b77678fc07 100644 --- a/codex-rs/state/src/runtime/logs.rs +++ b/codex-rs/state/src/runtime/logs.rs @@ -313,28 +313,46 @@ WHERE id IN ( Ok(rows) } - /// Query per-thread feedback logs, capped to the per-thread SQLite retention budget. - pub async fn query_feedback_logs(&self, thread_id: &str) -> anyhow::Result> { + /// Query feedback logs for a set of threads, capped to the SQLite retention budget. + pub async fn query_feedback_logs_for_threads( + &self, + thread_ids: &[&str], + ) -> anyhow::Result> { + if thread_ids.is_empty() { + return Ok(Vec::new()); + } + let max_bytes = usize::try_from(LOG_PARTITION_SIZE_LIMIT_BYTES).unwrap_or(usize::MAX); // Bound the fetched rows in SQL first so over-retained partitions do not have to load // every row into memory, then apply the exact whole-line byte cap after formatting. - let rows = sqlx::query_as::<_, FeedbackLogRow>( + let requested_threads = vec!["(?)"; thread_ids.len()].join(", "); + let query = format!( r#" -WITH latest_process AS ( - SELECT process_uuid - FROM logs - WHERE thread_id = ? AND process_uuid IS NOT NULL - ORDER BY ts DESC, ts_nanos DESC, id DESC - LIMIT 1 +WITH requested_threads(thread_id) AS ( + VALUES {requested_threads} +), +latest_processes AS ( + SELECT ( + SELECT process_uuid + FROM logs + WHERE logs.thread_id = requested_threads.thread_id AND process_uuid IS NOT NULL + ORDER BY ts DESC, ts_nanos DESC, id DESC + LIMIT 1 + ) AS process_uuid + FROM requested_threads ), feedback_logs AS ( SELECT ts, ts_nanos, level, feedback_log_body, estimated_bytes, id FROM logs WHERE feedback_log_body IS NOT NULL AND ( - thread_id = ? + thread_id IN (SELECT thread_id FROM requested_threads) OR ( thread_id IS NULL - AND process_uuid IN (SELECT process_uuid FROM latest_process) + AND process_uuid IN ( + SELECT process_uuid + FROM latest_processes + WHERE process_uuid IS NOT NULL + ) ) ) ), @@ -354,13 +372,16 @@ SELECT ts, ts_nanos, level, feedback_log_body FROM bounded_feedback_logs WHERE cumulative_estimated_bytes <= ? ORDER BY ts DESC, ts_nanos DESC, id DESC -"#, - ) - .bind(thread_id) - .bind(thread_id) - .bind(LOG_PARTITION_SIZE_LIMIT_BYTES) - .fetch_all(self.logs_pool.as_ref()) - .await?; +"# + ); + let mut sql = sqlx::query_as::<_, FeedbackLogRow>(query.as_str()); + for thread_id in thread_ids { + sql = sql.bind(thread_id); + } + let rows = sql + .bind(LOG_PARTITION_SIZE_LIMIT_BYTES) + .fetch_all(self.logs_pool.as_ref()) + .await?; let mut lines = Vec::new(); let mut total_bytes = 0usize; @@ -382,6 +403,11 @@ ORDER BY ts DESC, ts_nanos DESC, id DESC Ok(ordered_bytes) } + /// Query per-thread feedback logs, capped to the per-thread SQLite retention budget. + pub async fn query_feedback_logs(&self, thread_id: &str) -> anyhow::Result> { + self.query_feedback_logs_for_threads(&[thread_id]).await + } + /// Return the max log id matching optional filters. pub async fn max_log_id(&self, query: &LogQuery) -> anyhow::Result { let mut builder = @@ -1521,4 +1547,131 @@ mod tests { let _ = tokio::fs::remove_dir_all(codex_home).await; } + + #[tokio::test] + async fn query_feedback_logs_for_threads_merges_requested_threads_and_threadless_rows() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + runtime + .insert_logs(&[ + LogEntry { + ts: 1, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("thread-1".to_string()), + feedback_log_body: None, + thread_id: Some("thread-1".to_string()), + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 2, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("thread-2".to_string()), + feedback_log_body: None, + thread_id: Some("thread-2".to_string()), + process_uuid: Some("proc-2".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 3, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-proc-1".to_string()), + feedback_log_body: None, + thread_id: None, + process_uuid: Some("proc-1".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 4, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-proc-2".to_string()), + feedback_log_body: None, + thread_id: None, + process_uuid: Some("proc-2".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 5, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("thread-3".to_string()), + feedback_log_body: None, + thread_id: Some("thread-3".to_string()), + process_uuid: Some("proc-3".to_string()), + file: None, + line: None, + module_path: None, + }, + LogEntry { + ts: 6, + ts_nanos: 0, + level: "INFO".to_string(), + target: "cli".to_string(), + message: Some("threadless-proc-3".to_string()), + feedback_log_body: None, + thread_id: None, + process_uuid: Some("proc-3".to_string()), + file: None, + line: None, + module_path: None, + }, + ]) + .await + .expect("insert test logs"); + + let bytes = runtime + .query_feedback_logs_for_threads(&["thread-1", "thread-2"]) + .await + .expect("query feedback logs"); + + assert_eq!( + String::from_utf8(bytes).expect("valid utf-8"), + [ + format_feedback_log_line(1, 0, "INFO", "thread-1"), + format_feedback_log_line(2, 0, "INFO", "thread-2"), + format_feedback_log_line(3, 0, "INFO", "threadless-proc-1"), + format_feedback_log_line(4, 0, "INFO", "threadless-proc-2"), + ] + .concat() + ); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + + #[tokio::test] + async fn query_feedback_logs_for_threads_returns_empty_for_empty_thread_list() { + let codex_home = unique_temp_dir(); + let runtime = StateRuntime::init(codex_home.clone(), "test-provider".to_string()) + .await + .expect("initialize runtime"); + + let bytes = runtime + .query_feedback_logs_for_threads(&[]) + .await + .expect("query feedback logs"); + + assert_eq!(bytes, Vec::::new()); + + let _ = tokio::fs::remove_dir_all(codex_home).await; + } } From 74b8b800c76e5c1b2deeb48a1d6b23e1a6d9b0ea Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 25 Mar 2026 09:59:56 -0700 Subject: [PATCH 35/50] session source update --- codex-rs/core/src/analytics_client.rs | 22 ++++------ codex-rs/core/src/analytics_client_tests.rs | 43 +++++++++++++++++--- codex-rs/core/tests/suite/thread_metadata.rs | 4 +- 3 files changed, 49 insertions(+), 20 deletions(-) diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index a6b2d20fc2..de1a657dc8 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -49,7 +49,7 @@ pub(crate) struct CodexThreadStartedEvent { pub(crate) personality: Option, pub(crate) ephemeral: bool, pub(crate) session_source: SessionSource, - pub(crate) initial_history_type: InitialHistoryType, + pub(crate) initialization_mode: InitializationMode, pub(crate) subagent_source: Option, pub(crate) parent_thread_id: Option, pub(crate) created_at: u64, @@ -57,7 +57,7 @@ pub(crate) struct CodexThreadStartedEvent { #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "snake_case")] -pub(crate) enum InitialHistoryType { +pub(crate) enum InitializationMode { New, Forked, Resumed, @@ -392,8 +392,8 @@ struct CodexThreadStartedEventParams { collaboration_mode: &'static str, personality: Option, ephemeral: bool, - session_source: String, - initial_history_type: InitialHistoryType, + session_source: Option<&'static str>, + initialization_mode: InitializationMode, subagent_source: Option, parent_thread_id: Option, created_at: u64, @@ -772,7 +772,7 @@ fn codex_thread_started_event_params( personality: thread_event.personality.map(|value| value.to_string()), ephemeral: thread_event.ephemeral, session_source: session_source_name(&thread_event.session_source), - initial_history_type: thread_event.initial_history_type, + initialization_mode: thread_event.initialization_mode, subagent_source: thread_event.subagent_source.map(subagent_source_name), parent_thread_id: thread_event.parent_thread_id, created_at: thread_event.created_at, @@ -832,15 +832,11 @@ fn collaboration_mode_mode(mode: ModeKind) -> &'static str { } } -fn session_source_name(session_source: &SessionSource) -> String { +fn session_source_name(session_source: &SessionSource) -> Option<&'static str> { match session_source { - SessionSource::Cli => "cli".to_string(), - SessionSource::VSCode => "vscode".to_string(), - SessionSource::Exec => "exec".to_string(), - SessionSource::Mcp => "mcp".to_string(), - SessionSource::Custom(source) => source.clone(), - SessionSource::SubAgent(_) => "subagent".to_string(), - SessionSource::Unknown => "unknown".to_string(), + SessionSource::Cli | SessionSource::VSCode | SessionSource::Exec => Some("user"), + SessionSource::SubAgent(_) => Some("subagent"), + SessionSource::Mcp | SessionSource::Custom(_) | SessionSource::Unknown => None, } } diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/core/src/analytics_client_tests.rs index 587f12a726..e738918852 100644 --- a/codex-rs/core/src/analytics_client_tests.rs +++ b/codex-rs/core/src/analytics_client_tests.rs @@ -6,7 +6,7 @@ use super::CodexPluginEventRequest; use super::CodexPluginUsedEventRequest; use super::CodexThreadStartedEvent; use super::CodexThreadStartedEventRequest; -use super::InitialHistoryType; +use super::InitializationMode; use super::InvocationType; use super::TrackEventRequest; use super::TrackEventsContext; @@ -218,7 +218,7 @@ fn thread_started_event_serializes_expected_shape() { personality: Some(Personality::Friendly), ephemeral: true, session_source: SessionSource::Exec, - initial_history_type: InitialHistoryType::New, + initialization_mode: InitializationMode::New, subagent_source: None, parent_thread_id: None, created_at: 1_716_000_000, @@ -246,8 +246,8 @@ fn thread_started_event_serializes_expected_shape() { "collaboration_mode": "plan", "personality": "friendly", "ephemeral": true, - "session_source": "exec", - "initial_history_type": "new", + "session_source": "user", + "initialization_mode": "new", "subagent_source": null, "parent_thread_id": null, "created_at": 1716000000 @@ -275,7 +275,7 @@ fn thread_started_event_serializes_subagent_source() { personality: None, ephemeral: false, session_source: SessionSource::SubAgent(SubAgentSource::Review), - initial_history_type: InitialHistoryType::New, + initialization_mode: InitializationMode::New, subagent_source: Some(SubAgentSource::Review), parent_thread_id: None, created_at: 1, @@ -287,6 +287,39 @@ fn thread_started_event_serializes_subagent_source() { assert_eq!(payload["event_params"]["subagent_source"], "review"); } +#[test] +fn thread_started_event_omits_non_user_non_subagent_session_source() { + let event = TrackEventRequest::ThreadStarted(CodexThreadStartedEventRequest { + event_type: "codex_thread_started", + event_params: codex_thread_started_event_params(CodexThreadStartedEvent { + thread_id: "thread-2".to_string(), + model: "gpt-5".to_string(), + model_provider: "openai".to_string(), + reasoning_effort: None, + reasoning_summary: None, + service_tier: None, + approval_policy: AskForApproval::OnRequest, + approvals_reviewer: ApprovalsReviewer::User, + sandbox_policy: SandboxPolicy::new_read_only_policy(), + sandbox_network_access: false, + collaboration_mode: ModeKind::Default, + personality: None, + ephemeral: false, + session_source: SessionSource::Mcp, + initialization_mode: InitializationMode::New, + subagent_source: None, + parent_thread_id: None, + created_at: 1, + }), + }); + + let payload = serde_json::to_value(&event).expect("serialize mcp thread started event"); + assert_eq!( + payload["event_params"]["session_source"], + serde_json::Value::Null + ); +} + #[test] fn plugin_used_event_serializes_expected_shape() { let tracking = TrackEventsContext { diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/thread_metadata.rs index 397039e7b2..1b46a5a457 100644 --- a/codex-rs/core/tests/suite/thread_metadata.rs +++ b/codex-rs/core/tests/suite/thread_metadata.rs @@ -81,8 +81,8 @@ async fn thread_start_tracks_thread_started_analytics() -> Result<()> { assert_eq!(event["event_params"]["collaboration_mode"], "default"); assert_eq!(event["event_params"]["personality"], "friendly"); assert_eq!(event["event_params"]["ephemeral"], true); - assert_eq!(event["event_params"]["session_source"], "exec"); - assert_eq!(event["event_params"]["initial_history_type"], "new"); + assert_eq!(event["event_params"]["session_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "new"); assert_eq!( event["event_params"]["subagent_source"], serde_json::Value::Null From 5362886490962e80c735b2761db8547f84255312 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 25 Mar 2026 10:27:05 -0700 Subject: [PATCH 36/50] event rename and resume support --- codex-rs/core/src/analytics_client.rs | 50 ++++----- codex-rs/core/src/analytics_client_tests.rs | 39 +++---- codex-rs/core/src/codex.rs | 99 +++++++++--------- codex-rs/core/tests/suite/thread_metadata.rs | 103 ++++++++++++++++++- 4 files changed, 194 insertions(+), 97 deletions(-) diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/core/src/analytics_client.rs index de1a657dc8..c930144161 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/core/src/analytics_client.rs @@ -34,7 +34,7 @@ pub(crate) struct TrackEventsContext { } #[derive(Clone)] -pub(crate) struct CodexThreadStartedEvent { +pub(crate) struct CodexThreadInitializedEvent { pub(crate) thread_id: String, pub(crate) model: String, pub(crate) model_provider: String, @@ -118,8 +118,8 @@ impl AnalyticsEventsQueue { TrackEventsJob::SkillInvocations(job) => { send_track_skill_invocations(&auth_manager, job).await; } - TrackEventsJob::ThreadStarted(job) => { - send_track_thread_started(&auth_manager, job).await; + TrackEventsJob::ThreadInitialized(job) => { + send_track_thread_initialized(&auth_manager, job).await; } TrackEventsJob::AppMentioned(job) => { send_track_app_mentioned(&auth_manager, job).await; @@ -210,8 +210,8 @@ impl AnalyticsEventsClient { ); } - pub(crate) fn track_thread_started(&self, thread_event: CodexThreadStartedEvent) { - track_thread_started(&self.queue, Arc::clone(&self.config), thread_event); + pub(crate) fn track_thread_initialized(&self, thread_event: CodexThreadInitializedEvent) { + track_thread_initialized(&self.queue, Arc::clone(&self.config), thread_event); } pub(crate) fn track_app_mentioned( @@ -283,7 +283,7 @@ impl AnalyticsEventsClient { enum TrackEventsJob { SkillInvocations(TrackSkillInvocationsJob), - ThreadStarted(TrackThreadStartedJob), + ThreadInitialized(TrackThreadInitializedJob), AppMentioned(TrackAppMentionedJob), AppUsed(TrackAppUsedJob), PluginUsed(TrackPluginUsedJob), @@ -299,9 +299,9 @@ struct TrackSkillInvocationsJob { invocations: Vec, } -struct TrackThreadStartedJob { +struct TrackThreadInitializedJob { config: Arc, - thread_event: CodexThreadStartedEvent, + thread_event: CodexThreadInitializedEvent, } struct TrackAppMentionedJob { @@ -348,7 +348,7 @@ struct TrackEventsRequest { #[serde(untagged)] enum TrackEventRequest { SkillInvocation(SkillInvocationEventRequest), - ThreadStarted(CodexThreadStartedEventRequest), + ThreadInitialized(CodexThreadInitializedEventRequest), AppMentioned(CodexAppMentionedEventRequest), AppUsed(CodexAppUsedEventRequest), PluginUsed(CodexPluginUsedEventRequest), @@ -377,7 +377,7 @@ struct SkillInvocationEventParams { } #[derive(Serialize)] -struct CodexThreadStartedEventParams { +struct CodexThreadInitializedEventParams { thread_id: String, product_client_id: String, model: String, @@ -400,9 +400,9 @@ struct CodexThreadStartedEventParams { } #[derive(Serialize)] -struct CodexThreadStartedEventRequest { +struct CodexThreadInitializedEventRequest { event_type: &'static str, - event_params: CodexThreadStartedEventParams, + event_params: CodexThreadInitializedEventParams, } #[derive(Serialize)] @@ -483,15 +483,15 @@ pub(crate) fn track_skill_invocations( queue.try_send(job); } -pub(crate) fn track_thread_started( +pub(crate) fn track_thread_initialized( queue: &AnalyticsEventsQueue, config: Arc, - thread_event: CodexThreadStartedEvent, + thread_event: CodexThreadInitializedEvent, ) { if config.analytics_enabled == Some(false) { return; } - let job = TrackEventsJob::ThreadStarted(TrackThreadStartedJob { + let job = TrackEventsJob::ThreadInitialized(TrackThreadInitializedJob { config, thread_event, }); @@ -634,15 +634,15 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil send_track_events(auth_manager, config, events).await; } -async fn send_track_thread_started(auth_manager: &AuthManager, job: TrackThreadStartedJob) { - let TrackThreadStartedJob { +async fn send_track_thread_initialized(auth_manager: &AuthManager, job: TrackThreadInitializedJob) { + let TrackThreadInitializedJob { config, thread_event, } = job; - let events = vec![TrackEventRequest::ThreadStarted( - CodexThreadStartedEventRequest { - event_type: "codex_thread_started", - event_params: codex_thread_started_event_params(thread_event), + let events = vec![TrackEventRequest::ThreadInitialized( + CodexThreadInitializedEventRequest { + event_type: "codex_thread_initialized", + event_params: codex_thread_initialized_event_params(thread_event), }, )]; @@ -748,10 +748,10 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code } } -fn codex_thread_started_event_params( - thread_event: CodexThreadStartedEvent, -) -> CodexThreadStartedEventParams { - CodexThreadStartedEventParams { +fn codex_thread_initialized_event_params( + thread_event: CodexThreadInitializedEvent, +) -> CodexThreadInitializedEventParams { + CodexThreadInitializedEventParams { thread_id: thread_event.thread_id, product_client_id: crate::default_client::originator().value, model: thread_event.model, diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/core/src/analytics_client_tests.rs index e738918852..405f3e7706 100644 --- a/codex-rs/core/src/analytics_client_tests.rs +++ b/codex-rs/core/src/analytics_client_tests.rs @@ -4,8 +4,8 @@ use super::CodexAppMentionedEventRequest; use super::CodexAppUsedEventRequest; use super::CodexPluginEventRequest; use super::CodexPluginUsedEventRequest; -use super::CodexThreadStartedEvent; -use super::CodexThreadStartedEventRequest; +use super::CodexThreadInitializedEvent; +use super::CodexThreadInitializedEventRequest; use super::InitializationMode; use super::InvocationType; use super::TrackEventRequest; @@ -13,7 +13,7 @@ use super::TrackEventsContext; use super::codex_app_metadata; use super::codex_plugin_metadata; use super::codex_plugin_used_metadata; -use super::codex_thread_started_event_params; +use super::codex_thread_initialized_event_params; use super::normalize_path_for_skill_id; use crate::plugins::AppConnectorId; use crate::plugins::PluginCapabilitySummary; @@ -200,10 +200,10 @@ fn app_used_dedupe_is_keyed_by_turn_and_connector() { } #[test] -fn thread_started_event_serializes_expected_shape() { - let event = TrackEventRequest::ThreadStarted(CodexThreadStartedEventRequest { - event_type: "codex_thread_started", - event_params: codex_thread_started_event_params(CodexThreadStartedEvent { +fn thread_initialized_event_serializes_expected_shape() { + let event = TrackEventRequest::ThreadInitialized(CodexThreadInitializedEventRequest { + event_type: "codex_thread_initialized", + event_params: codex_thread_initialized_event_params(CodexThreadInitializedEvent { thread_id: "thread-0".to_string(), model: "gpt-5".to_string(), model_provider: "openai".to_string(), @@ -225,12 +225,12 @@ fn thread_started_event_serializes_expected_shape() { }), }); - let payload = serde_json::to_value(&event).expect("serialize thread started event"); + let payload = serde_json::to_value(&event).expect("serialize thread initialized event"); assert_eq!( payload, json!({ - "event_type": "codex_thread_started", + "event_type": "codex_thread_initialized", "event_params": { "thread_id": "thread-0", "product_client_id": crate::default_client::originator().value, @@ -257,10 +257,10 @@ fn thread_started_event_serializes_expected_shape() { } #[test] -fn thread_started_event_serializes_subagent_source() { - let event = TrackEventRequest::ThreadStarted(CodexThreadStartedEventRequest { - event_type: "codex_thread_started", - event_params: codex_thread_started_event_params(CodexThreadStartedEvent { +fn thread_initialized_event_serializes_subagent_source() { + let event = TrackEventRequest::ThreadInitialized(CodexThreadInitializedEventRequest { + event_type: "codex_thread_initialized", + event_params: codex_thread_initialized_event_params(CodexThreadInitializedEvent { thread_id: "thread-1".to_string(), model: "gpt-5".to_string(), model_provider: "openai".to_string(), @@ -282,16 +282,17 @@ fn thread_started_event_serializes_subagent_source() { }), }); - let payload = serde_json::to_value(&event).expect("serialize subagent thread started event"); + let payload = + serde_json::to_value(&event).expect("serialize subagent thread initialized event"); assert_eq!(payload["event_params"]["session_source"], "subagent"); assert_eq!(payload["event_params"]["subagent_source"], "review"); } #[test] -fn thread_started_event_omits_non_user_non_subagent_session_source() { - let event = TrackEventRequest::ThreadStarted(CodexThreadStartedEventRequest { - event_type: "codex_thread_started", - event_params: codex_thread_started_event_params(CodexThreadStartedEvent { +fn thread_initialized_event_omits_non_user_non_subagent_session_source() { + let event = TrackEventRequest::ThreadInitialized(CodexThreadInitializedEventRequest { + event_type: "codex_thread_initialized", + event_params: codex_thread_initialized_event_params(CodexThreadInitializedEvent { thread_id: "thread-2".to_string(), model: "gpt-5".to_string(), model_provider: "openai".to_string(), @@ -313,7 +314,7 @@ fn thread_started_event_omits_non_user_non_subagent_session_source() { }), }); - let payload = serde_json::to_value(&event).expect("serialize mcp thread started event"); + let payload = serde_json::to_value(&event).expect("serialize mcp thread initialized event"); assert_eq!( payload["event_params"]["session_source"], serde_json::Value::Null diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 4a3b752ae5..cc54b00269 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -16,8 +16,8 @@ use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; use crate::analytics_client::AnalyticsEventsClient; use crate::analytics_client::AppInvocation; -use crate::analytics_client::CodexThreadStartedEvent; -use crate::analytics_client::InitialHistoryType; +use crate::analytics_client::CodexThreadInitializedEvent; +use crate::analytics_client::InitializationMode; use crate::analytics_client::InvocationType; use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; @@ -631,11 +631,9 @@ impl Codex { user_shell_override, }; - let should_emit_thread_started = - !matches!(conversation_history, InitialHistory::Resumed(_)); - let initial_history_type = initial_history_type(&conversation_history); + let initialization_mode = initialization_mode(&conversation_history); let thread_session_source = session_configuration.session_source.clone(); - let thread_started_configuration = session_configuration.clone(); + let thread_initialized_configuration = session_configuration.clone(); // Generate a unique ID for the lifetime of this Codex session. let session_source_clone = session_configuration.session_source.clone(); @@ -664,46 +662,47 @@ impl Codex { })?; let thread_id = session.conversation_id; - if should_emit_thread_started { - session - .services - .analytics_events_client - .track_thread_started(CodexThreadStartedEvent { - thread_id: thread_id.to_string(), - model: thread_started_configuration - .collaboration_mode - .model() - .to_string(), - model_provider: thread_started_configuration - .original_config_do_not_use - .model_provider_id - .clone(), - reasoning_effort: thread_started_configuration - .collaboration_mode - .reasoning_effort(), - reasoning_summary: thread_started_configuration.model_reasoning_summary, - service_tier: thread_started_configuration.service_tier, - approval_policy: thread_started_configuration.approval_policy.value(), - approvals_reviewer: thread_started_configuration.approvals_reviewer, - sandbox_policy: thread_started_configuration.sandbox_policy.get().clone(), - sandbox_network_access: thread_started_configuration - .network_sandbox_policy - .is_enabled(), - collaboration_mode: thread_started_configuration.collaboration_mode.mode, - personality: thread_started_configuration.personality, - ephemeral: thread_started_configuration - .original_config_do_not_use - .ephemeral, - initial_history_type, - subagent_source: session_source_subagent_source(&thread_session_source), - parent_thread_id: session_source_parent_thread_id(&thread_session_source), - session_source: thread_session_source, - created_at: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(), - }); - } + session + .services + .analytics_events_client + .track_thread_initialized(CodexThreadInitializedEvent { + thread_id: thread_id.to_string(), + model: thread_initialized_configuration + .collaboration_mode + .model() + .to_string(), + model_provider: thread_initialized_configuration + .original_config_do_not_use + .model_provider_id + .clone(), + reasoning_effort: thread_initialized_configuration + .collaboration_mode + .reasoning_effort(), + reasoning_summary: thread_initialized_configuration.model_reasoning_summary, + service_tier: thread_initialized_configuration.service_tier, + approval_policy: thread_initialized_configuration.approval_policy.value(), + approvals_reviewer: thread_initialized_configuration.approvals_reviewer, + sandbox_policy: thread_initialized_configuration + .sandbox_policy + .get() + .clone(), + sandbox_network_access: thread_initialized_configuration + .network_sandbox_policy + .is_enabled(), + collaboration_mode: thread_initialized_configuration.collaboration_mode.mode, + personality: thread_initialized_configuration.personality, + ephemeral: thread_initialized_configuration + .original_config_do_not_use + .ephemeral, + initialization_mode, + subagent_source: session_source_subagent_source(&thread_session_source), + parent_thread_id: session_source_parent_thread_id(&thread_session_source), + session_source: thread_session_source, + created_at: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }); // This task will run until Op::Shutdown is received. let session_for_loop = Arc::clone(&session); @@ -819,11 +818,11 @@ impl Codex { } } -fn initial_history_type(conversation_history: &InitialHistory) -> InitialHistoryType { +fn initialization_mode(conversation_history: &InitialHistory) -> InitializationMode { match conversation_history { - InitialHistory::New => InitialHistoryType::New, - InitialHistory::Forked(_) => InitialHistoryType::Forked, - InitialHistory::Resumed(_) => InitialHistoryType::Resumed, + InitialHistory::New => InitializationMode::New, + InitialHistory::Forked(_) => InitializationMode::Forked, + InitialHistory::Resumed(_) => InitializationMode::Resumed, } } diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/thread_metadata.rs index 1b46a5a457..036f732c71 100644 --- a/codex-rs/core/tests/suite/thread_metadata.rs +++ b/codex-rs/core/tests/suite/thread_metadata.rs @@ -1,6 +1,7 @@ use anyhow::Result; use codex_core::CodexAuth; use codex_core::config::Constrained; +use codex_core::find_thread_path_by_id_str; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; @@ -12,7 +13,7 @@ use std::time::Duration; use std::time::Instant; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn thread_start_tracks_thread_started_analytics() -> Result<()> { +async fn thread_initialization_tracks_thread_initialized_analytics() -> Result<()> { let server = start_mock_server().await; let chatgpt_base_url = server.uri(); @@ -55,8 +56,8 @@ async fn thread_start_tracks_thread_started_analytics() -> Result<()> { .as_array() .expect("events array") .iter() - .find(|event| event["event_type"] == "codex_thread_started") - .expect("codex_thread_started event should be present"); + .find(|event| event["event_type"] == "codex_thread_initialized") + .expect("codex_thread_initialized event should be present"); assert_eq!( event["event_params"]["thread_id"], @@ -99,3 +100,99 @@ async fn thread_start_tracks_thread_started_analytics() -> Result<()> { Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn resumed_thread_emits_thread_initialized_analytics() -> Result<()> { + let server = start_mock_server().await; + let chatgpt_base_url = server.uri(); + + let initial = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config({ + let chatgpt_base_url = chatgpt_base_url.clone(); + move |config| { + config.chatgpt_base_url = chatgpt_base_url; + config.model = Some("gpt-5".to_string()); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_reasoning_summary = Some(ReasoningSummary::Detailed); + config.service_tier = Some(ServiceTier::Flex); + config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.permissions.sandbox_policy = Constrained::allow_any( + codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(), + ); + config.personality = Some(Personality::Friendly); + config.ephemeral = true; + } + }) + .build(&server) + .await?; + + let rollout_path = find_thread_path_by_id_str( + initial.codex_home_path(), + &initial.session_configured.session_id.to_string(), + ) + .await? + .expect("rollout path for initial thread"); + + let _resumed = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + config.chatgpt_base_url = chatgpt_base_url; + config.model = Some("gpt-5".to_string()); + config.model_reasoning_effort = Some(ReasoningEffort::High); + config.model_reasoning_summary = Some(ReasoningSummary::Detailed); + config.service_tier = Some(ServiceTier::Flex); + config.approvals_reviewer = ApprovalsReviewer::GuardianSubagent; + config.permissions.sandbox_policy = Constrained::allow_any( + codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(), + ); + config.personality = Some(Personality::Friendly); + config.ephemeral = true; + }) + .resume(&server, initial.home.clone(), rollout_path) + .await?; + + let deadline = Instant::now() + Duration::from_secs(10); + let analytics_request = loop { + let requests = server.received_requests().await.unwrap_or_default(); + if let Some(request) = requests.into_iter().find(|request| { + if request.url.path() != "/codex/analytics-events/events" { + return false; + } + let Ok(payload) = serde_json::from_slice::(&request.body) else { + return false; + }; + payload["events"] + .as_array() + .into_iter() + .flatten() + .any(|event| { + event["event_type"] == "codex_thread_initialized" + && event["event_params"]["initialization_mode"] == "resumed" + }) + }) { + break request; + } + if Instant::now() >= deadline { + panic!("timed out waiting for resumed thread analytics request"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + }; + + let payload: serde_json::Value = + serde_json::from_slice(&analytics_request.body).expect("analytics payload"); + let event = payload["events"] + .as_array() + .expect("events array") + .iter() + .find(|event| { + event["event_type"] == "codex_thread_initialized" + && event["event_params"]["initialization_mode"] == "resumed" + }) + .expect("codex_thread_initialized resumed event should be present"); + + assert_eq!(event["event_params"]["session_source"], "user"); + assert_eq!(event["event_params"]["initialization_mode"], "resumed"); + + Ok(()) +} From fba3c79885188b9b88bbceee9e9b872933393aee Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 10:43:49 -0700 Subject: [PATCH 37/50] Extract codex-instructions crate (#15744) ## Summary - extract instruction fragment and user-instruction types into codex-instructions - update codex-core to consume the new crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 10 +++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/contextual_user_message.rs | 61 +------------------ .../core/src/contextual_user_message_tests.rs | 1 + codex-rs/core/src/instructions/mod.rs | 8 +-- codex-rs/instructions/BUILD.bazel | 16 +++++ codex-rs/instructions/Cargo.toml | 20 ++++++ codex-rs/instructions/src/fragment.rs | 61 +++++++++++++++++++ codex-rs/instructions/src/lib.rs | 11 ++++ .../src}/user_instructions.rs | 15 +++-- .../src}/user_instructions_tests.rs | 4 ++ 12 files changed, 139 insertions(+), 71 deletions(-) create mode 100644 codex-rs/instructions/BUILD.bazel create mode 100644 codex-rs/instructions/Cargo.toml create mode 100644 codex-rs/instructions/src/fragment.rs create mode 100644 codex-rs/instructions/src/lib.rs rename codex-rs/{core/src/instructions => instructions/src}/user_instructions.rs (79%) rename codex-rs/{core/src/instructions => instructions/src}/user_instructions_tests.rs (94%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 33244c1185..b57b6164bd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1883,6 +1883,7 @@ dependencies = [ "codex-features", "codex-git-utils", "codex-hooks", + "codex-instructions", "codex-login", "codex-network-proxy", "codex-otel", @@ -2174,6 +2175,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "codex-instructions" +version = "0.0.0" +dependencies = [ + "codex-protocol", + "pretty_assertions", + "serde", +] + [[package]] name = "codex-keyring-store" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 3d2ee99482..126351131f 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -25,6 +25,7 @@ members = [ "skills", "core", "hooks", + "instructions", "secrets", "exec", "exec-server", @@ -123,6 +124,7 @@ codex-features = { path = "features" } codex-file-search = { path = "file-search" } codex-git-utils = { path = "git-utils" } codex-hooks = { path = "hooks" } +codex-instructions = { path = "instructions" } codex-keyring-store = { path = "keyring-store" } codex-linux-sandbox = { path = "linux-sandbox" } codex-lmstudio = { path = "lmstudio" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index a3868e4358..d335dc6cc5 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -42,6 +42,7 @@ codex-skills = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } +codex-instructions = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-artifacts = { workspace = true } diff --git a/codex-rs/core/src/contextual_user_message.rs b/codex-rs/core/src/contextual_user_message.rs index 4df05f0da1..68a86cc885 100644 --- a/codex-rs/core/src/contextual_user_message.rs +++ b/codex-rs/core/src/contextual_user_message.rs @@ -1,14 +1,12 @@ +use codex_instructions::AGENTS_MD_FRAGMENT; +use codex_instructions::ContextualUserFragmentDefinition; +use codex_instructions::SKILL_FRAGMENT; use codex_protocol::items::HookPromptItem; use codex_protocol::items::parse_hook_prompt_fragment; use codex_protocol::models::ContentItem; -use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG; use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG; -pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for "; -pub(crate) const AGENTS_MD_END_MARKER: &str = ""; -pub(crate) const SKILL_OPEN_TAG: &str = ""; -pub(crate) const SKILL_CLOSE_TAG: &str = ""; pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = ""; pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = ""; pub(crate) const TURN_ABORTED_OPEN_TAG: &str = ""; @@ -16,64 +14,11 @@ pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = ""; pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = ""; pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = ""; -#[derive(Clone, Copy)] -pub(crate) struct ContextualUserFragmentDefinition { - start_marker: &'static str, - end_marker: &'static str, -} - -impl ContextualUserFragmentDefinition { - pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self { - Self { - start_marker, - end_marker, - } - } - - pub(crate) fn matches_text(&self, text: &str) -> bool { - let trimmed = text.trim_start(); - let starts_with_marker = trimmed - .get(..self.start_marker.len()) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker)); - let trimmed = trimmed.trim_end(); - let ends_with_marker = trimmed - .get(trimmed.len().saturating_sub(self.end_marker.len())..) - .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker)); - starts_with_marker && ends_with_marker - } - - pub(crate) const fn start_marker(&self) -> &'static str { - self.start_marker - } - - pub(crate) const fn end_marker(&self) -> &'static str { - self.end_marker - } - - pub(crate) fn wrap(&self, body: String) -> String { - format!("{}\n{}\n{}", self.start_marker, body, self.end_marker) - } - - pub(crate) fn into_message(self, text: String) -> ResponseItem { - ResponseItem::Message { - id: None, - role: "user".to_string(), - content: vec![ContentItem::InputText { text }], - end_turn: None, - phase: None, - } - } -} - -pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition = - ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER); pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition = ContextualUserFragmentDefinition::new( ENVIRONMENT_CONTEXT_OPEN_TAG, ENVIRONMENT_CONTEXT_CLOSE_TAG, ); -pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition = - ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG); pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition = ContextualUserFragmentDefinition::new( USER_SHELL_COMMAND_OPEN_TAG, diff --git a/codex-rs/core/src/contextual_user_message_tests.rs b/codex-rs/core/src/contextual_user_message_tests.rs index f71ca35f6e..93db860ea9 100644 --- a/codex-rs/core/src/contextual_user_message_tests.rs +++ b/codex-rs/core/src/contextual_user_message_tests.rs @@ -1,6 +1,7 @@ use super::*; use codex_protocol::items::HookPromptFragment; use codex_protocol::items::build_hook_prompt_message; +use codex_protocol::models::ResponseItem; #[test] fn detects_environment_context_fragment() { diff --git a/codex-rs/core/src/instructions/mod.rs b/codex-rs/core/src/instructions/mod.rs index 9f1d95d2f6..0ef48ff620 100644 --- a/codex-rs/core/src/instructions/mod.rs +++ b/codex-rs/core/src/instructions/mod.rs @@ -1,5 +1,3 @@ -mod user_instructions; - -pub(crate) use user_instructions::SkillInstructions; -pub use user_instructions::USER_INSTRUCTIONS_PREFIX; -pub(crate) use user_instructions::UserInstructions; +pub(crate) use codex_instructions::SkillInstructions; +pub use codex_instructions::USER_INSTRUCTIONS_PREFIX; +pub(crate) use codex_instructions::UserInstructions; diff --git a/codex-rs/instructions/BUILD.bazel b/codex-rs/instructions/BUILD.bazel new file mode 100644 index 0000000000..d3cdbd19d1 --- /dev/null +++ b/codex-rs/instructions/BUILD.bazel @@ -0,0 +1,16 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "instructions", + crate_name = "codex_instructions", + compile_data = glob( + include = ["**"], + exclude = [ + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ) + [ + "//codex-rs:node-version.txt", + ], +) diff --git a/codex-rs/instructions/Cargo.toml b/codex-rs/instructions/Cargo.toml new file mode 100644 index 0000000000..cdaa3d7368 --- /dev/null +++ b/codex-rs/instructions/Cargo.toml @@ -0,0 +1,20 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-instructions" +version.workspace = true + +[lib] +doctest = false +name = "codex_instructions" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/codex-rs/instructions/src/fragment.rs b/codex-rs/instructions/src/fragment.rs new file mode 100644 index 0000000000..590dac379b --- /dev/null +++ b/codex-rs/instructions/src/fragment.rs @@ -0,0 +1,61 @@ +use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; + +pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for "; +pub(crate) const AGENTS_MD_END_MARKER: &str = ""; +pub(crate) const SKILL_OPEN_TAG: &str = ""; +pub(crate) const SKILL_CLOSE_TAG: &str = ""; + +#[derive(Clone, Copy)] +pub struct ContextualUserFragmentDefinition { + start_marker: &'static str, + end_marker: &'static str, +} + +impl ContextualUserFragmentDefinition { + pub const fn new(start_marker: &'static str, end_marker: &'static str) -> Self { + Self { + start_marker, + end_marker, + } + } + + pub fn matches_text(&self, text: &str) -> bool { + let trimmed = text.trim_start(); + let starts_with_marker = trimmed + .get(..self.start_marker.len()) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker)); + let trimmed = trimmed.trim_end(); + let ends_with_marker = trimmed + .get(trimmed.len().saturating_sub(self.end_marker.len())..) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker)); + starts_with_marker && ends_with_marker + } + + pub const fn start_marker(&self) -> &'static str { + self.start_marker + } + + pub const fn end_marker(&self) -> &'static str { + self.end_marker + } + + pub fn wrap(&self, body: String) -> String { + format!("{}\n{}\n{}", self.start_marker, body, self.end_marker) + } + + pub fn into_message(self, text: String) -> ResponseItem { + ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ContentItem::InputText { text }], + end_turn: None, + phase: None, + } + } +} + +pub const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER); +pub const SKILL_FRAGMENT: ContextualUserFragmentDefinition = + ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG); diff --git a/codex-rs/instructions/src/lib.rs b/codex-rs/instructions/src/lib.rs new file mode 100644 index 0000000000..1b1f81550c --- /dev/null +++ b/codex-rs/instructions/src/lib.rs @@ -0,0 +1,11 @@ +//! User and skill instruction payloads and contextual user fragment markers for Codex prompts. + +mod fragment; +mod user_instructions; + +pub use fragment::AGENTS_MD_FRAGMENT; +pub use fragment::ContextualUserFragmentDefinition; +pub use fragment::SKILL_FRAGMENT; +pub use user_instructions::SkillInstructions; +pub use user_instructions::USER_INSTRUCTIONS_PREFIX; +pub use user_instructions::UserInstructions; diff --git a/codex-rs/core/src/instructions/user_instructions.rs b/codex-rs/instructions/src/user_instructions.rs similarity index 79% rename from codex-rs/core/src/instructions/user_instructions.rs rename to codex-rs/instructions/src/user_instructions.rs index a0389c9ff8..4fd266e766 100644 --- a/codex-rs/core/src/instructions/user_instructions.rs +++ b/codex-rs/instructions/src/user_instructions.rs @@ -3,20 +3,21 @@ use serde::Serialize; use codex_protocol::models::ResponseItem; -use crate::contextual_user_message::AGENTS_MD_FRAGMENT; -use crate::contextual_user_message::SKILL_FRAGMENT; +use crate::fragment::AGENTS_MD_FRAGMENT; +use crate::fragment::AGENTS_MD_START_MARKER; +use crate::fragment::SKILL_FRAGMENT; -pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for "; +pub const USER_INSTRUCTIONS_PREFIX: &str = AGENTS_MD_START_MARKER; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "user_instructions", rename_all = "snake_case")] -pub(crate) struct UserInstructions { +pub struct UserInstructions { pub directory: String, pub text: String, } impl UserInstructions { - pub(crate) fn serialize_to_text(&self) -> String { + pub fn serialize_to_text(&self) -> String { format!( "{prefix}{directory}\n\n\n{contents}\n{suffix}", prefix = AGENTS_MD_FRAGMENT.start_marker(), @@ -35,14 +36,12 @@ impl From for ResponseItem { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "skill_instructions", rename_all = "snake_case")] -pub(crate) struct SkillInstructions { +pub struct SkillInstructions { pub name: String, pub path: String, pub contents: String, } -impl SkillInstructions {} - impl From for ResponseItem { fn from(si: SkillInstructions) -> Self { SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!( diff --git a/codex-rs/core/src/instructions/user_instructions_tests.rs b/codex-rs/instructions/src/user_instructions_tests.rs similarity index 94% rename from codex-rs/core/src/instructions/user_instructions_tests.rs rename to codex-rs/instructions/src/user_instructions_tests.rs index 58442600a8..75f35d11b0 100644 --- a/codex-rs/core/src/instructions/user_instructions_tests.rs +++ b/codex-rs/instructions/src/user_instructions_tests.rs @@ -1,7 +1,11 @@ use super::*; use codex_protocol::models::ContentItem; +use codex_protocol::models::ResponseItem; use pretty_assertions::assert_eq; +use crate::fragment::AGENTS_MD_FRAGMENT; +use crate::fragment::SKILL_FRAGMENT; + #[test] fn test_user_instructions() { let user_instructions = UserInstructions { From 0f770257e576401e87a9704c4954a2ce093a1ac3 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 25 Mar 2026 10:52:34 -0700 Subject: [PATCH 38/50] test fix --- codex-rs/core/tests/suite/thread_metadata.rs | 27 +++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/thread_metadata.rs index 036f732c71..0984ebeb62 100644 --- a/codex-rs/core/tests/suite/thread_metadata.rs +++ b/codex-rs/core/tests/suite/thread_metadata.rs @@ -1,12 +1,13 @@ use anyhow::Result; use codex_core::CodexAuth; +use codex_core::RolloutRecorder; use codex_core::config::Constrained; -use codex_core::find_thread_path_by_id_str; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::ServiceTier; use codex_protocol::openai_models::ReasoningEffort; +use codex_protocol::protocol::InitialHistory; use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use std::time::Duration; @@ -127,12 +128,24 @@ async fn resumed_thread_emits_thread_initialized_analytics() -> Result<()> { .build(&server) .await?; - let rollout_path = find_thread_path_by_id_str( - initial.codex_home_path(), - &initial.session_configured.session_id.to_string(), - ) - .await? - .expect("rollout path for initial thread"); + let rollout_path = initial + .session_configured + .rollout_path + .clone() + .expect("rollout path for initial thread"); + let resume_deadline = Instant::now() + Duration::from_secs(10); + loop { + if matches!( + RolloutRecorder::get_rollout_history(&rollout_path).await?, + InitialHistory::Resumed(_) + ) { + break; + } + if Instant::now() >= resume_deadline { + panic!("timed out waiting for rollout to become resumable"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } let _resumed = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) From 6b10e186c4d1b544745d241416c9d94bb83a9fef Mon Sep 17 00:00:00 2001 From: Jeremy Rose <172423086+nornagon-openai@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:05:07 -0700 Subject: [PATCH 39/50] Add non-interactive resume filter option (#15339) ## Summary - add `codex resume --include-non-interactive` to include non-interactive sessions in the picker and `--last` - keep current-provider and cwd filtering behavior unchanged - replace the picker API boolean with a `SessionSourceFilter` enum to avoid a boolean trap ## Tests - `cargo test -p codex-cli` - `cargo test -p codex-tui` - `just fmt` - `just fix -p codex-cli` - `just fix -p codex-tui` --- codex-rs/cli/src/main.rs | 19 ++++++++++++++ codex-rs/tui/src/app.rs | 1 + codex-rs/tui/src/cli.rs | 4 +++ codex-rs/tui/src/lib.rs | 21 +++++++++++++-- codex-rs/tui/src/resume_picker.rs | 43 +++++++++++++++++++++++++++---- 5 files changed, 81 insertions(+), 7 deletions(-) diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8446e457b2..82ede69320 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -208,6 +208,10 @@ struct ResumeCommand { #[arg(long = "all", default_value_t = false)] all: bool, + /// Include non-interactive sessions in the resume picker and --last selection. + #[arg(long = "include-non-interactive", default_value_t = false)] + include_non_interactive: bool, + #[clap(flatten)] remote: InteractiveRemoteOptions, @@ -691,6 +695,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { session_id, last, all, + include_non_interactive, remote, config_overrides, })) => { @@ -700,6 +705,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { session_id, last, all, + include_non_interactive, config_overrides, ); let exit_info = run_interactive_tui( @@ -1169,6 +1175,7 @@ fn finalize_resume_interactive( session_id: Option, last: bool, show_all: bool, + include_non_interactive: bool, resume_cli: TuiCli, ) -> TuiCli { // Start with the parsed interactive CLI so resume shares the same @@ -1178,6 +1185,7 @@ fn finalize_resume_interactive( interactive.resume_last = last; interactive.resume_session_id = resume_session_id; interactive.resume_show_all = show_all; + interactive.resume_include_non_interactive = include_non_interactive; // Merge resume-scoped flags and overrides with highest precedence. merge_interactive_cli_flags(&mut interactive, resume_cli); @@ -1290,6 +1298,7 @@ mod tests { session_id, last, all, + include_non_interactive, remote: _, config_overrides: resume_cli, }) = subcommand.expect("resume present") @@ -1303,6 +1312,7 @@ mod tests { session_id, last, all, + include_non_interactive, resume_cli, ) } @@ -1498,6 +1508,15 @@ mod tests { assert!(interactive.resume_show_all); } + #[test] + fn resume_include_non_interactive_flag_sets_source_filter_override() { + let interactive = + finalize_resume_from_args(["codex", "resume", "--include-non-interactive"].as_ref()); + + assert!(interactive.resume_picker); + assert!(interactive.resume_include_non_interactive); + } + #[test] fn resume_merges_option_flags_and_full_auto() { let interactive = finalize_resume_from_args( diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 43ccc78657..a606520277 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -2819,6 +2819,7 @@ impl App { tui, &self.config, /*show_all*/ false, + crate::resume_picker::SessionSourceFilter::InteractiveOnly, ) .await? { diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index 86bea97abe..e3087af2ae 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -32,6 +32,10 @@ pub struct Cli { #[clap(skip)] pub resume_show_all: bool, + /// Internal: include non-interactive sessions in resume listings. + #[clap(skip)] + pub resume_include_non_interactive: bool, + // Internal controls set by the top-level `codex fork` subcommand. // These are not exposed as user flags on the base `codex` command. #[clap(skip)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2fcf2e3dc3..43182b5240 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -845,12 +845,17 @@ async fn run_ratatui_app( } else { Some(config.cwd.as_path()) }; + let allowed_sources = if cli.resume_include_non_interactive { + &[][..] + } else { + INTERACTIVE_SESSION_SOURCES.as_slice() + }; match RolloutRecorder::find_latest_thread_path( &config, /*page_size*/ 1, /*cursor*/ None, ThreadSortKey::UpdatedAt, - INTERACTIVE_SESSION_SOURCES.as_slice(), + allowed_sources, Some(provider_filter.as_slice()), &config.model_provider_id, filter_cwd, @@ -888,7 +893,19 @@ async fn run_ratatui_app( _ => resume_picker::SessionSelection::StartFresh, } } else if cli.resume_picker { - match resume_picker::run_resume_picker(&mut tui, &config, cli.resume_show_all).await? { + let source_filter = if cli.resume_include_non_interactive { + resume_picker::SessionSourceFilter::IncludeNonInteractive + } else { + resume_picker::SessionSourceFilter::InteractiveOnly + }; + match resume_picker::run_resume_picker( + &mut tui, + &config, + cli.resume_show_all, + source_filter, + ) + .await? + { resume_picker::SessionSelection::Exit => { terminal_restore_guard.restore_silently(); session_log::log_session_end(); diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index 1a74fcd83a..1ac3492ebf 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -60,6 +60,21 @@ pub enum SessionPickerAction { Fork, } +#[derive(Clone, Copy, Debug)] +pub enum SessionSourceFilter { + InteractiveOnly, + IncludeNonInteractive, +} + +impl SessionSourceFilter { + fn allowed_sources(self) -> &'static [codex_protocol::protocol::SessionSource] { + match self { + SessionSourceFilter::InteractiveOnly => INTERACTIVE_SESSION_SOURCES.as_slice(), + SessionSourceFilter::IncludeNonInteractive => &[], + } + } +} + impl SessionPickerAction { fn title(self) -> &'static str { match self { @@ -116,15 +131,24 @@ enum BackgroundEvent { /// new sessions appear during pagination. /// /// Filtering happens in two layers: -/// 1. Provider and source filtering at the backend (only interactive CLI sessions -/// for the current model provider). +/// 1. Provider and source filtering at the backend (interactive sessions for the +/// current model provider by default, optionally including non-interactive +/// sessions). /// 2. Working-directory filtering at the picker (unless `--all` is passed). pub async fn run_resume_picker( tui: &mut Tui, config: &Config, show_all: bool, + source_filter: SessionSourceFilter, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Resume).await + run_session_picker( + tui, + config, + show_all, + source_filter, + SessionPickerAction::Resume, + ) + .await } pub async fn run_fork_picker( @@ -132,13 +156,21 @@ pub async fn run_fork_picker( config: &Config, show_all: bool, ) -> Result { - run_session_picker(tui, config, show_all, SessionPickerAction::Fork).await + run_session_picker( + tui, + config, + show_all, + SessionSourceFilter::InteractiveOnly, + SessionPickerAction::Fork, + ) + .await } async fn run_session_picker( tui: &mut Tui, config: &Config, show_all: bool, + source_filter: SessionSourceFilter, action: SessionPickerAction, ) -> Result { let alt = AltScreenGuard::enter(tui); @@ -154,6 +186,7 @@ async fn run_session_picker( let config = config.clone(); let loader_tx = bg_tx.clone(); + let allowed_sources = source_filter.allowed_sources(); let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| { let tx = loader_tx.clone(); let config = config.clone(); @@ -164,7 +197,7 @@ async fn run_session_picker( PAGE_SIZE, request.cursor.as_ref(), request.sort_key, - INTERACTIVE_SESSION_SOURCES.as_slice(), + allowed_sources, Some(provider_filter.as_slice()), request.default_provider.as_str(), /*search_term*/ None, From ad74543a6f6e5590ae8839142259df571405da28 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 11:05:35 -0700 Subject: [PATCH 40/50] Extract codex-utils-plugins crate (#15746) ## Summary - extract shared plugin path and manifest helpers into codex-utils-plugins - update codex-core to consume the utility crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 10 +++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/mention_syntax.rs | 6 +- codex-rs/core/src/plugins/manifest.rs | 3 +- codex-rs/utils/plugins/BUILD.bazel | 6 ++ codex-rs/utils/plugins/Cargo.toml | 21 ++++++ codex-rs/utils/plugins/src/lib.rs | 7 ++ codex-rs/utils/plugins/src/mention_syntax.rs | 7 ++ .../utils/plugins/src/plugin_namespace.rs | 70 +++++++++++++++++++ 10 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 codex-rs/utils/plugins/BUILD.bazel create mode 100644 codex-rs/utils/plugins/Cargo.toml create mode 100644 codex-rs/utils/plugins/src/lib.rs create mode 100644 codex-rs/utils/plugins/src/mention_syntax.rs create mode 100644 codex-rs/utils/plugins/src/plugin_namespace.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b57b6164bd..d9f7512fcc 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1905,6 +1905,7 @@ dependencies = [ "codex-utils-image", "codex-utils-output-truncation", "codex-utils-path", + "codex-utils-plugins", "codex-utils-pty", "codex-utils-readiness", "codex-utils-stream-parser", @@ -2947,6 +2948,15 @@ dependencies = [ "tempfile", ] +[[package]] +name = "codex-utils-plugins" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "codex-utils-pty" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 126351131f..571e037902 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -69,6 +69,7 @@ members = [ "utils/oss", "utils/output-truncation", "utils/path-utils", + "utils/plugins", "utils/fuzzy-match", "utils/stream-parser", "utils/template", @@ -163,6 +164,7 @@ codex-utils-json-to-toml = { path = "utils/json-to-toml" } codex-utils-oss = { path = "utils/oss" } codex-utils-output-truncation = { path = "utils/output-truncation" } codex-utils-path = { path = "utils/path-utils" } +codex-utils-plugins = { path = "utils/plugins" } codex-utils-pty = { path = "utils/pty" } codex-utils-readiness = { path = "utils/readiness" } codex-utils-rustls-provider = { path = "utils/rustls-provider" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index d335dc6cc5..ebff813abf 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -58,6 +58,7 @@ codex-utils-image = { workspace = true } codex-utils-home-dir = { workspace = true } codex-utils-output-truncation = { workspace = true } codex-utils-path = { workspace = true } +codex-utils-plugins = { workspace = true } codex-utils-pty = { workspace = true } codex-utils-readiness = { workspace = true } codex-secrets = { workspace = true } diff --git a/codex-rs/core/src/mention_syntax.rs b/codex-rs/core/src/mention_syntax.rs index e58f419b54..690cd1a2f5 100644 --- a/codex-rs/core/src/mention_syntax.rs +++ b/codex-rs/core/src/mention_syntax.rs @@ -1,4 +1,2 @@ -// Default plaintext sigil for tools. -pub const TOOL_MENTION_SIGIL: char = '$'; -// Plugins use `@` in linked plaintext outside TUI. -pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@'; +pub use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; +pub use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index 91c7cbbb30..d1b2c648e0 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -1,11 +1,10 @@ use codex_utils_absolute_path::AbsolutePathBuf; +pub(crate) use codex_utils_plugins::PLUGIN_MANIFEST_PATH; use serde::Deserialize; use serde_json::Value as JsonValue; use std::fs; use std::path::Component; use std::path::Path; - -pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; const MAX_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_DEFAULT_PROMPT_LEN: usize = 128; diff --git a/codex-rs/utils/plugins/BUILD.bazel b/codex-rs/utils/plugins/BUILD.bazel new file mode 100644 index 0000000000..273e98fc4b --- /dev/null +++ b/codex-rs/utils/plugins/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "plugins", + crate_name = "codex_utils_plugins", +) diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml new file mode 100644 index 0000000000..4e1826c256 --- /dev/null +++ b/codex-rs/utils/plugins/Cargo.toml @@ -0,0 +1,21 @@ + +[package] +edition.workspace = true +license.workspace = true +name = "codex-utils-plugins" +version.workspace = true + +[lib] +doctest = false +name = "codex_utils_plugins" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs new file mode 100644 index 0000000000..5e990eb3cb --- /dev/null +++ b/codex-rs/utils/plugins/src/lib.rs @@ -0,0 +1,7 @@ +//! Plugin path resolution and plaintext mention sigils shared across Codex crates. + +pub mod mention_syntax; +pub mod plugin_namespace; + +pub use plugin_namespace::PLUGIN_MANIFEST_PATH; +pub use plugin_namespace::plugin_namespace_for_skill_path; diff --git a/codex-rs/utils/plugins/src/mention_syntax.rs b/codex-rs/utils/plugins/src/mention_syntax.rs new file mode 100644 index 0000000000..de5d1432b9 --- /dev/null +++ b/codex-rs/utils/plugins/src/mention_syntax.rs @@ -0,0 +1,7 @@ +//! Sigils for tool/plugin mentions in plaintext (shared across Codex crates). + +/// Default plaintext sigil for tools. +pub const TOOL_MENTION_SIGIL: char = '$'; + +/// Plugins use `@` in linked plaintext outside TUI. +pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@'; diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs new file mode 100644 index 0000000000..6688ae0469 --- /dev/null +++ b/codex-rs/utils/plugins/src/plugin_namespace.rs @@ -0,0 +1,70 @@ +//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. + +use std::fs; +use std::path::Path; + +/// Relative path from a plugin root to its manifest file. +pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifestName { + #[serde(default)] + name: String, +} + +fn plugin_manifest_name(plugin_root: &Path) -> Option { + let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH); + if !manifest_path.is_file() { + return None; + } + let contents = fs::read_to_string(&manifest_path).ok()?; + let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?; + Some( + plugin_root + .file_name() + .and_then(|entry| entry.to_str()) + .filter(|_| raw_name.trim().is_empty()) + .unwrap_or(raw_name.as_str()) + .to_string(), + ) +} + +/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid +/// plugin manifest (same `name` rules as full manifest loading in codex-core). +pub fn plugin_namespace_for_skill_path(path: &Path) -> Option { + for ancestor in path.ancestors() { + if let Some(name) = plugin_manifest_name(ancestor) { + return Some(name); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::plugin_namespace_for_skill_path; + use std::fs; + use tempfile::tempdir; + + #[test] + fn uses_manifest_name() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("plugins/sample"); + let skill_path = plugin_root.join("skills/search/SKILL.md"); + + fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write manifest"); + fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill"); + + assert_eq!( + plugin_namespace_for_skill_path(&skill_path), + Some("sample".to_string()) + ); + } +} From 2bb1027e37fd2ff7b5a72de8b5f5d8ce8cca2cba Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 11:07:31 -0700 Subject: [PATCH 41/50] Extract codex-plugin crate (#15747) ## Summary - extract plugin identifiers and load-outcome types into codex-plugin - update codex-core to consume the new plugin crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 12 ++ codex-rs/Cargo.toml | 2 + codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/plugins/manager.rs | 216 +++------------------ codex-rs/core/src/plugins/manager_tests.rs | 54 ++---- codex-rs/core/src/plugins/marketplace.rs | 4 +- codex-rs/core/src/plugins/mod.rs | 20 +- codex-rs/core/src/plugins/store.rs | 66 +------ codex-rs/core/src/plugins/store_tests.rs | 1 + codex-rs/plugin/BUILD.bazel | 15 ++ codex-rs/plugin/Cargo.toml | 18 ++ codex-rs/plugin/src/lib.rs | 55 ++++++ codex-rs/plugin/src/load_outcome.rs | 163 ++++++++++++++++ codex-rs/plugin/src/plugin_id.rs | 64 ++++++ 14 files changed, 394 insertions(+), 297 deletions(-) create mode 100644 codex-rs/plugin/BUILD.bazel create mode 100644 codex-rs/plugin/Cargo.toml create mode 100644 codex-rs/plugin/src/lib.rs create mode 100644 codex-rs/plugin/src/load_outcome.rs create mode 100644 codex-rs/plugin/src/plugin_id.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index d9f7512fcc..b9760de416 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1887,6 +1887,7 @@ dependencies = [ "codex-login", "codex-network-proxy", "codex-otel", + "codex-plugin", "codex-protocol", "codex-rmcp-client", "codex-rollout", @@ -2394,6 +2395,17 @@ dependencies = [ "zip", ] +[[package]] +name = "codex-plugin" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "codex-utils-plugins", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "codex-process-hardening" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 571e037902..4b23f48658 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -80,6 +80,7 @@ members = [ "codex-experimental-api-macros", "test-macros", "package-manager", + "plugin", "artifacts", ] resolver = "2" @@ -134,6 +135,7 @@ codex-mcp-server = { path = "mcp-server" } codex-network-proxy = { path = "network-proxy" } codex-ollama = { path = "ollama" } codex-otel = { path = "otel" } +codex-plugin = { path = "plugin" } codex-process-hardening = { path = "process-hardening" } codex-protocol = { path = "protocol" } codex-rollout = { path = "rollout" } diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index ebff813abf..9e01e6662f 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -46,6 +46,7 @@ codex-instructions = { workspace = true } codex-network-proxy = { workspace = true } codex-otel = { workspace = true } codex-artifacts = { workspace = true } +codex-plugin = { workspace = true } codex-protocol = { workspace = true } codex-rollout = { workspace = true } codex-rmcp-client = { workspace = true } diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index d2d2e1b1c2..261cdf88dc 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -1,3 +1,5 @@ +use super::LoadedPlugin; +use super::PluginLoadOutcome; use super::PluginManifestPaths; use super::curated_plugins_repo_path; use super::load_plugin_manifest; @@ -20,14 +22,11 @@ use super::remote::fetch_remote_featured_plugin_ids; use super::remote::fetch_remote_plugin_status; use super::remote::uninstall_remote_plugin; use super::startup_sync::start_startup_remote_plugin_sync_once; -use super::store::PluginId; -use super::store::PluginIdError; use super::store::PluginInstallResult as StorePluginInstallResult; use super::store::PluginStore; use super::store::PluginStoreError; use super::sync_openai_plugins_repo; use crate::AuthManager; -use crate::analytics_client::AnalyticsEventsClient; use crate::auth::CodexAuth; use crate::config::Config; use crate::config::ConfigService; @@ -46,6 +45,12 @@ use crate::skills::loader::load_skills_from_roots; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::MergeStrategy; use codex_features::Feature; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_plugin::PluginTelemetryMetadata; +use codex_plugin::prompt_safe_plugin_description; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; @@ -68,12 +73,13 @@ use toml_edit::value; use tracing::info; use tracing::warn; +use crate::AnalyticsEventsClient; + const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated"; static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false); -const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60 * 60 * 3); @@ -114,9 +120,6 @@ fn featured_plugin_ids_cache_key( } } -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct AppConnectorId(pub String); - #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallRequest { pub plugin_name: String, @@ -185,89 +188,6 @@ pub struct ConfiguredMarketplaceListOutcome { pub errors: Vec, } -#[derive(Debug, Clone, PartialEq)] -pub struct LoadedPlugin { - pub config_name: String, - pub manifest_name: Option, - pub manifest_description: Option, - pub root: AbsolutePathBuf, - pub enabled: bool, - pub skill_roots: Vec, - pub disabled_skill_paths: HashSet, - pub has_enabled_skills: bool, - pub mcp_servers: HashMap, - pub apps: Vec, - pub error: Option, -} - -impl LoadedPlugin { - fn is_active(&self) -> bool { - self.enabled && self.error.is_none() - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct PluginCapabilitySummary { - pub config_name: String, - pub display_name: String, - pub description: Option, - pub has_skills: bool, - pub mcp_server_names: Vec, - pub app_connector_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginTelemetryMetadata { - pub plugin_id: PluginId, - pub capability_summary: Option, -} - -impl PluginTelemetryMetadata { - pub fn from_plugin_id(plugin_id: &PluginId) -> Self { - Self { - plugin_id: plugin_id.clone(), - capability_summary: None, - } - } -} - -impl PluginCapabilitySummary { - fn from_plugin(plugin: &LoadedPlugin) -> Option { - if !plugin.is_active() { - return None; - } - - let mut mcp_server_names: Vec = plugin.mcp_servers.keys().cloned().collect(); - mcp_server_names.sort_unstable(); - - let summary = Self { - config_name: plugin.config_name.clone(), - display_name: plugin - .manifest_name - .clone() - .unwrap_or_else(|| plugin.config_name.clone()), - description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), - has_skills: plugin.has_enabled_skills, - mcp_server_names, - app_connector_ids: plugin.apps.clone(), - }; - - (summary.has_skills - || !summary.mcp_server_names.is_empty() - || !summary.app_connector_ids.is_empty()) - .then_some(summary) - } - - pub fn telemetry_metadata(&self) -> Option { - PluginId::parse(&self.config_name) - .ok() - .map(|plugin_id| PluginTelemetryMetadata { - plugin_id, - capability_summary: Some(self.clone()), - }) - } -} - impl From for PluginCapabilitySummary { fn from(value: PluginDetail) -> Self { let has_skills = value.skills.iter().any(|skill| { @@ -286,95 +206,6 @@ impl From for PluginCapabilitySummary { } } -fn prompt_safe_plugin_description(description: Option<&str>) -> Option { - let description = description? - .split_whitespace() - .collect::>() - .join(" "); - if description.is_empty() { - return None; - } - - Some( - description - .chars() - .take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN) - .collect(), - ) -} - -#[derive(Debug, Clone, PartialEq)] -pub struct PluginLoadOutcome { - plugins: Vec, - capability_summaries: Vec, -} - -impl Default for PluginLoadOutcome { - fn default() -> Self { - Self::from_plugins(Vec::new()) - } -} - -impl PluginLoadOutcome { - fn from_plugins(plugins: Vec) -> Self { - let capability_summaries = plugins - .iter() - .filter_map(PluginCapabilitySummary::from_plugin) - .collect::>(); - Self { - plugins, - capability_summaries, - } - } - - pub fn effective_skill_roots(&self) -> Vec { - let mut skill_roots: Vec = self - .plugins - .iter() - .filter(|plugin| plugin.is_active()) - .flat_map(|plugin| plugin.skill_roots.iter().cloned()) - .collect(); - skill_roots.sort_unstable(); - skill_roots.dedup(); - skill_roots - } - - pub fn effective_mcp_servers(&self) -> HashMap { - let mut mcp_servers = HashMap::new(); - for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { - for (name, config) in &plugin.mcp_servers { - mcp_servers - .entry(name.clone()) - .or_insert_with(|| config.clone()); - } - } - mcp_servers - } - - pub fn effective_apps(&self) -> Vec { - let mut apps = Vec::new(); - let mut seen_connector_ids = std::collections::HashSet::new(); - - for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { - for connector_id in &plugin.apps { - if seen_connector_ids.insert(connector_id.clone()) { - apps.push(connector_id.clone()); - } - } - } - - apps - } - - pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] { - &self.capability_summaries - } - - pub fn plugins(&self) -> &[LoadedPlugin] { - &self.plugins - } -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct RemotePluginSyncResult { /// Plugin ids newly installed into the local plugin cache. @@ -575,6 +406,19 @@ impl PluginsManager { *cached_enabled_outcome = None; } + /// Resolve plugin skill roots for a config layer stack without touching the plugins cache. + pub fn effective_skill_roots_for_layer_stack( + &self, + config_layer_stack: &ConfigLayerStack, + plugins_feature_enabled: bool, + ) -> Vec { + if !plugins_feature_enabled { + return Vec::new(); + } + load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product) + .effective_skill_roots() + } + fn cached_enabled_outcome(&self) -> Option { match self.cached_enabled_outcome.read() { Ok(cache) => cache.clone(), @@ -1174,7 +1018,7 @@ impl PluginsManager { } }) .collect::>(); - configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key); + configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key); self.start_curated_repo_sync(configured_curated_plugin_ids); start_startup_remote_plugin_sync_once( Arc::clone(self), @@ -1341,7 +1185,7 @@ impl PluginUninstallError { fn log_plugin_load_errors(outcome: &PluginLoadOutcome) { for plugin in outcome - .plugins + .plugins() .iter() .filter(|plugin| plugin.error.is_some()) { @@ -1413,16 +1257,6 @@ pub(crate) fn load_plugins_from_layer_stack( PluginLoadOutcome::from_plugins(plugins) } -pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option { - for ancestor in path.ancestors() { - if let Some(manifest) = load_plugin_manifest(ancestor) { - return Some(manifest.name); - } - } - - None -} - fn refresh_curated_plugin_cache( codex_home: &Path, plugin_version: &str, diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 3d61c39375..884beee66d 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -7,7 +7,9 @@ use crate::config_loader::ConfigLayerEntry; use crate::config_loader::ConfigLayerStack; use crate::config_loader::ConfigRequirements; use crate::config_loader::ConfigRequirementsToml; +use crate::plugins::LoadedPlugin; use crate::plugins::MarketplacePluginInstallPolicy; +use crate::plugins::PluginLoadOutcome; use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA; use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha; use crate::plugins::test_support::write_file; @@ -26,6 +28,8 @@ use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; +const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; + fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) { let plugin_root = root.join(dir_name); fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap(); @@ -130,7 +134,7 @@ fn load_plugins_loads_default_skills_and_mcp_servers() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins, + outcome.plugins(), vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: Some("sample".to_string()), @@ -220,10 +224,10 @@ enabled = true let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize"); assert_eq!( - outcome.plugins[0].disabled_skill_paths, + outcome.plugins()[0].disabled_skill_paths, HashSet::from([skill_path]) ); - assert!(!outcome.plugins[0].has_enabled_skills); + assert!(!outcome.plugins()[0].has_enabled_skills); assert!(outcome.capability_summaries().is_empty()); } @@ -256,8 +260,8 @@ enabled = true "#; let outcome = load_plugins_from_config(config_toml, codex_home.path()); - assert!(outcome.plugins[0].disabled_skill_paths.is_empty()); - assert!(outcome.plugins[0].has_enabled_skills); + assert!(outcome.plugins()[0].disabled_skill_paths.is_empty()); + assert!(outcome.plugins()[0].has_enabled_skills); assert_eq!( outcome.capability_summaries(), &[PluginCapabilitySummary { @@ -338,7 +342,7 @@ fn capability_summary_sanitizes_plugin_descriptions_to_one_line() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].manifest_description.as_deref(), + outcome.plugins()[0].manifest_description.as_deref(), Some("Plugin that\n includes the sample\tserver") ); assert_eq!( @@ -373,7 +377,7 @@ fn capability_summary_truncates_overlong_plugin_descriptions() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].manifest_description.as_deref(), + outcome.plugins()[0].manifest_description.as_deref(), Some(too_long.as_str()) ); assert_eq!( @@ -453,14 +457,14 @@ fn load_plugins_uses_manifest_configured_component_paths() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].skill_roots, + outcome.plugins()[0].skill_roots, vec![ plugin_root.join("custom-skills"), plugin_root.join("skills") ] ); assert_eq!( - outcome.plugins[0].mcp_servers, + outcome.plugins()[0].mcp_servers, HashMap::from([( "custom".to_string(), McpServerConfig { @@ -483,7 +487,7 @@ fn load_plugins_uses_manifest_configured_component_paths() { )]) ); assert_eq!( - outcome.plugins[0].apps, + outcome.plugins()[0].apps, vec![AppConnectorId("connector_custom".to_string())] ); } @@ -559,11 +563,11 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path()); assert_eq!( - outcome.plugins[0].skill_roots, + outcome.plugins()[0].skill_roots, vec![plugin_root.join("skills")] ); assert_eq!( - outcome.plugins[0].mcp_servers, + outcome.plugins()[0].mcp_servers, HashMap::from([( "default".to_string(), McpServerConfig { @@ -586,7 +590,7 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() { )]) ); assert_eq!( - outcome.plugins[0].apps, + outcome.plugins()[0].apps, vec![AppConnectorId("connector_default".to_string())] ); } @@ -618,7 +622,7 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() { let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path()); assert_eq!( - outcome.plugins, + outcome.plugins(), vec![LoadedPlugin { config_name: "sample@test".to_string(), manifest_name: None, @@ -805,24 +809,6 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { ); } -#[test] -fn plugin_namespace_for_skill_path_uses_manifest_name() { - let codex_home = TempDir::new().unwrap(); - let plugin_root = codex_home.path().join("plugins/sample"); - let skill_path = plugin_root.join("skills/search/SKILL.md"); - - write_file( - &plugin_root.join(".codex-plugin/plugin.json"), - r#"{"name":"sample"}"#, - ); - write_file(&skill_path, "---\ndescription: search\n---\n"); - - assert_eq!( - plugin_namespace_for_skill_path(&skill_path), - Some("sample".to_string()) - ); -} - #[test] fn load_plugins_returns_empty_when_feature_disabled() { let codex_home = TempDir::new().unwrap(); @@ -880,9 +866,9 @@ fn load_plugins_rejects_invalid_plugin_keys() { codex_home.path(), ); - assert_eq!(outcome.plugins.len(), 1); + assert_eq!(outcome.plugins().len(), 1); assert_eq!( - outcome.plugins[0].error.as_deref(), + outcome.plugins()[0].error.as_deref(), Some("invalid plugin key `sample`; expected @") ); assert!(outcome.effective_skill_roots().is_empty()); diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index 5563f0c3aa..ee775262aa 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -1,10 +1,10 @@ use super::PluginManifestInterface; use super::load_plugin_manifest; -use super::store::PluginId; -use super::store::PluginIdError; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_git_utils::get_git_repo_root; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; use codex_protocol::protocol::Product; use codex_utils_absolute_path::AbsolutePathBuf; use dirs::home_dir; diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 34403f4d1f..c16ebfcc58 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -1,3 +1,5 @@ +use crate::config::types::McpServerConfig; + mod discoverable; mod injection; mod manager; @@ -11,31 +13,36 @@ mod store; pub(crate) mod test_support; mod toggles; +pub use codex_plugin::AppConnectorId; +pub use codex_plugin::EffectiveSkillRoots; +pub use codex_plugin::PluginCapabilitySummary; +pub use codex_plugin::PluginId; +pub use codex_plugin::PluginIdError; +pub use codex_plugin::PluginTelemetryMetadata; + +pub type LoadedPlugin = codex_plugin::LoadedPlugin; +pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; + +pub(crate) use codex_plugin::plugin_namespace_for_skill_path; pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; -pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplace; pub use manager::ConfiguredMarketplaceListOutcome; pub use manager::ConfiguredMarketplacePlugin; -pub use manager::LoadedPlugin; pub use manager::OPENAI_CURATED_MARKETPLACE_NAME; -pub use manager::PluginCapabilitySummary; pub use manager::PluginDetail; pub use manager::PluginInstallError; pub use manager::PluginInstallOutcome; pub use manager::PluginInstallRequest; -pub use manager::PluginLoadOutcome; pub use manager::PluginReadOutcome; pub use manager::PluginReadRequest; pub use manager::PluginRemoteSyncError; -pub use manager::PluginTelemetryMetadata; pub use manager::PluginUninstallError; pub use manager::PluginsManager; pub use manager::RemotePluginSyncResult; pub use manager::installed_plugin_telemetry_metadata; pub use manager::load_plugin_apps; pub use manager::load_plugin_mcp_servers; -pub(crate) use manager::plugin_namespace_for_skill_path; pub use manager::plugin_telemetry_metadata_from_root; pub use manifest::PluginManifestInterface; pub(crate) use manifest::PluginManifestPaths; @@ -53,5 +60,4 @@ pub(crate) use render::render_plugins_section; pub(crate) use startup_sync::curated_plugins_repo_path; pub(crate) use startup_sync::read_curated_plugins_sha; pub(crate) use startup_sync::sync_openai_plugins_repo; -pub use store::PluginId; pub use toggles::collect_plugin_enabled_candidates; diff --git a/codex-rs/core/src/plugins/store.rs b/codex-rs/core/src/plugins/store.rs index 97b0ccefe1..faa7fc0812 100644 --- a/codex-rs/core/src/plugins/store.rs +++ b/codex-rs/core/src/plugins/store.rs @@ -1,6 +1,8 @@ use super::load_plugin_manifest; -use super::manifest::PLUGIN_MANIFEST_PATH; +use codex_plugin::PluginId; +use codex_plugin::validate_plugin_segment; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PLUGIN_MANIFEST_PATH; use std::fs; use std::io; use std::path::Path; @@ -9,53 +11,6 @@ use std::path::PathBuf; pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local"; pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache"; -#[derive(Debug, thiserror::Error)] -pub enum PluginIdError { - #[error("{0}")] - Invalid(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PluginId { - pub plugin_name: String, - pub marketplace_name: String, -} - -impl PluginId { - pub fn new(plugin_name: String, marketplace_name: String) -> Result { - validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?; - validate_plugin_segment(&marketplace_name, "marketplace name") - .map_err(PluginIdError::Invalid)?; - Ok(Self { - plugin_name, - marketplace_name, - }) - } - - pub fn parse(plugin_key: &str) -> Result { - let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else { - return Err(PluginIdError::Invalid(format!( - "invalid plugin key `{plugin_key}`; expected @" - ))); - }; - if plugin_name.is_empty() || marketplace_name.is_empty() { - return Err(PluginIdError::Invalid(format!( - "invalid plugin key `{plugin_key}`; expected @" - ))); - } - - Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err { - PluginIdError::Invalid(message) => { - PluginIdError::Invalid(format!("{message} in `{plugin_key}`")) - } - }) - } - - pub fn as_key(&self) -> String { - format!("{}@{}", self.plugin_name, self.marketplace_name) - } -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginInstallResult { pub plugin_id: PluginId, @@ -221,21 +176,6 @@ fn plugin_name_for_source(source_path: &Path) -> Result Result<(), String> { - if segment.is_empty() { - return Err(format!("invalid {kind}: must not be empty")); - } - if !segment - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') - { - return Err(format!( - "invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed" - )); - } - Ok(()) -} - fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> { if !path.exists() { return Ok(()); diff --git a/codex-rs/core/src/plugins/store_tests.rs b/codex-rs/core/src/plugins/store_tests.rs index 61c3460255..e75e71c568 100644 --- a/codex-rs/core/src/plugins/store_tests.rs +++ b/codex-rs/core/src/plugins/store_tests.rs @@ -1,4 +1,5 @@ use super::*; +use codex_plugin::PluginId; use pretty_assertions::assert_eq; use tempfile::tempdir; diff --git a/codex-rs/plugin/BUILD.bazel b/codex-rs/plugin/BUILD.bazel new file mode 100644 index 0000000000..292606ff06 --- /dev/null +++ b/codex-rs/plugin/BUILD.bazel @@ -0,0 +1,15 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "plugin", + crate_name = "codex_plugin", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), +) diff --git a/codex-rs/plugin/Cargo.toml b/codex-rs/plugin/Cargo.toml new file mode 100644 index 0000000000..b72d74682c --- /dev/null +++ b/codex-rs/plugin/Cargo.toml @@ -0,0 +1,18 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-plugin" +version.workspace = true + +[lib] +doctest = false +name = "codex_plugin" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +codex-utils-plugins = { workspace = true } +thiserror = { workspace = true } diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs new file mode 100644 index 0000000000..7b56cd3faf --- /dev/null +++ b/codex-rs/plugin/src/lib.rs @@ -0,0 +1,55 @@ +//! Shared plugin identifiers and telemetry-facing summaries. + +pub use codex_utils_plugins::PLUGIN_MANIFEST_PATH; +pub use codex_utils_plugins::mention_syntax; +pub use codex_utils_plugins::plugin_namespace_for_skill_path; + +mod load_outcome; +mod plugin_id; + +pub use load_outcome::EffectiveSkillRoots; +pub use load_outcome::LoadedPlugin; +pub use load_outcome::PluginLoadOutcome; +pub use load_outcome::prompt_safe_plugin_description; +pub use plugin_id::PluginId; +pub use plugin_id::PluginIdError; +pub use plugin_id::validate_plugin_segment; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct AppConnectorId(pub String); + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginCapabilitySummary { + pub config_name: String, + pub display_name: String, + pub description: Option, + pub has_skills: bool, + pub mcp_server_names: Vec, + pub app_connector_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginTelemetryMetadata { + pub plugin_id: PluginId, + pub capability_summary: Option, +} + +impl PluginTelemetryMetadata { + pub fn from_plugin_id(plugin_id: &PluginId) -> Self { + Self { + plugin_id: plugin_id.clone(), + capability_summary: None, + } + } +} + +impl PluginCapabilitySummary { + pub fn telemetry_metadata(&self) -> Option { + PluginId::parse(&self.config_name) + .ok() + .map(|plugin_id| PluginTelemetryMetadata { + plugin_id, + capability_summary: Some(self.clone()), + }) + } +} diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs new file mode 100644 index 0000000000..9faebaebe9 --- /dev/null +++ b/codex-rs/plugin/src/load_outcome.rs @@ -0,0 +1,163 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; + +use crate::AppConnectorId; +use crate::PluginCapabilitySummary; + +const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024; + +/// A plugin that was loaded from disk, including merged MCP server definitions. +#[derive(Debug, Clone, PartialEq)] +pub struct LoadedPlugin { + pub config_name: String, + pub manifest_name: Option, + pub manifest_description: Option, + pub root: AbsolutePathBuf, + pub enabled: bool, + pub skill_roots: Vec, + pub disabled_skill_paths: HashSet, + pub has_enabled_skills: bool, + pub mcp_servers: HashMap, + pub apps: Vec, + pub error: Option, +} + +impl LoadedPlugin { + pub fn is_active(&self) -> bool { + self.enabled && self.error.is_none() + } +} + +fn plugin_capability_summary_from_loaded( + plugin: &LoadedPlugin, +) -> Option { + if !plugin.is_active() { + return None; + } + + let mut mcp_server_names: Vec = plugin.mcp_servers.keys().cloned().collect(); + mcp_server_names.sort_unstable(); + + let summary = PluginCapabilitySummary { + config_name: plugin.config_name.clone(), + display_name: plugin + .manifest_name + .clone() + .unwrap_or_else(|| plugin.config_name.clone()), + description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()), + has_skills: plugin.has_enabled_skills, + mcp_server_names, + app_connector_ids: plugin.apps.clone(), + }; + + (summary.has_skills + || !summary.mcp_server_names.is_empty() + || !summary.app_connector_ids.is_empty()) + .then_some(summary) +} + +/// Normalizes plugin descriptions for inclusion in model-facing capability summaries. +pub fn prompt_safe_plugin_description(description: Option<&str>) -> Option { + let description = description? + .split_whitespace() + .collect::>() + .join(" "); + if description.is_empty() { + return None; + } + + Some( + description + .chars() + .take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN) + .collect(), + ) +} + +/// Outcome of loading configured plugins (skills roots, MCP, apps, errors). +#[derive(Debug, Clone, PartialEq)] +pub struct PluginLoadOutcome { + plugins: Vec>, + capability_summaries: Vec, +} + +impl Default for PluginLoadOutcome { + fn default() -> Self { + Self::from_plugins(Vec::new()) + } +} + +impl PluginLoadOutcome { + pub fn from_plugins(plugins: Vec>) -> Self { + let capability_summaries = plugins + .iter() + .filter_map(plugin_capability_summary_from_loaded) + .collect::>(); + Self { + plugins, + capability_summaries, + } + } + + pub fn effective_skill_roots(&self) -> Vec { + let mut skill_roots: Vec = self + .plugins + .iter() + .filter(|plugin| plugin.is_active()) + .flat_map(|plugin| plugin.skill_roots.iter().cloned()) + .collect(); + skill_roots.sort_unstable(); + skill_roots.dedup(); + skill_roots + } + + pub fn effective_mcp_servers(&self) -> HashMap { + let mut mcp_servers = HashMap::new(); + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for (name, config) in &plugin.mcp_servers { + mcp_servers + .entry(name.clone()) + .or_insert_with(|| config.clone()); + } + } + mcp_servers + } + + pub fn effective_apps(&self) -> Vec { + let mut apps = Vec::new(); + let mut seen_connector_ids = HashSet::new(); + + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for connector_id in &plugin.apps { + if seen_connector_ids.insert(connector_id.clone()) { + apps.push(connector_id.clone()); + } + } + } + + apps + } + + pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] { + &self.capability_summaries + } + + pub fn plugins(&self) -> &[LoadedPlugin] { + &self.plugins + } +} + +/// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin` +/// without naming the MCP config type parameter. +pub trait EffectiveSkillRoots { + fn effective_skill_roots(&self) -> Vec; +} + +impl EffectiveSkillRoots for PluginLoadOutcome { + fn effective_skill_roots(&self) -> Vec { + PluginLoadOutcome::effective_skill_roots(self) + } +} diff --git a/codex-rs/plugin/src/plugin_id.rs b/codex-rs/plugin/src/plugin_id.rs new file mode 100644 index 0000000000..075116322b --- /dev/null +++ b/codex-rs/plugin/src/plugin_id.rs @@ -0,0 +1,64 @@ +//! Stable plugin identifier parsing and validation shared with the plugin cache. + +#[derive(Debug, thiserror::Error)] +pub enum PluginIdError { + #[error("{0}")] + Invalid(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PluginId { + pub plugin_name: String, + pub marketplace_name: String, +} + +impl PluginId { + pub fn new(plugin_name: String, marketplace_name: String) -> Result { + validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?; + validate_plugin_segment(&marketplace_name, "marketplace name") + .map_err(PluginIdError::Invalid)?; + Ok(Self { + plugin_name, + marketplace_name, + }) + } + + pub fn parse(plugin_key: &str) -> Result { + let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else { + return Err(PluginIdError::Invalid(format!( + "invalid plugin key `{plugin_key}`; expected @" + ))); + }; + if plugin_name.is_empty() || marketplace_name.is_empty() { + return Err(PluginIdError::Invalid(format!( + "invalid plugin key `{plugin_key}`; expected @" + ))); + } + + Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err { + PluginIdError::Invalid(message) => { + PluginIdError::Invalid(format!("{message} in `{plugin_key}`")) + } + }) + } + + pub fn as_key(&self) -> String { + format!("{}@{}", self.plugin_name, self.marketplace_name) + } +} + +/// Validates a single path segment used in plugin IDs and cache layout. +pub fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> { + if segment.is_empty() { + return Err(format!("invalid {kind}: must not be empty")); + } + if !segment + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + { + return Err(format!( + "invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed" + )); + } + Ok(()) +} From d273efc0f31615ffdfe23481829d3dbb1e729861 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 11:08:05 -0700 Subject: [PATCH 42/50] Extract codex-analytics crate (#15748) ## Summary - move the analytics events client into codex-analytics - update codex-core and app-server callsites to use the new crate ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 17 ++ codex-rs/Cargo.toml | 2 + codex-rs/analytics/BUILD.bazel | 6 + codex-rs/analytics/Cargo.toml | 30 +++ .../src/analytics_client.rs | 239 ++++++++++-------- .../src/analytics_client_tests.rs | 17 +- codex-rs/analytics/src/lib.rs | 8 + codex-rs/app-server/src/config_api.rs | 13 +- codex-rs/app-server/src/message_processor.rs | 7 +- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/codex.rs | 11 +- codex-rs/core/src/codex_tests.rs | 6 +- codex-rs/core/src/lib.rs | 3 +- codex-rs/core/src/mcp_tool_call.rs | 6 +- codex-rs/core/src/skills/injection.rs | 8 +- codex-rs/core/src/skills/invocation_utils.rs | 6 +- codex-rs/core/src/state/service.rs | 2 +- 17 files changed, 244 insertions(+), 138 deletions(-) create mode 100644 codex-rs/analytics/BUILD.bazel create mode 100644 codex-rs/analytics/Cargo.toml rename codex-rs/{core => analytics}/src/analytics_client.rs (78%) rename codex-rs/{core => analytics}/src/analytics_client_tests.rs (95%) create mode 100644 codex-rs/analytics/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index b9760de416..df44a7f83b 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1384,6 +1384,22 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9b18233253483ce2f65329a24072ec414db782531bdbb7d0bbc4bd2ce6b7e21" +[[package]] +name = "codex-analytics" +version = "0.0.0" +dependencies = [ + "codex-git-utils", + "codex-login", + "codex-plugin", + "codex-protocol", + "pretty_assertions", + "serde", + "serde_json", + "sha1", + "tokio", + "tracing", +] + [[package]] name = "codex-ansi-escape" version = "0.0.0" @@ -1869,6 +1885,7 @@ dependencies = [ "chardetng", "chrono", "clap", + "codex-analytics", "codex-api", "codex-app-server-protocol", "codex-apply-patch", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 4b23f48658..10b9485d20 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "analytics", "backend-client", "ansi-escape", "async-utils", @@ -98,6 +99,7 @@ license = "Apache-2.0" # Internal app_test_support = { path = "app-server/tests/common" } codex-ansi-escape = { path = "ansi-escape" } +codex-analytics = { path = "analytics" } codex-api = { path = "codex-api" } codex-artifacts = { path = "artifacts" } codex-code-mode = { path = "code-mode" } diff --git a/codex-rs/analytics/BUILD.bazel b/codex-rs/analytics/BUILD.bazel new file mode 100644 index 0000000000..aec07c8746 --- /dev/null +++ b/codex-rs/analytics/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "analytics", + crate_name = "codex_analytics", +) diff --git a/codex-rs/analytics/Cargo.toml b/codex-rs/analytics/Cargo.toml new file mode 100644 index 0000000000..9633eebb14 --- /dev/null +++ b/codex-rs/analytics/Cargo.toml @@ -0,0 +1,30 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-analytics" +version.workspace = true + +[lib] +doctest = false +name = "codex_analytics" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-git-utils = { workspace = true } +codex-login = { workspace = true } +codex-plugin = { workspace = true } +codex-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } +sha1 = { workspace = true } +tokio = { workspace = true, features = [ + "macros", + "rt-multi-thread", +] } +tracing = { workspace = true, features = ["log"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } +serde_json = { workspace = true } diff --git a/codex-rs/core/src/analytics_client.rs b/codex-rs/analytics/src/analytics_client.rs similarity index 78% rename from codex-rs/core/src/analytics_client.rs rename to codex-rs/analytics/src/analytics_client.rs index 922f16de58..ac8d10b6c1 100644 --- a/codex-rs/core/src/analytics_client.rs +++ b/codex-rs/analytics/src/analytics_client.rs @@ -1,9 +1,9 @@ -use crate::AuthManager; -use crate::config::Config; -use crate::default_client::create_client; -use crate::plugins::PluginTelemetryMetadata; use codex_git_utils::collect_git_info; use codex_git_utils::get_git_repo_root; +use codex_login::AuthManager; +use codex_login::default_client::create_client; +use codex_login::default_client::originator; +use codex_plugin::PluginTelemetryMetadata; use codex_protocol::protocol::SkillScope; use serde::Serialize; use sha1::Digest; @@ -17,13 +17,13 @@ use std::time::Duration; use tokio::sync::mpsc; #[derive(Clone)] -pub(crate) struct TrackEventsContext { - pub(crate) model_slug: String, - pub(crate) thread_id: String, - pub(crate) turn_id: String, +pub struct TrackEventsContext { + pub model_slug: String, + pub thread_id: String, + pub turn_id: String, } -pub(crate) fn build_track_events_context( +pub fn build_track_events_context( model_slug: String, thread_id: String, turn_id: String, @@ -36,24 +36,24 @@ pub(crate) fn build_track_events_context( } #[derive(Clone, Debug)] -pub(crate) struct SkillInvocation { - pub(crate) skill_name: String, - pub(crate) skill_scope: SkillScope, - pub(crate) skill_path: PathBuf, - pub(crate) invocation_type: InvocationType, +pub struct SkillInvocation { + pub skill_name: String, + pub skill_scope: SkillScope, + pub skill_path: PathBuf, + pub invocation_type: InvocationType, } #[derive(Clone, Copy, Debug, Serialize)] #[serde(rename_all = "lowercase")] -pub(crate) enum InvocationType { +pub enum InvocationType { Explicit, Implicit, } -pub(crate) struct AppInvocation { - pub(crate) connector_id: Option, - pub(crate) app_name: Option, - pub(crate) invocation_type: Option, +pub struct AppInvocation { + pub connector_id: Option, + pub app_name: Option, + pub invocation_type: Option, } #[derive(Clone)] @@ -66,38 +66,38 @@ pub(crate) struct AnalyticsEventsQueue { #[derive(Clone)] pub struct AnalyticsEventsClient { queue: AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, } impl AnalyticsEventsQueue { - pub(crate) fn new(auth_manager: Arc) -> Self { + pub(crate) fn new(auth_manager: Arc, base_url: String) -> Self { let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE); tokio::spawn(async move { while let Some(job) = receiver.recv().await { match job { TrackEventsJob::SkillInvocations(job) => { - send_track_skill_invocations(&auth_manager, job).await; + send_track_skill_invocations(&auth_manager, &base_url, job).await; } TrackEventsJob::AppMentioned(job) => { - send_track_app_mentioned(&auth_manager, job).await; + send_track_app_mentioned(&auth_manager, &base_url, job).await; } TrackEventsJob::AppUsed(job) => { - send_track_app_used(&auth_manager, job).await; + send_track_app_used(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginUsed(job) => { - send_track_plugin_used(&auth_manager, job).await; + send_track_plugin_used(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginInstalled(job) => { - send_track_plugin_installed(&auth_manager, job).await; + send_track_plugin_installed(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginUninstalled(job) => { - send_track_plugin_uninstalled(&auth_manager, job).await; + send_track_plugin_uninstalled(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginEnabled(job) => { - send_track_plugin_enabled(&auth_manager, job).await; + send_track_plugin_enabled(&auth_manager, &base_url, job).await; } TrackEventsJob::PluginDisabled(job) => { - send_track_plugin_disabled(&auth_manager, job).await; + send_track_plugin_disabled(&auth_manager, &base_url, job).await; } } } @@ -147,60 +147,51 @@ impl AnalyticsEventsQueue { } impl AnalyticsEventsClient { - pub fn new(config: Arc, auth_manager: Arc) -> Self { + pub fn new( + auth_manager: Arc, + base_url: String, + analytics_enabled: Option, + ) -> Self { Self { - queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager)), - config, + queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager), base_url), + analytics_enabled, } } - pub(crate) fn track_skill_invocations( + pub fn track_skill_invocations( &self, tracking: TrackEventsContext, invocations: Vec, ) { track_skill_invocations( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, Some(tracking), invocations, ); } - pub(crate) fn track_app_mentioned( - &self, - tracking: TrackEventsContext, - mentions: Vec, - ) { + pub fn track_app_mentioned(&self, tracking: TrackEventsContext, mentions: Vec) { track_app_mentioned( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, Some(tracking), mentions, ); } - pub(crate) fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { - track_app_used(&self.queue, Arc::clone(&self.config), Some(tracking), app); + pub fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) { + track_app_used(&self.queue, self.analytics_enabled, Some(tracking), app); } - pub(crate) fn track_plugin_used( - &self, - tracking: TrackEventsContext, - plugin: PluginTelemetryMetadata, - ) { - track_plugin_used( - &self.queue, - Arc::clone(&self.config), - Some(tracking), - plugin, - ); + pub fn track_plugin_used(&self, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata) { + track_plugin_used(&self.queue, self.analytics_enabled, Some(tracking), plugin); } pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Installed, plugin, ); @@ -209,7 +200,7 @@ impl AnalyticsEventsClient { pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Uninstalled, plugin, ); @@ -218,7 +209,7 @@ impl AnalyticsEventsClient { pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Enabled, plugin, ); @@ -227,7 +218,7 @@ impl AnalyticsEventsClient { pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) { track_plugin_management( &self.queue, - Arc::clone(&self.config), + self.analytics_enabled, PluginManagementEventType::Disabled, plugin, ); @@ -246,31 +237,31 @@ enum TrackEventsJob { } struct TrackSkillInvocationsJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, invocations: Vec, } struct TrackAppMentionedJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, mentions: Vec, } struct TrackAppUsedJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, app: AppInvocation, } struct TrackPluginUsedJob { - config: Arc, + analytics_enabled: Option, tracking: TrackEventsContext, plugin: PluginTelemetryMetadata, } struct TrackPluginManagementJob { - config: Arc, + analytics_enabled: Option, plugin: PluginTelemetryMetadata, } @@ -379,11 +370,11 @@ struct CodexPluginUsedEventRequest { pub(crate) fn track_skill_invocations( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, invocations: Vec, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -393,7 +384,7 @@ pub(crate) fn track_skill_invocations( return; } let job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob { - config, + analytics_enabled, tracking, invocations, }); @@ -402,11 +393,11 @@ pub(crate) fn track_skill_invocations( pub(crate) fn track_app_mentioned( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, mentions: Vec, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -416,7 +407,7 @@ pub(crate) fn track_app_mentioned( return; } let job = TrackEventsJob::AppMentioned(TrackAppMentionedJob { - config, + analytics_enabled, tracking, mentions, }); @@ -425,11 +416,11 @@ pub(crate) fn track_app_mentioned( pub(crate) fn track_app_used( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, app: AppInvocation, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -439,7 +430,7 @@ pub(crate) fn track_app_used( return; } let job = TrackEventsJob::AppUsed(TrackAppUsedJob { - config, + analytics_enabled, tracking, app, }); @@ -448,11 +439,11 @@ pub(crate) fn track_app_used( pub(crate) fn track_plugin_used( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, tracking: Option, plugin: PluginTelemetryMetadata, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } let Some(tracking) = tracking else { @@ -462,7 +453,7 @@ pub(crate) fn track_plugin_used( return; } let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob { - config, + analytics_enabled, tracking, plugin, }); @@ -471,14 +462,17 @@ pub(crate) fn track_plugin_used( fn track_plugin_management( queue: &AnalyticsEventsQueue, - config: Arc, + analytics_enabled: Option, event_type: PluginManagementEventType, plugin: PluginTelemetryMetadata, ) { - if config.analytics_enabled == Some(false) { + if analytics_enabled == Some(false) { return; } - let job = TrackPluginManagementJob { config, plugin }; + let job = TrackPluginManagementJob { + analytics_enabled, + plugin, + }; let job = match event_type { PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job), PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job), @@ -488,9 +482,13 @@ fn track_plugin_management( queue.try_send(job); } -async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkillInvocationsJob) { +async fn send_track_skill_invocations( + auth_manager: &AuthManager, + base_url: &str, + job: TrackSkillInvocationsJob, +) { let TrackSkillInvocationsJob { - config, + analytics_enabled, tracking, invocations, } = job; @@ -525,7 +523,7 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil thread_id: Some(tracking.thread_id.clone()), invoke_type: Some(invocation.invocation_type), model_slug: Some(tracking.model_slug.clone()), - product_client_id: Some(crate::default_client::originator().value), + product_client_id: Some(originator().value), repo_url, skill_scope: Some(skill_scope.to_string()), }, @@ -533,12 +531,16 @@ async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkil )); } - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMentionedJob) { +async fn send_track_app_mentioned( + auth_manager: &AuthManager, + base_url: &str, + job: TrackAppMentionedJob, +) { let TrackAppMentionedJob { - config, + analytics_enabled, tracking, mentions, } = job; @@ -553,12 +555,12 @@ async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMenti }) .collect::>(); - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) { +async fn send_track_app_used(auth_manager: &AuthManager, base_url: &str, job: TrackAppUsedJob) { let TrackAppUsedJob { - config, + analytics_enabled, tracking, app, } = job; @@ -568,12 +570,16 @@ async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) { event_params, })]; - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsedJob) { +async fn send_track_plugin_used( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginUsedJob, +) { let TrackPluginUsedJob { - config, + analytics_enabled, tracking, plugin, } = job; @@ -582,31 +588,52 @@ async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsed event_params: codex_plugin_used_metadata(&tracking, plugin), })]; - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } -async fn send_track_plugin_installed(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_installed").await; +async fn send_track_plugin_installed( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_installed").await; } -async fn send_track_plugin_uninstalled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_uninstalled").await; +async fn send_track_plugin_uninstalled( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_uninstalled") + .await; } -async fn send_track_plugin_enabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_enabled").await; +async fn send_track_plugin_enabled( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_enabled").await; } -async fn send_track_plugin_disabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) { - send_track_plugin_management_event(auth_manager, job, "codex_plugin_disabled").await; +async fn send_track_plugin_disabled( + auth_manager: &AuthManager, + base_url: &str, + job: TrackPluginManagementJob, +) { + send_track_plugin_management_event(auth_manager, base_url, job, "codex_plugin_disabled").await; } async fn send_track_plugin_management_event( auth_manager: &AuthManager, + base_url: &str, job: TrackPluginManagementJob, event_type: &'static str, ) { - let TrackPluginManagementJob { config, plugin } = job; + let TrackPluginManagementJob { + analytics_enabled, + plugin, + } = job; let event_params = codex_plugin_metadata(plugin); let event = CodexPluginEventRequest { event_type, @@ -620,7 +647,7 @@ async fn send_track_plugin_management_event( _ => unreachable!("unknown plugin management event type"), }]; - send_track_events(auth_manager, config, events).await; + send_track_events(auth_manager, analytics_enabled, base_url, events).await; } fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata { @@ -629,7 +656,7 @@ fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> Code thread_id: Some(tracking.thread_id.clone()), turn_id: Some(tracking.turn_id.clone()), app_name: app.app_name, - product_client_id: Some(crate::default_client::originator().value), + product_client_id: Some(originator().value), invoke_type: app.invocation_type, model_slug: Some(tracking.model_slug.clone()), } @@ -654,7 +681,7 @@ fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata .map(|connector_id| connector_id.0) .collect() }), - product_client_id: Some(crate::default_client::originator().value), + product_client_id: Some(originator().value), } } @@ -672,9 +699,13 @@ fn codex_plugin_used_metadata( async fn send_track_events( auth_manager: &AuthManager, - config: Arc, + analytics_enabled: Option, + base_url: &str, events: Vec, ) { + if analytics_enabled == Some(false) { + return; + } if events.is_empty() { return; } @@ -692,7 +723,7 @@ async fn send_track_events( return; }; - let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let base_url = base_url.trim_end_matches('/'); let url = format!("{base_url}/codex/analytics-events/events"); let payload = TrackEventsRequest { events }; diff --git a/codex-rs/core/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs similarity index 95% rename from codex-rs/core/src/analytics_client_tests.rs rename to codex-rs/analytics/src/analytics_client_tests.rs index a038aae047..f64da8f589 100644 --- a/codex-rs/core/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -11,10 +11,11 @@ use super::codex_app_metadata; use super::codex_plugin_metadata; use super::codex_plugin_used_metadata; use super::normalize_path_for_skill_id; -use crate::plugins::AppConnectorId; -use crate::plugins::PluginCapabilitySummary; -use crate::plugins::PluginId; -use crate::plugins::PluginTelemetryMetadata; +use codex_login::default_client::originator; +use codex_plugin::AppConnectorId; +use codex_plugin::PluginCapabilitySummary; +use codex_plugin::PluginId; +use codex_plugin::PluginTelemetryMetadata; use pretty_assertions::assert_eq; use serde_json::json; use std::collections::HashSet; @@ -109,7 +110,7 @@ fn app_mentioned_event_serializes_expected_shape() { "thread_id": "thread-1", "turn_id": "turn-1", "app_name": "Calendar", - "product_client_id": crate::default_client::originator().value, + "product_client_id": originator().value, "invoke_type": "explicit", "model_slug": "gpt-5" } @@ -147,7 +148,7 @@ fn app_used_event_serializes_expected_shape() { "thread_id": "thread-2", "turn_id": "turn-2", "app_name": "Google Drive", - "product_client_id": crate::default_client::originator().value, + "product_client_id": originator().value, "invoke_type": "implicit", "model_slug": "gpt-5" } @@ -210,7 +211,7 @@ fn plugin_used_event_serializes_expected_shape() { "has_skills": true, "mcp_server_count": 2, "connector_ids": ["calendar", "drive"], - "product_client_id": crate::default_client::originator().value, + "product_client_id": originator().value, "thread_id": "thread-3", "turn_id": "turn-3", "model_slug": "gpt-5" @@ -239,7 +240,7 @@ fn plugin_management_event_serializes_expected_shape() { "has_skills": true, "mcp_server_count": 2, "connector_ids": ["calendar", "drive"], - "product_client_id": crate::default_client::originator().value + "product_client_id": originator().value } }) ); diff --git a/codex-rs/analytics/src/lib.rs b/codex-rs/analytics/src/lib.rs new file mode 100644 index 0000000000..e36f5d71b5 --- /dev/null +++ b/codex-rs/analytics/src/lib.rs @@ -0,0 +1,8 @@ +mod analytics_client; + +pub use analytics_client::AnalyticsEventsClient; +pub use analytics_client::AppInvocation; +pub use analytics_client::InvocationType; +pub use analytics_client::SkillInvocation; +pub use analytics_client::TrackEventsContext; +pub use analytics_client::build_track_events_context; diff --git a/codex-rs/app-server/src/config_api.rs b/codex-rs/app-server/src/config_api.rs index 4aa2982a81..2be0bb859c 100644 --- a/codex-rs/app-server/src/config_api.rs +++ b/codex-rs/app-server/src/config_api.rs @@ -450,6 +450,8 @@ fn config_write_error(code: ConfigWriteErrorCode, message: impl Into) -> mod tests { use super::*; use codex_core::AnalyticsEventsClient; + use codex_core::AuthManager; + use codex_core::CodexAuth; use codex_core::config_loader::NetworkRequirementsToml as CoreNetworkRequirementsToml; use codex_features::Feature; use codex_protocol::protocol::AskForApproval as CoreAskForApproval; @@ -651,6 +653,7 @@ mod tests { .await .expect("load analytics config"), ); + let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test")); let config_api = ConfigApi::new( codex_home.path().to_path_buf(), Arc::new(RwLock::new(Vec::new())), @@ -659,10 +662,12 @@ mod tests { Arc::new(RwLock::new(CloudRequirementsLoader::default())), reloader.clone(), AnalyticsEventsClient::new( - analytics_config, - codex_core::test_support::auth_manager_from_auth( - codex_core::CodexAuth::from_api_key("test"), - ), + auth_manager, + analytics_config + .chatgpt_base_url + .trim_end_matches('/') + .to_string(), + analytics_config.analytics_enabled, ), ); diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index d070738fba..feca709513 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -222,8 +222,11 @@ impl MessageProcessor { auth_manager.set_external_auth_refresher(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); - let analytics_events_client = - AnalyticsEventsClient::new(Arc::clone(&config), Arc::clone(&auth_manager)); + let analytics_events_client = AnalyticsEventsClient::new( + Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, + ); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 9e01e6662f..42ca739fa2 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -27,6 +27,7 @@ bm25 = { workspace = true } chardetng = { workspace = true } chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } +codex-analytics = { workspace = true } codex-api = { workspace = true } codex-app-server-protocol = { workspace = true } codex-apply-patch = { workspace = true } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 1e874b4d66..2684cc90bf 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -12,10 +12,6 @@ use crate::SandboxState; use crate::agent::AgentControl; use crate::agent::AgentStatus; use crate::agent::agent_status_from_event; -use crate::analytics_client::AnalyticsEventsClient; -use crate::analytics_client::AppInvocation; -use crate::analytics_client::InvocationType; -use crate::analytics_client::build_track_events_context; use crate::apps::render_apps_section; use crate::auth_env_telemetry::collect_auth_env_telemetry; use crate::commit_attribution::commit_message_trailer_instruction; @@ -349,6 +345,10 @@ use crate::turn_timing::record_turn_ttft_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::util::backoff; use crate::windows_sandbox::WindowsSandboxLevelExt; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AppInvocation; +use codex_analytics::InvocationType; +use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; use codex_git_utils::get_git_repo_root; use codex_otel::SessionTelemetry; @@ -1844,8 +1844,9 @@ impl Session { shell_zsh_path: config.zsh_path.clone(), main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, ), hooks, rollout: Mutex::new(rollout_recorder), diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 34399a000c..82371f5be7 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2668,8 +2668,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, ), hooks: Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), @@ -3503,8 +3504,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( shell_zsh_path: None, main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(), analytics_events_client: AnalyticsEventsClient::new( - Arc::clone(&config), Arc::clone(&auth_manager), + config.chatgpt_base_url.trim_end_matches('/').to_string(), + config.analytics_enabled, ), hooks: Hooks::new(HooksConfig { legacy_notify_argv: config.notify.clone(), diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 71f4328aed..b4426c9cdf 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -5,7 +5,6 @@ // the TUI or the tracing stack). #![deny(clippy::print_stdout, clippy::print_stderr)] -mod analytics_client; pub mod api_bridge; mod apply_patch; mod apps; @@ -108,9 +107,9 @@ pub type NewConversation = NewThread; #[deprecated(note = "use CodexThread")] pub type CodexConversation = CodexThread; // Re-export common auth types for workspace consumers -pub use analytics_client::AnalyticsEventsClient; pub use auth::AuthManager; pub use auth::CodexAuth; +pub use codex_analytics::AnalyticsEventsClient; mod default_client_forwarding; /// Default Codex HTTP client headers and reqwest construction. diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 18a4843578..d31696db5c 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -8,9 +8,6 @@ use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use tracing::error; -use crate::analytics_client::AppInvocation; -use crate::analytics_client::InvocationType; -use crate::analytics_client::build_track_events_context; use crate::arc_monitor::ArcMonitorOutcome; use crate::arc_monitor::monitor_action; use crate::codex::Session; @@ -32,6 +29,9 @@ use crate::protocol::McpInvocation; use crate::protocol::McpToolCallBeginEvent; use crate::protocol::McpToolCallEndEvent; use crate::state_db; +use codex_analytics::AppInvocation; +use codex_analytics::InvocationType; +use codex_analytics::build_track_events_context; use codex_features::Feature; use codex_protocol::mcp::CallToolResult; use codex_protocol::openai_models::InputModality; diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core/src/skills/injection.rs index b83be2322c..dac05f8368 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core/src/skills/injection.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; -use crate::analytics_client::AnalyticsEventsClient; -use crate::analytics_client::InvocationType; -use crate::analytics_client::SkillInvocation; -use crate::analytics_client::TrackEventsContext; use crate::instructions::SkillInstructions; use crate::mention_syntax::TOOL_MENTION_SIGIL; use crate::mentions::build_skill_name_counts; use crate::skills::SkillMetadata; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::InvocationType; +use codex_analytics::SkillInvocation; +use codex_analytics::TrackEventsContext; use codex_otel::SessionTelemetry; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core/src/skills/invocation_utils.rs index 122158bda7..084670fa86 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core/src/skills/invocation_utils.rs @@ -2,13 +2,13 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use crate::analytics_client::InvocationType; -use crate::analytics_client::SkillInvocation; -use crate::analytics_client::build_track_events_context; use crate::codex::Session; use crate::codex::TurnContext; use crate::skills::SkillLoadOutcome; use crate::skills::SkillMetadata; +use codex_analytics::InvocationType; +use codex_analytics::SkillInvocation; +use codex_analytics::build_track_events_context; pub(crate) fn build_implicit_skill_path_indexes( skills: Vec, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index f12f7ef971..ad980db1a7 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -4,7 +4,6 @@ use std::sync::Arc; use crate::AuthManager; use crate::RolloutRecorder; use crate::agent::AgentControl; -use crate::analytics_client::AnalyticsEventsClient; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; @@ -20,6 +19,7 @@ use crate::tools::network_approval::NetworkApprovalService; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; +use codex_analytics::AnalyticsEventsClient; use codex_exec_server::Environment; use codex_hooks::Hooks; use codex_otel::SessionTelemetry; From 6566ab7e021b7138672757538407ebd070f2a4ae Mon Sep 17 00:00:00 2001 From: evawong-oai Date: Wed, 25 Mar 2026 11:40:43 -0700 Subject: [PATCH 43/50] Clarify codex_home base for MDM path resolution (#15707) ## Summary Add the follow up code comment Michael asked for at the MDM `managed_config_from_mdm` - a follow up from https://github.com/openai/codex/pull/15351. ## Validation 1. `cargo fmt --all --check` 2. `cargo test -p codex-core managed_preferences_expand_home_directory_in_workspace_write_roots -- --nocapture` 3. `cargo test -p codex-core write_value_succeeds_when_managed_preferences_expand_home_directory_paths -- --nocapture` 4. `./tools/argument-comment-lint/run-prebuilt-linter.sh -p codex-core` --- codex-rs/core/src/config_loader/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index 767ffe635d..dab130dad1 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -285,6 +285,11 @@ pub async fn load_config_layers_state( )); } if let Some(config) = managed_config_from_mdm { + // As a general rule, config from MDM should _not_ include relative + // paths, starting with `./`, but a path starting with `~/` _is_ a + // supported use case. Because resolve_relative_paths_in_config_toml() + // relies on AbsolutePathBufGuard to resolve `~/`, we must supply a + // value for base_dir, so codex_home is as good a value as any. let managed_config = resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; layers.push(ConfigLayerEntry::new_with_raw_toml( From 79359fb5e78d470c69a7b6e82291608ed685c3bb Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 25 Mar 2026 15:50:42 -0300 Subject: [PATCH 44/50] fix(tui_app_server): fix remote subagent switching and agent names (#15513) ## TL;DR This PR changes the `tui_app_server` _path_ in the following ways: - add missing feature to show agent names (shows only UUIDs today) - add `Cmd/Alt+Arrows` navigation between agent conversations ## Problem When the TUI connects to a remote app server, collab agent tool-call items (spawn, wait, delegate, etc.) render thread UUIDs instead of human-readable agent names because the `ChatWidget` never receives nickname/role metadata for receiver threads. Separately, keyboard next/previous agent navigation silently does nothing when the local `AgentNavigationState` cache has not yet been populated with subagent threads that the remote server already knows about. Both issues share a root cause: in the remote (app-server) code path the TUI never proactively fetches thread metadata. In the local code path this metadata arrives naturally via spawn events the TUI itself orchestrates, but in the remote path those events were processed by a different client and the TUI only sees the resulting collab tool-call notifications. ## Mental model Collab agent tool-call notifications reference receiver threads by id, but carry no nickname or role. The TUI needs that metadata in two places: 1. **Rendering** -- `ChatWidget` converts `CollabAgentToolCall` items into history cells. Without metadata, agent status lines show raw UUIDs. 2. **Navigation** -- `AgentNavigationState` tracks known threads for the `/agent` picker and keyboard cycling. Without entries for remote subagents, next/previous has nowhere to go. This change closes the gap with two complementary strategies: - **Eager hydration**: when any notification carries `receiver_thread_ids`, the TUI fetches metadata (`thread/read`) for threads it has not yet cached before the notification is rendered. - **Backfill on thread switch**: when the user resumes, forks, or starts a new app-server thread, the TUI fetches the full `thread/loaded/list`, walks the parent-child spawn tree, and registers every descendant subagent in both the navigation cache and the `ChatWidget` metadata map. A new `collab_agent_metadata` side-table in `ChatWidget` stores nickname/role keyed by `ThreadId`, kept in sync by `App` whenever it calls `upsert_agent_picker_thread`. The `replace_chat_widget` helper re-seeds this map from `AgentNavigationState` so that thread switches (which reconstruct the widget) do not lose previously discovered metadata. ## Non-goals - This change does not alter the local (non-app-server) collab code path. That path already receives metadata via spawn events and is unaffected. - No new protocol messages are introduced. The change uses existing `thread/read` and `thread/loaded/list` RPCs. - No changes to how `AgentNavigationState` orders or cycles through threads. The traversal logic is unchanged; only the population of entries is extended. ## Tradeoffs - **Extra RPCs on notification path**: `hydrate_collab_agent_metadata_for_notification` issues a `thread/read` for each unknown receiver thread before the notification is forwarded to rendering. This adds latency on the notification path but only fires once per thread (the result is cached). The alternative -- rendering first and backfilling names later -- would cause visible flicker as UUIDs are replaced with names. - **Backfill fetches all loaded threads**: `backfill_loaded_subagent_threads` fetches the full loaded-thread list and walks the spawn tree even when the user may only care about one subagent. This is simple and correct but O(loaded_threads) per thread switch. For typical session sizes this is negligible; it could become a concern for sessions with hundreds of subagents. - **Metadata duplication**: agent nickname/role is now stored in both `AgentNavigationState` (for picker/label) and `ChatWidget::collab_agent_metadata` (for rendering). The two are kept in sync through `upsert_agent_picker_thread` and `replace_chat_widget`, but there is no compile-time enforcement of this coupling. ## Architecture ### New module: `app::loaded_threads` Pure function `find_loaded_subagent_threads_for_primary` that takes a flat list of `Thread` objects and a primary thread id, then walks the `SessionSource::SubAgent` parent-child edges to collect all transitive descendants. Returns a sorted vec of `LoadedSubagentThread` (thread_id + nickname + role). No async, no side effects -- designed for unit testing. ### New methods on `App` | Method | Purpose | |--------|---------| | `collab_receiver_thread_ids` | Extracts `receiver_thread_ids` from `ItemStarted` / `ItemCompleted` collab notifications | | `hydrate_collab_agent_metadata_for_notification` | Fetches and caches metadata for unknown receiver threads before a notification is rendered | | `backfill_loaded_subagent_threads` | Bulk-fetches all loaded threads and registers descendants of the primary thread | | `adjacent_thread_id_with_backfill` | Attempts navigation, falls back to backfill if the cache has no adjacent entry | | `replace_chat_widget` | Replaces the widget and re-seeds its metadata map from `AgentNavigationState` | ### New state in `ChatWidget` `collab_agent_metadata: HashMap` -- a lookup table that rendering functions consult to attach human-readable names to collab tool-call items. Populated externally by `App` via `set_collab_agent_metadata`. ### New method on `AppServerSession` `thread_loaded_list` -- thin wrapper around `ClientRequest::ThreadLoadedList`. ## Observability - `tracing::warn` on invalid thread ids during hydration and backfill. - `tracing::warn` on failed `thread/read` or `thread/loaded/list` RPCs (with thread id and error). - No new metrics or feature flags. ## Tests - **`loaded_threads::tests::finds_loaded_subagent_tree_for_primary_thread`** -- unit test for the spawn-tree walk: verifies child and grandchild are included, unrelated threads are excluded, and metadata is carried through. - **`app::tests::replace_chat_widget_reseeds_collab_agent_metadata_for_replay`** -- integration test that creates a `ChatWidget`, replaces it via `replace_chat_widget`, replays a collab wait notification, and asserts the rendered history cell contains the agent name rather than a UUID. - **Updated snapshot** `app_server_collab_wait_items_render_history` -- the existing collab wait rendering test now sets metadata before sending notifications, so the snapshot shows `Robie [explorer]` / `Ada [reviewer]` instead of raw thread ids. --------- Co-authored-by: Eric Traut --- codex-rs/tui_app_server/src/app.rs | 331 +++++++++++++++++- .../tui_app_server/src/app/loaded_threads.rs | 208 +++++++++++ .../tui_app_server/src/app_server_session.rs | 18 + codex-rs/tui_app_server/src/chatwidget.rs | 128 ++++++- ...rver_collab_wait_items_render_history.snap | 8 +- .../tui_app_server/src/chatwidget/tests.rs | 11 + 6 files changed, 672 insertions(+), 32 deletions(-) create mode 100644 codex-rs/tui_app_server/src/app/loaded_threads.rs diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index e00d5ebc05..3ec29039de 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -69,6 +69,8 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SkillsListResponse; +use codex_app_server_protocol::ThreadItem; +use codex_app_server_protocol::ThreadLoadedListParams; use codex_app_server_protocol::ThreadRollbackResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnError as AppServerTurnError; @@ -143,11 +145,13 @@ use uuid::Uuid; mod agent_navigation; mod app_server_adapter; mod app_server_requests; +mod loaded_threads; mod pending_interactive_replay; use self::agent_navigation::AgentNavigationDirection; use self::agent_navigation::AgentNavigationState; use self::app_server_requests::PendingAppServerRequests; +use self::loaded_threads::find_loaded_subagent_threads_for_primary; use self::pending_interactive_replay::PendingInteractiveReplayState; const EXTERNAL_EDITOR_HINT: &str = "Save and close external editor to continue."; @@ -200,6 +204,30 @@ fn command_execution_decision_to_review_decision( } } +/// Extracts `receiver_thread_ids` from collab agent tool-call notifications. +/// +/// Only `ItemStarted` and `ItemCompleted` notifications with a `CollabAgentToolCall` item carry +/// receiver thread ids. All other notification variants return `None`. +fn collab_receiver_thread_ids(notification: &ServerNotification) -> Option<&[String]> { + match notification { + ServerNotification::ItemStarted(notification) => match ¬ification.item { + ThreadItem::CollabAgentToolCall { + receiver_thread_ids, + .. + } => Some(receiver_thread_ids), + _ => None, + }, + ServerNotification::ItemCompleted(notification) => match ¬ification.item { + ThreadItem::CollabAgentToolCall { + receiver_thread_ids, + .. + } => Some(receiver_thread_ids), + _ => None, + }, + _ => None, + } +} + fn convert_via_json(value: T) -> Option where T: serde::Serialize, @@ -951,6 +979,7 @@ pub(crate) struct App { active_thread_id: Option, active_thread_rx: Option>, primary_thread_id: Option, + last_subagent_backfill_attempt: Option, primary_session_configured: Option, pending_primary_events: VecDeque, pending_app_server_requests: PendingAppServerRequests, @@ -2265,6 +2294,67 @@ impl App { Ok(()) } + /// Eagerly fetches nickname and role for receiver threads referenced by a collab notification. + /// + /// This runs on every buffered thread notification before it reaches rendering. For each + /// receiver thread id that the navigation cache does not yet have metadata for, it issues a + /// `thread/read` RPC and registers the result in both `AgentNavigationState` and the + /// `ChatWidget` metadata map. Threads that already have a nickname or role cached are skipped, + /// so the cost is at most one RPC per thread over the lifetime of a session. + /// + /// Failures are logged and silently ignored -- the worst outcome is that a rendered item shows + /// a thread id instead of a human-readable name, which is the same behavior the TUI had before + /// this change. + async fn hydrate_collab_agent_metadata_for_notification( + &mut self, + app_server: &mut AppServerSession, + notification: &ServerNotification, + ) { + let Some(receiver_thread_ids) = collab_receiver_thread_ids(notification) else { + return; + }; + + for receiver_thread_id in receiver_thread_ids { + let Ok(thread_id) = ThreadId::from_string(receiver_thread_id) else { + tracing::warn!( + thread_id = receiver_thread_id, + "ignoring collab receiver with invalid thread id during metadata hydration" + ); + continue; + }; + + if self + .agent_navigation + .get(&thread_id) + .is_some_and(|entry| entry.agent_nickname.is_some() || entry.agent_role.is_some()) + { + continue; + } + + match app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + { + Ok(thread) => { + self.ensure_thread_channel(thread_id); + self.upsert_agent_picker_thread( + thread_id, + thread.agent_nickname, + thread.agent_role, + /*is_closed*/ false, + ); + } + Err(err) => { + tracing::warn!( + thread_id = %thread_id, + error = %err, + "failed to hydrate collab receiver thread metadata" + ); + } + } + } + } + async fn infer_session_for_thread_notification( &mut self, thread_id: ThreadId, @@ -2602,6 +2692,11 @@ impl App { agent_role: Option, is_closed: bool, ) { + self.chat_widget.set_collab_agent_metadata( + thread_id, + agent_nickname.clone(), + agent_role.clone(), + ); self.agent_navigation .upsert(thread_id, agent_nickname, agent_role, is_closed); self.sync_active_agent_label(); @@ -2616,6 +2711,24 @@ impl App { self.sync_active_agent_label(); } + /// Replaces the chat widget and re-seeds the new widget's collab metadata from the navigation + /// cache. + /// + /// Thread switches reconstruct the `ChatWidget`, which loses the `collab_agent_metadata` map. + /// This helper copies every known nickname/role from `AgentNavigationState` into the + /// replacement widget so that replayed collab items render agent names immediately. + fn replace_chat_widget(&mut self, mut chat_widget: ChatWidget) { + for (thread_id, entry) in self.agent_navigation.ordered_threads() { + chat_widget.set_collab_agent_metadata( + thread_id, + entry.agent_nickname.clone(), + entry.agent_role.clone(), + ); + } + self.chat_widget = chat_widget; + self.sync_active_agent_label(); + } + async fn select_agent_thread( &mut self, tui: &mut tui::Tui, @@ -2661,8 +2774,7 @@ impl App { self.active_thread_rx = Some(receiver); let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - self.chat_widget = ChatWidget::new_with_app_event(init); - self.sync_active_agent_label(); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.reset_for_thread_switch(tui)?; self.replay_thread_snapshot(snapshot, !is_replay_only); @@ -2697,6 +2809,7 @@ impl App { self.active_thread_id = None; self.active_thread_rx = None; self.primary_thread_id = None; + self.last_subagent_backfill_attempt = None; self.primary_session_configured = None; self.pending_primary_events.clear(); self.pending_app_server_requests.clear(); @@ -2732,7 +2845,7 @@ impl App { match app_server.start_thread(&config).await { Ok(started) => { if let Err(err) = self - .replace_chat_widget_with_app_server_thread(tui, started) + .replace_chat_widget_with_app_server_thread(tui, app_server, started) .await { self.chat_widget.add_error_message(format!( @@ -2760,13 +2873,119 @@ impl App { async fn replace_chat_widget_with_app_server_thread( &mut self, tui: &mut tui::Tui, + app_server: &mut AppServerSession, started: AppServerStartedThread, ) -> Result<()> { - let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); - self.chat_widget = ChatWidget::new_with_app_event(init); self.reset_thread_event_state(); + let init = self.chatwidget_init_for_forked_or_resumed_thread(tui, self.config.clone()); + self.replace_chat_widget(ChatWidget::new_with_app_event(init)); self.enqueue_primary_thread_session(started.session, started.turns) + .await?; + self.backfill_loaded_subagent_threads(app_server).await; + Ok(()) + } + + /// Fetches all loaded threads from the app server and registers descendants of the primary + /// thread in the navigation cache and chat widget metadata. + /// + /// Called after `replace_chat_widget_with_app_server_thread` during resume, fork, and new + /// thread creation so that the `/agent` picker and keyboard navigation are pre-populated even + /// if the TUI did not witness the original spawn events. + /// + /// The loaded-thread list is fetched in full (no pagination) and the spawn tree is walked + /// by `find_loaded_subagent_threads_for_primary`. Each discovered subagent is registered via + /// `upsert_agent_picker_thread`, which writes to both `AgentNavigationState` and the + /// `ChatWidget` metadata map. + async fn backfill_loaded_subagent_threads( + &mut self, + app_server: &mut AppServerSession, + ) -> bool { + let Some(primary_thread_id) = self.primary_thread_id else { + return false; + }; + + let loaded_thread_ids = match app_server + .thread_loaded_list(ThreadLoadedListParams { + cursor: None, + limit: None, + }) .await + { + Ok(response) => response.data, + Err(err) => { + tracing::warn!(%err, "failed to list loaded threads for subagent backfill"); + return false; + } + }; + + let mut threads = Vec::new(); + let mut had_read_error = false; + for thread_id in loaded_thread_ids { + let Ok(thread_id) = ThreadId::from_string(&thread_id) else { + tracing::warn!("ignoring loaded thread with invalid id during subagent backfill"); + continue; + }; + + if thread_id == primary_thread_id { + continue; + } + + match app_server + .thread_read(thread_id, /*include_turns*/ false) + .await + { + Ok(thread) => threads.push(thread), + Err(err) => { + had_read_error = true; + tracing::warn!(thread_id = %thread_id, %err, "failed to read loaded thread"); + } + } + } + + for thread in find_loaded_subagent_threads_for_primary(threads, primary_thread_id) { + self.ensure_thread_channel(thread.thread_id); + self.upsert_agent_picker_thread( + thread.thread_id, + thread.agent_nickname, + thread.agent_role, + /*is_closed*/ false, + ); + } + + !had_read_error + } + + /// Returns the adjacent thread id for keyboard navigation, backfilling from the server if the + /// local cache has no neighbor. + /// + /// Tries the fast path first: ask `AgentNavigationState` directly. If it returns `None` (no + /// adjacent entry exists, typically because the cache was never populated with remote + /// subagents), performs a full `backfill_loaded_subagent_threads` and retries. This ensures the + /// first next/previous keypress in a resumed remote session discovers subagents on demand + /// without requiring the user to wait for a proactive fetch. + async fn adjacent_thread_id_with_backfill( + &mut self, + app_server: &mut AppServerSession, + direction: AgentNavigationDirection, + ) -> Option { + let current_thread = self.current_displayed_thread_id(); + if let Some(thread_id) = self + .agent_navigation + .adjacent_thread_id(current_thread, direction) + { + return Some(thread_id); + } + + let primary_thread_id = self.primary_thread_id?; + if self.last_subagent_backfill_attempt == Some(primary_thread_id) { + return None; + } + + if self.backfill_loaded_subagent_threads(app_server).await { + self.last_subagent_backfill_attempt = Some(primary_thread_id); + } + self.agent_navigation + .adjacent_thread_id(self.current_displayed_thread_id(), direction) } fn fresh_session_config(&self) -> Config { @@ -3116,6 +3335,7 @@ impl App { active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -3428,7 +3648,9 @@ impl App { self.file_search .update_search_dir(self.config.cwd.to_path_buf()); match self - .replace_chat_widget_with_app_server_thread(tui, resumed) + .replace_chat_widget_with_app_server_thread( + tui, app_server, resumed, + ) .await { Ok(()) => { @@ -3488,7 +3710,7 @@ impl App { Ok(forked) => { self.shutdown_current_thread(app_server).await; match self - .replace_chat_widget_with_app_server_thread(tui, forked) + .replace_chat_widget_with_app_server_thread(tui, app_server, forked) .await { Ok(()) => { @@ -4962,6 +5184,11 @@ impl App { // thread, so unrelated shutdowns cannot consume this marker. self.pending_shutdown_exit_thread_id = None; } + if let ThreadBufferedEvent::Notification(notification) = &event { + self.hydrate_collab_agent_metadata_for_notification(app_server, notification) + .await; + } + self.handle_thread_event_now(event); if self.backtrack_render_pending { tui.frame_requester().schedule_frame(); @@ -5117,10 +5344,10 @@ impl App { && self.chat_widget.composer_text_with_pending().is_empty() && previous_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { - if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( - self.current_displayed_thread_id(), - AgentNavigationDirection::Previous, - ) { + if let Some(thread_id) = self + .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Previous) + .await + { let _ = self.select_agent_thread(tui, app_server, thread_id).await; } return; @@ -5132,10 +5359,10 @@ impl App { && self.chat_widget.composer_text_with_pending().is_empty() && next_agent_shortcut_matches(key_event, allow_agent_word_motion_fallback) { - if let Some(thread_id) = self.agent_navigation.adjacent_thread_id( - self.current_displayed_thread_id(), - AgentNavigationDirection::Next, - ) { + if let Some(thread_id) = self + .adjacent_thread_id_with_backfill(app_server, AgentNavigationDirection::Next) + .await + { let _ = self.select_agent_thread(tui, app_server, thread_id).await; } return; @@ -7864,6 +8091,7 @@ guardian_approval = true active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -7915,6 +8143,7 @@ guardian_approval = true active_thread_id: None, active_thread_rx: None, primary_thread_id: None, + last_subagent_backfill_attempt: None, primary_session_configured: None, pending_primary_events: VecDeque::new(), pending_app_server_requests: PendingAppServerRequests::default(), @@ -9002,6 +9231,78 @@ guardian_approval = true ); } + #[tokio::test] + async fn replace_chat_widget_reseeds_collab_agent_metadata_for_replay() { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let receiver_thread_id = + ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread"); + app.agent_navigation.upsert( + receiver_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + false, + ); + + let replacement = ChatWidget::new_with_app_event(ChatWidgetInit { + config: app.config.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), + app_event_tx: app.app_event_tx.clone(), + initial_user_message: None, + enhanced_keys_supported: app.enhanced_keys_supported, + has_chatgpt_account: app.chat_widget.has_chatgpt_account(), + model_catalog: app.model_catalog.clone(), + feedback: app.feedback.clone(), + is_first_run: false, + feedback_audience: app.feedback_audience, + status_account_display: app.chat_widget.status_account_display().cloned(), + initial_plan_type: app.chat_widget.current_plan_type(), + model: Some(app.chat_widget.current_model().to_string()), + startup_tooltip_override: None, + status_line_invalid_items_warned: app.status_line_invalid_items_warned.clone(), + session_telemetry: app.session_telemetry.clone(), + }); + app.replace_chat_widget(replacement); + + app.replay_thread_snapshot( + ThreadEventSnapshot { + session: None, + turns: Vec::new(), + events: vec![ThreadBufferedEvent::Notification( + ServerNotification::ItemStarted(codex_app_server_protocol::ItemStartedNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + item: ThreadItem::CollabAgentToolCall { + id: "wait-1".to_string(), + tool: codex_app_server_protocol::CollabAgentTool::Wait, + status: codex_app_server_protocol::CollabAgentToolCallStatus::InProgress, + sender_thread_id: ThreadId::new().to_string(), + receiver_thread_ids: vec![receiver_thread_id.to_string()], + prompt: None, + model: None, + reasoning_effort: None, + agents_states: HashMap::new(), + }, + }), + )], + input_state: None, + }, + false, + ); + + let mut saw_named_wait = false; + while let Ok(event) = app_event_rx.try_recv() { + if let AppEvent::InsertHistoryCell(cell) = event { + let transcript = lines_to_single_string(&cell.transcript_lines(80)); + saw_named_wait |= transcript.contains("Robie [explorer]"); + } + } + + assert!( + saw_named_wait, + "expected replayed wait item to keep agent name" + ); + } + #[tokio::test] async fn refreshed_snapshot_session_persists_resumed_turns() { let mut app = make_test_app().await; diff --git a/codex-rs/tui_app_server/src/app/loaded_threads.rs b/codex-rs/tui_app_server/src/app/loaded_threads.rs new file mode 100644 index 0000000000..c6ac09890d --- /dev/null +++ b/codex-rs/tui_app_server/src/app/loaded_threads.rs @@ -0,0 +1,208 @@ +//! Discovers subagent threads that belong to a primary thread by walking spawn-tree edges. +//! +//! When the TUI resumes or switches to an existing thread, it needs to populate +//! `AgentNavigationState` and `ChatWidget` metadata for every subagent that was spawned during +//! that thread's lifetime. The app server exposes a flat list of currently loaded threads via +//! `thread/loaded/list`, but the TUI must figure out which of those are descendants of the +//! primary thread. +//! +//! This module provides the pure, synchronous tree-walk that turns that flat list into the filtered +//! set of descendants. It intentionally has no async, no I/O, and no side effects so it can be +//! unit-tested in isolation. +//! +//! The walk starts from `primary_thread_id` and repeatedly follows +//! `SessionSource::SubAgent(ThreadSpawn { parent_thread_id, .. })` edges until no new children are +//! found. The primary thread itself is never included in the output. + +use codex_app_server_protocol::SessionSource; +use codex_app_server_protocol::Thread; +use codex_protocol::ThreadId; +use codex_protocol::protocol::SubAgentSource; +use std::collections::HashMap; +use std::collections::HashSet; + +/// A subagent thread discovered by the spawn-tree walk, carrying just enough metadata for the +/// TUI to register it in the navigation cache and rendering metadata map. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LoadedSubagentThread { + pub(crate) thread_id: ThreadId, + pub(crate) agent_nickname: Option, + pub(crate) agent_role: Option, +} + +/// Walks the spawn tree rooted at `primary_thread_id` and returns every descendant subagent. +/// +/// The walk is breadth-first over `SessionSource::SubAgent(ThreadSpawn { parent_thread_id })` edges. +/// Threads whose `source` is not a `ThreadSpawn`, or whose `parent_thread_id` does not chain back +/// to `primary_thread_id`, are excluded. The primary thread itself is never included. +/// +/// Results are sorted by stringified thread id for deterministic output in tests and in the +/// navigation cache. Callers should not rely on this ordering for anything semantic; it exists +/// purely to make snapshot assertions stable. +/// +/// If two threads claim the same parent, both are included. Cycles in the parent chain are not +/// possible because `ThreadId`s are server-assigned UUIDs and the server enforces acyclicity, but +/// the `included` set guards against re-visiting regardless. +pub(crate) fn find_loaded_subagent_threads_for_primary( + threads: Vec, + primary_thread_id: ThreadId, +) -> Vec { + let mut threads_by_id = HashMap::new(); + for thread in threads { + let Ok(thread_id) = ThreadId::from_string(&thread.id) else { + continue; + }; + threads_by_id.insert(thread_id, thread); + } + + let mut included = HashSet::new(); + let mut pending = vec![primary_thread_id]; + while let Some(parent_thread_id) = pending.pop() { + for (thread_id, thread) in &threads_by_id { + if included.contains(thread_id) { + continue; + } + + let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: source_parent_thread_id, + .. + }) = &thread.source + else { + continue; + }; + + if *source_parent_thread_id != parent_thread_id { + continue; + } + + included.insert(*thread_id); + pending.push(*thread_id); + } + } + + let mut loaded_threads: Vec = included + .into_iter() + .filter_map(|thread_id| { + threads_by_id + .remove(&thread_id) + .map(|thread| LoadedSubagentThread { + thread_id, + agent_nickname: thread.agent_nickname, + agent_role: thread.agent_role, + }) + }) + .collect(); + loaded_threads.sort_by_key(|thread| thread.thread_id.to_string()); + loaded_threads +} + +#[cfg(test)] +mod tests { + use super::LoadedSubagentThread; + use super::find_loaded_subagent_threads_for_primary; + use codex_app_server_protocol::SessionSource; + use codex_app_server_protocol::Thread; + use codex_app_server_protocol::ThreadStatus; + use codex_protocol::ThreadId; + use codex_protocol::protocol::SubAgentSource; + use pretty_assertions::assert_eq; + use std::path::PathBuf; + + fn test_thread(thread_id: ThreadId, source: SessionSource) -> Thread { + Thread { + id: thread_id.to_string(), + preview: String::new(), + ephemeral: false, + model_provider: "openai".to_string(), + created_at: 0, + updated_at: 0, + status: ThreadStatus::Idle, + path: None, + cwd: PathBuf::from("/tmp"), + cli_version: "0.0.0".to_string(), + source, + agent_nickname: None, + agent_role: None, + git_info: None, + name: None, + turns: Vec::new(), + } + } + + #[test] + fn finds_loaded_subagent_tree_for_primary_thread() { + let primary_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000001").expect("valid thread"); + let child_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000002").expect("valid thread"); + let grandchild_thread_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000003").expect("valid thread"); + let unrelated_parent_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000004").expect("valid thread"); + let unrelated_child_id = + ThreadId::from_string("00000000-0000-0000-0000-000000000005").expect("valid thread"); + + let mut child = test_thread( + child_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: primary_thread_id, + depth: 1, + agent_path: None, + agent_nickname: Some("Scout".to_string()), + agent_role: Some("explorer".to_string()), + }), + ); + child.agent_nickname = Some("Scout".to_string()); + child.agent_role = Some("explorer".to_string()); + + let mut grandchild = test_thread( + grandchild_thread_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: child_thread_id, + depth: 2, + agent_path: None, + agent_nickname: Some("Atlas".to_string()), + agent_role: Some("worker".to_string()), + }), + ); + grandchild.agent_nickname = Some("Atlas".to_string()); + grandchild.agent_role = Some("worker".to_string()); + + let unrelated_child = test_thread( + unrelated_child_id, + SessionSource::SubAgent(SubAgentSource::ThreadSpawn { + parent_thread_id: unrelated_parent_id, + depth: 1, + agent_path: None, + agent_nickname: Some("Other".to_string()), + agent_role: Some("researcher".to_string()), + }), + ); + + let loaded = find_loaded_subagent_threads_for_primary( + vec![ + test_thread(primary_thread_id, SessionSource::Cli), + child, + grandchild, + unrelated_child, + ], + primary_thread_id, + ); + + assert_eq!( + loaded, + vec![ + LoadedSubagentThread { + thread_id: child_thread_id, + agent_nickname: Some("Scout".to_string()), + agent_role: Some("explorer".to_string()), + }, + LoadedSubagentThread { + thread_id: grandchild_thread_id, + agent_nickname: Some("Atlas".to_string()), + agent_role: Some("worker".to_string()), + }, + ] + ); + } +} diff --git a/codex-rs/tui_app_server/src/app_server_session.rs b/codex-rs/tui_app_server/src/app_server_session.rs index ecdea588ee..bd34404913 100644 --- a/codex-rs/tui_app_server/src/app_server_session.rs +++ b/codex-rs/tui_app_server/src/app_server_session.rs @@ -27,6 +27,8 @@ use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_app_server_protocol::ThreadLoadedListParams; +use codex_app_server_protocol::ThreadLoadedListResponse; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadRealtimeAppendAudioParams; @@ -354,6 +356,22 @@ impl AppServerSession { .wrap_err("thread/list failed during TUI session lookup") } + /// Lists thread ids that the app server currently holds in memory. + /// + /// Used by `App::backfill_loaded_subagent_threads` to discover subagent threads that were + /// spawned before the TUI connected. The caller then fetches full metadata per thread via + /// `thread_read` and walks the spawn tree. + pub(crate) async fn thread_loaded_list( + &mut self, + params: ThreadLoadedListParams, + ) -> Result { + let request_id = self.next_request_id(); + self.client + .request_typed(ClientRequest::ThreadLoadedList { request_id, params }) + .await + .wrap_err("failed to list loaded threads from app server") + } + pub(crate) async fn thread_read( &mut self, thread_id: ThreadId, diff --git a/codex-rs/tui_app_server/src/chatwidget.rs b/codex-rs/tui_app_server/src/chatwidget.rs index f3c272d62b..0aea665b67 100644 --- a/codex-rs/tui_app_server/src/chatwidget.rs +++ b/codex-rs/tui_app_server/src/chatwidget.rs @@ -140,6 +140,7 @@ use codex_protocol::protocol::ApplyPatchApprovalRequestEvent; use codex_protocol::protocol::BackgroundEventEvent; #[cfg(test)] use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; +use codex_protocol::protocol::CollabAgentRef; #[cfg(test)] use codex_protocol::protocol::CollabAgentSpawnBeginEvent; use codex_protocol::protocol::CollabAgentStatusEntry; @@ -757,6 +758,7 @@ pub(crate) struct ChatWidget { // Latest completed user-visible Codex output that `/copy` should place on the clipboard. last_copyable_output: Option, running_commands: HashMap, + collab_agent_metadata: HashMap, pending_collab_spawn_requests: HashMap, suppressed_exec_calls: HashSet, skills_all: Vec, @@ -904,6 +906,18 @@ pub(crate) struct ChatWidget { last_non_retry_error: Option<(String, String)>, } +/// Cached nickname and role for a collab agent thread, used to attach human-readable labels to +/// rendered tool-call items. +/// +/// Populated externally by `App` via `set_collab_agent_metadata` and consulted by the +/// notification-to-core-event conversion helpers. Defaults to empty so that missing metadata +/// degrades to the previous behavior of showing raw thread ids. +#[derive(Clone, Debug, Default)] +struct CollabAgentMetadata { + agent_nickname: Option, + agent_role: Option, +} + #[cfg_attr(not(test), allow(dead_code))] enum CodexOpTarget { Direct(UnboundedSender), @@ -1379,9 +1393,12 @@ fn app_server_collab_state_to_core(state: &AppServerCollabAgentState) -> AgentSt } } +/// Converts app-server collab agent states into the core protocol representation, enriching each +/// entry with cached nickname and role metadata so rendered items show human-readable names. fn app_server_collab_agent_statuses_to_core( receiver_thread_ids: &[String], agents_states: &HashMap, + collab_agent_metadata: &HashMap, ) -> (Vec, HashMap) { let mut agent_statuses = Vec::new(); let mut statuses = HashMap::new(); @@ -1394,10 +1411,14 @@ fn app_server_collab_agent_statuses_to_core( continue; }; let status = app_server_collab_state_to_core(agent_state); + let metadata = collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default(); agent_statuses.push(CollabAgentStatusEntry { thread_id, - agent_nickname: None, - agent_role: None, + agent_nickname: metadata.agent_nickname, + agent_role: metadata.agent_role, status: status.clone(), }); statuses.insert(thread_id, status); @@ -1406,6 +1427,31 @@ fn app_server_collab_agent_statuses_to_core( (agent_statuses, statuses) } +/// Builds `CollabAgentRef` entries for every valid receiver thread, attaching cached metadata. +/// +/// Used when converting collab `Wait` tool-call items so the rendered waiting list shows agent +/// names instead of bare thread ids. +fn app_server_collab_receiver_agent_refs( + receiver_thread_ids: &[String], + collab_agent_metadata: &HashMap, +) -> Vec { + receiver_thread_ids + .iter() + .filter_map(|thread_id| { + let thread_id = app_server_collab_thread_id_to_core(thread_id)?; + let metadata = collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default(); + Some(CollabAgentRef { + thread_id, + agent_nickname: metadata.agent_nickname, + agent_role: metadata.agent_role, + }) + }) + .collect() +} + fn request_permissions_from_params( params: codex_app_server_protocol::PermissionsRequestApprovalParams, ) -> RequestPermissionsEvent { @@ -1489,6 +1535,35 @@ fn web_search_action_to_core( } impl ChatWidget { + /// Stores or overwrites the cached nickname and role for a collab agent thread. + /// + /// Called by `App::upsert_agent_picker_thread` and `App::replace_chat_widget` to keep the + /// rendering metadata in sync with the navigation cache. Must be called before any + /// notification referencing this thread is processed, otherwise the rendered item will fall + /// back to showing the raw thread id. + pub(crate) fn set_collab_agent_metadata( + &mut self, + thread_id: ThreadId, + agent_nickname: Option, + agent_role: Option, + ) { + self.collab_agent_metadata.insert( + thread_id, + CollabAgentMetadata { + agent_nickname, + agent_role, + }, + ); + } + + /// Returns the cached metadata for a thread, defaulting to empty if none has been registered. + fn collab_agent_metadata(&self, thread_id: ThreadId) -> CollabAgentMetadata { + self.collab_agent_metadata + .get(&thread_id) + .cloned() + .unwrap_or_default() + } + fn realtime_conversation_enabled(&self) -> bool { self.config.features.enabled(Feature::RealtimeConversation) && cfg!(not(target_os = "linux")) @@ -3443,6 +3518,8 @@ impl ChatWidget { let first_receiver = receiver_thread_ids .first() .and_then(|thread_id| app_server_collab_thread_id_to_core(thread_id)); + let first_receiver_metadata = + first_receiver.map(|thread_id| self.collab_agent_metadata(thread_id)); match tool { CollabAgentTool::SpawnAgent => { @@ -3473,8 +3550,12 @@ impl ChatWidget { call_id: id, sender_thread_id, new_thread_id: first_receiver, - new_agent_nickname: None, - new_agent_role: None, + new_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + new_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), prompt: prompt.unwrap_or_default(), model: String::new(), reasoning_effort: ReasoningEffortConfig::Medium, @@ -3499,8 +3580,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), prompt: prompt.unwrap_or_default(), status: receiver_thread_ids .iter() @@ -3521,8 +3606,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), }, )); } else { @@ -3531,8 +3620,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), status: receiver_thread_ids .iter() .find_map(|thread_id| agents_states.get(thread_id)) @@ -3556,7 +3649,10 @@ impl ChatWidget { app_server_collab_thread_id_to_core(thread_id) }) .collect(), - receiver_agents: Vec::new(), + receiver_agents: app_server_collab_receiver_agent_refs( + &receiver_thread_ids, + &self.collab_agent_metadata, + ), call_id: id, }, )); @@ -3564,6 +3660,7 @@ impl ChatWidget { let (agent_statuses, statuses) = app_server_collab_agent_statuses_to_core( &receiver_thread_ids, &agents_states, + &self.collab_agent_metadata, ); self.on_collab_event(multi_agents::waiting_end( codex_protocol::protocol::CollabWaitingEndEvent { @@ -3584,8 +3681,12 @@ impl ChatWidget { call_id: id, sender_thread_id, receiver_thread_id, - receiver_agent_nickname: None, - receiver_agent_role: None, + receiver_agent_nickname: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_nickname.clone()), + receiver_agent_role: first_receiver_metadata + .as_ref() + .and_then(|metadata| metadata.agent_role.clone()), status: receiver_thread_ids .iter() .find_map(|thread_id| agents_states.get(thread_id)) @@ -4319,6 +4420,7 @@ impl ChatWidget { plan_stream_controller: None, last_copyable_output: None, running_commands: HashMap::new(), + collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), suppressed_exec_calls: HashSet::new(), last_unified_wait: None, diff --git a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap index a5b90d0e98..17c08589ad 100644 --- a/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap +++ b/codex-rs/tui_app_server/src/chatwidget/snapshots/codex_tui_app_server__chatwidget__tests__app_server_collab_wait_items_render_history.snap @@ -3,10 +3,10 @@ source: tui_app_server/src/chatwidget/tests.rs expression: combined --- • Waiting for 2 agents - └ 019cff70-2599-75e2-af72-b958ce5dc1cc - 019cff70-2599-75e2-af72-b96db334332d + └ Robie [explorer] + Ada [reviewer] • Finished waiting - └ 019cff70-2599-75e2-af72-b958ce5dc1cc: Completed - Done - 019cff70-2599-75e2-af72-b96db334332d: Running + └ Robie [explorer]: Completed - Done + Ada [reviewer]: Running diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 98509303ea..9b54d70351 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -2046,6 +2046,7 @@ async fn make_chatwidget_manual( pending_guardian_review_status: PendingGuardianReviewStatus::default(), last_copyable_output: None, running_commands: HashMap::new(), + collab_agent_metadata: HashMap::new(), pending_collab_spawn_requests: HashMap::new(), suppressed_exec_calls: HashSet::new(), skills_all: Vec::new(), @@ -4677,6 +4678,16 @@ async fn live_app_server_collab_wait_items_render_history() { ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread id"); let other_receiver_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b96db334332d").expect("valid thread id"); + chat.set_collab_agent_metadata( + receiver_thread_id, + Some("Robie".to_string()), + Some("explorer".to_string()), + ); + chat.set_collab_agent_metadata( + other_receiver_thread_id, + Some("Ada".to_string()), + Some("reviewer".to_string()), + ); chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { From 91337399fe3c216949b7fd5203fa7737b86841ed Mon Sep 17 00:00:00 2001 From: Matthew Zeng Date: Wed, 25 Mar 2026 12:26:02 -0700 Subject: [PATCH 45/50] [apps][tool_suggest] Remove tool_suggest's dependency on tool search. (#14856) - [x] Remove tool_suggest's dependency on tool search. --- codex-rs/core/src/apps/render.rs | 2 +- codex-rs/core/src/codex.rs | 5 +- .../core/src/tools/handlers/tool_suggest.rs | 101 ++++++++---- .../src/tools/handlers/tool_suggest_tests.rs | 28 ++++ codex-rs/core/src/tools/spec.rs | 3 +- codex-rs/core/src/tools/spec_tests.rs | 57 +++++++ .../search_tool/tool_suggest_description.md | 10 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/tool_suggest.rs | 144 ++++++++++++++++++ codex-rs/features/src/lib.rs | 8 +- codex-rs/features/src/tests.rs | 8 +- 11 files changed, 319 insertions(+), 48 deletions(-) create mode 100644 codex-rs/core/tests/suite/tool_suggest.rs diff --git a/codex-rs/core/src/apps/render.rs b/codex-rs/core/src/apps/render.rs index 3657a23255..72accdba3b 100644 --- a/codex-rs/core/src/apps/render.rs +++ b/codex-rs/core/src/apps/render.rs @@ -12,7 +12,7 @@ pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option { } let body = format!( - "## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps." + "## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps." ); Some(format!( "{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}" diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 2684cc90bf..7da6799d63 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -6462,10 +6462,7 @@ pub(crate) async fn built_tools( None }; let auth = sess.services.auth_manager.auth().await; - let discoverable_tools = if apps_enabled - && turn_context.tools_config.search_tool - && turn_context.tools_config.tool_suggest - { + let discoverable_tools = if apps_enabled && turn_context.tools_config.tool_suggest { if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() { match connectors::list_tool_suggest_discoverable_tools_with_auth( &turn_context.config, diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 8a98bd2b03..bdffbbbada 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -252,45 +252,90 @@ async fn verify_tool_suggestion_completed( auth: Option<&crate::CodexAuth>, ) -> bool { match tool { - DiscoverableTool::Connector(connector) => { - let manager = session.services.mcp_connection_manager.read().await; - match manager.hard_refresh_codex_apps_tools_cache().await { - Ok(mcp_tools) => { - let accessible_connectors = connectors::with_app_enabled_state( - connectors::accessible_connectors_from_mcp_tools(&mcp_tools), - &turn.config, - ); - connectors::refresh_accessible_connectors_cache_from_mcp_tools( - &turn.config, - auth, - &mcp_tools, - ); - verified_connector_suggestion_completed( - connector.id.as_str(), - &accessible_connectors, - ) - } - Err(err) => { - warn!( - "failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}", - connector.id - ); - false - } - } - } + DiscoverableTool::Connector(connector) => refresh_missing_suggested_connectors( + session, + turn, + auth, + std::slice::from_ref(&connector.id), + connector.id.as_str(), + ) + .await + .is_some_and(|accessible_connectors| { + verified_connector_suggestion_completed(connector.id.as_str(), &accessible_connectors) + }), DiscoverableTool::Plugin(plugin) => { session.reload_user_config_layer().await; let config = session.get_config().await; - verified_plugin_suggestion_completed( + let completed = verified_plugin_suggestion_completed( plugin.id.as_str(), config.as_ref(), session.services.plugins_manager.as_ref(), + ); + let _ = refresh_missing_suggested_connectors( + session, + turn, + auth, + &plugin.app_connector_ids, + plugin.id.as_str(), ) + .await; + completed } } } +async fn refresh_missing_suggested_connectors( + session: &crate::codex::Session, + turn: &crate::codex::TurnContext, + auth: Option<&crate::CodexAuth>, + expected_connector_ids: &[String], + tool_id: &str, +) -> Option> { + if expected_connector_ids.is_empty() { + return Some(Vec::new()); + } + + let manager = session.services.mcp_connection_manager.read().await; + let mcp_tools = manager.list_all_tools().await; + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + if all_suggested_connectors_picked_up(expected_connector_ids, &accessible_connectors) { + return Some(accessible_connectors); + } + + match manager.hard_refresh_codex_apps_tools_cache().await { + Ok(mcp_tools) => { + let accessible_connectors = connectors::with_app_enabled_state( + connectors::accessible_connectors_from_mcp_tools(&mcp_tools), + &turn.config, + ); + connectors::refresh_accessible_connectors_cache_from_mcp_tools( + &turn.config, + auth, + &mcp_tools, + ); + Some(accessible_connectors) + } + Err(err) => { + warn!( + "failed to refresh codex apps tools cache after tool suggestion for {tool_id}: {err:#}" + ); + None + } + } +} + +fn all_suggested_connectors_picked_up( + expected_connector_ids: &[String], + accessible_connectors: &[AppInfo], +) -> bool { + expected_connector_ids.iter().all(|connector_id| { + verified_connector_suggestion_completed(connector_id, accessible_connectors) + }) +} + fn verified_connector_suggestion_completed( tool_id: &str, accessible_connectors: &[AppInfo], diff --git a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs index 31aa49bbab..32d5061aed 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest_tests.rs @@ -235,6 +235,34 @@ fn verified_connector_suggestion_completed_requires_accessible_connector() { )); } +#[test] +fn all_suggested_connectors_picked_up_requires_every_expected_connector() { + let accessible_connectors = vec![AppInfo { + id: "calendar".to_string(), + name: "Google Calendar".to_string(), + description: None, + logo_url: None, + logo_url_dark: None, + distribution_channel: None, + branding: None, + app_metadata: None, + labels: None, + install_url: None, + is_accessible: true, + is_enabled: false, + plugin_display_names: Vec::new(), + }]; + + assert!(all_suggested_connectors_picked_up( + &["calendar".to_string()], + &accessible_connectors, + )); + assert!(!all_suggested_connectors_picked_up( + &["calendar".to_string(), "gmail".to_string()], + &accessible_connectors, + )); +} + #[tokio::test] async fn verified_plugin_suggestion_completed_requires_installed_plugin() { let codex_home = tempdir().expect("tempdir should succeed"); diff --git a/codex-rs/core/src/tools/spec.rs b/codex-rs/core/src/tools/spec.rs index 120493dcbb..3c10c3b844 100644 --- a/codex-rs/core/src/tools/spec.rs +++ b/codex-rs/core/src/tools/spec.rs @@ -390,8 +390,7 @@ impl ToolsConfig { include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput); let include_search_tool = model_info.supports_search_tool && features.enabled(Feature::ToolSearch); - let include_tool_suggest = include_search_tool - && features.enabled(Feature::ToolSuggest) + let include_tool_suggest = features.enabled(Feature::ToolSuggest) && features.enabled(Feature::Apps) && features.enabled(Feature::Plugins); let include_original_image_detail = can_request_original_image_detail(features, model_info); diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index f12aee6fa2..6089e8bf72 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -2029,6 +2029,8 @@ fn tool_suggest_is_not_registered_without_feature_flag() { let model_info = search_capable_model_info(); let mut features = Features::with_defaults(); features.enable(Feature::ToolSearch); + features.enable(Feature::Apps); + features.enable(Feature::Plugins); features.disable(Feature::ToolSuggest); let available_models = Vec::new(); let tools_config = ToolsConfig::new(&ToolsConfigParams { @@ -2060,6 +2062,54 @@ fn tool_suggest_is_not_registered_without_feature_flag() { ); } +#[test] +fn tool_suggest_can_be_registered_without_search_tool() { + let model_info = ModelInfo { + supports_search_tool: false, + ..search_capable_model_info() + }; + let mut features = Features::with_defaults(); + features.enable(Feature::Apps); + features.enable(Feature::Plugins); + features.enable(Feature::ToolSuggest); + let available_models = Vec::new(); + let tools_config = ToolsConfig::new(&ToolsConfigParams { + model_info: &model_info, + available_models: &available_models, + features: &features, + web_search_mode: Some(WebSearchMode::Cached), + session_source: SessionSource::Cli, + sandbox_policy: &SandboxPolicy::DangerFullAccess, + windows_sandbox_level: WindowsSandboxLevel::Disabled, + }); + let (tools, _) = build_specs_with_discoverable_tools( + &tools_config, + None, + None, + Some(vec![discoverable_connector( + "connector_2128aebfecb84f64a069897515042a44", + "Google Calendar", + "Plan events and schedules.", + )]), + &[], + ) + .build(); + + assert_contains_tool_names(&tools, &[TOOL_SUGGEST_TOOL_NAME]); + assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME); + + let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME); + let ToolSpec::Function(ResponsesApiTool { description, .. }) = &tool_suggest.spec else { + panic!("expected function tool"); + }; + assert!( + description.contains( + "You've already tried to find a matching available tool for the user's request" + ) + ); + assert!(description.contains("This includes `tool_search` (if available) and other means.")); +} + #[test] fn tool_suggest_requires_apps_and_plugins_features() { let model_info = search_capable_model_info(); @@ -2315,7 +2365,14 @@ fn tool_suggest_description_lists_discoverable_tools() { assert!( description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample") ); + assert!( + description.contains( + "You've already tried to find a matching available tool for the user's request" + ) + ); + assert!(description.contains("This includes `tool_search` (if available) and other means.")); assert!(description.contains("DO NOT explore or recommend tools that are not on this list.")); + assert!(!description.contains("tool_search fails to find a good match")); let JsonSchema::Object { required, .. } = parameters else { panic!("expected object parameters"); }; diff --git a/codex-rs/core/templates/search_tool/tool_suggest_description.md b/codex-rs/core/templates/search_tool/tool_suggest_description.md index fcf599c397..75e58958b5 100644 --- a/codex-rs/core/templates/search_tool/tool_suggest_description.md +++ b/codex-rs/core/templates/search_tool/tool_suggest_description.md @@ -3,8 +3,7 @@ Suggests a discoverable connector or plugin when the user clearly wants a capability that is not currently available in the active `tools` list. Use this ONLY when: -- There's no available tool to handle the user's request -- And tool_search fails to find a good match +- You've already tried to find a matching available tool for the user's request but couldn't find a good match. This includes `tool_search` (if available) and other means. - AND the user's request strongly matches one of the discoverable tools listed below. Tool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list. @@ -14,12 +13,13 @@ Discoverable tools: Workflow: -1. Match the user's request against the discoverable tools list above. -2. If one tool clearly fits, call `tool_suggest` with: +1. Ensure all possible means have been exhausted to find an existing available tool but none of them matches the request intent. +2. Match the user's request against the discoverable tools list above. +3. If one tool clearly fits, call `tool_suggest` with: - `tool_type`: `connector` or `plugin` - `action_type`: `install` or `enable` - `tool_id`: exact id from the discoverable tools list above - `suggest_reason`: concise one-line user-facing reason this tool can help with the current request -3. After the suggestion flow completes: +4. After the suggestion flow completes: - if the user finished the install or enable flow, continue by searching again or using the newly available tool - if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks you to. diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index f4891d58c5..d540b01bbf 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -129,6 +129,7 @@ mod subagent_notifications; mod text_encoding_fix; mod tool_harness; mod tool_parallelism; +mod tool_suggest; mod tools; mod truncation; mod turn_state; diff --git a/codex-rs/core/tests/suite/tool_suggest.rs b/codex-rs/core/tests/suite/tool_suggest.rs new file mode 100644 index 0000000000..460c7366a3 --- /dev/null +++ b/codex-rs/core/tests/suite/tool_suggest.rs @@ -0,0 +1,144 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use anyhow::Result; +use codex_core::CodexAuth; +use codex_core::config::Config; +use codex_core::config::types::ToolSuggestDiscoverable; +use codex_core::config::types::ToolSuggestDiscoverableType; +use codex_features::Feature; +use codex_protocol::openai_models::ModelsResponse; +use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::SandboxPolicy; +use core_test_support::apps_test_server::AppsTestServer; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_response_created; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::skip_if_no_network; +use core_test_support::test_codex::test_codex; +use serde_json::Value; + +const TOOL_SEARCH_TOOL_NAME: &str = "tool_search"; +const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest"; +const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2"; + +fn tool_names(body: &Value) -> Vec { + body.get("tools") + .and_then(Value::as_array) + .map(|tools| { + tools + .iter() + .filter_map(|tool| { + tool.get("name") + .or_else(|| tool.get("type")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect() + }) + .unwrap_or_default() +} + +fn function_tool_description(body: &Value, name: &str) -> Option { + body.get("tools") + .and_then(Value::as_array) + .and_then(|tools| { + tools.iter().find_map(|tool| { + if tool.get("name").and_then(Value::as_str) == Some(name) { + tool.get("description") + .and_then(Value::as_str) + .map(str::to_string) + } else { + None + } + }) + }) +} + +fn configure_apps_without_search_tool(config: &mut Config, apps_base_url: &str) { + config + .features + .enable(Feature::Apps) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::Plugins) + .expect("test config should allow feature update"); + config + .features + .enable(Feature::ToolSuggest) + .expect("test config should allow feature update"); + config.chatgpt_base_url = apps_base_url.to_string(); + config.model = Some("gpt-5-codex".to_string()); + config.tool_suggest.discoverables = vec![ToolSuggestDiscoverable { + kind: ToolSuggestDiscoverableType::Connector, + id: DISCOVERABLE_GMAIL_ID.to_string(), + }]; + + let mut model_catalog: ModelsResponse = + serde_json::from_str(include_str!("../../models.json")).expect("valid models.json"); + let model = model_catalog + .models + .iter_mut() + .find(|model| model.slug == "gpt-5-codex") + .expect("gpt-5-codex exists in bundled models.json"); + model.supports_search_tool = false; + config.model_catalog = Some(model_catalog); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_suggest_is_available_without_search_tool_after_discovery_attempts() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let apps_server = AppsTestServer::mount(&server).await?; + let mock = mount_sse_once( + &server, + sse(vec![ + ev_response_created("resp-1"), + ev_assistant_message("msg-1", "done"), + ev_completed("resp-1"), + ]), + ) + .await; + + let mut builder = test_codex() + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + configure_apps_without_search_tool(config, apps_server.chatgpt_base_url.as_str()) + }); + let test = builder.build(&server).await?; + + test.submit_turn_with_policies( + "list tools", + AskForApproval::Never, + SandboxPolicy::DangerFullAccess, + ) + .await?; + + let body = mock.single_request().body_json(); + let tools = tool_names(&body); + assert!( + !tools.iter().any(|name| name == TOOL_SEARCH_TOOL_NAME), + "tools list should not include {TOOL_SEARCH_TOOL_NAME}: {tools:?}" + ); + assert!( + tools.iter().any(|name| name == TOOL_SUGGEST_TOOL_NAME), + "tools list should include {TOOL_SUGGEST_TOOL_NAME}: {tools:?}" + ); + + let description = + function_tool_description(&body, TOOL_SUGGEST_TOOL_NAME).expect("description"); + assert!( + description.contains( + "You've already tried to find a matching available tool for the user's request" + ) + ); + assert!(description.contains("This includes `tool_search` (if available) and other means.")); + assert!(!description.contains("tool_search fails to find a good match")); + + Ok(()) +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index ccde6a5e54..0aa019cb06 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -717,7 +717,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::Apps, key: "apps", stage: Stage::Stable, - default_enabled: true, + default_enabled: false, }, FeatureSpec { id: Feature::ToolSearch, @@ -729,13 +729,13 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::ToolSuggest, key: "tool_suggest", stage: Stage::Stable, - default_enabled: true, + default_enabled: false, }, FeatureSpec { id: Feature::Plugins, key: "plugins", stage: Stage::Stable, - default_enabled: true, + default_enabled: false, }, FeatureSpec { id: Feature::ImageGeneration, @@ -787,7 +787,7 @@ pub const FEATURES: &[FeatureSpec] = &[ id: Feature::ToolCallMcpElicitation, key: "tool_call_mcp_elicitation", stage: Stage::Stable, - default_enabled: true, + default_enabled: false, }, FeatureSpec { id: Feature::Personality, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index ecfa87b41b..4943a1ccf8 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -115,9 +115,9 @@ fn request_permissions_tool_is_under_development() { } #[test] -fn tool_suggest_is_stable_and_enabled_by_default() { +fn tool_suggest_is_stable_and_disabled_by_default() { assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable); - assert_eq!(Feature::ToolSuggest.default_enabled(), true); + assert_eq!(Feature::ToolSuggest.default_enabled(), false); } #[test] @@ -145,9 +145,9 @@ fn image_generation_is_under_development() { } #[test] -fn tool_call_mcp_elicitation_is_stable_and_enabled_by_default() { +fn tool_call_mcp_elicitation_is_stable_and_disabled_by_default() { assert_eq!(Feature::ToolCallMcpElicitation.stage(), Stage::Stable); - assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), true); + assert_eq!(Feature::ToolCallMcpElicitation.default_enabled(), false); } #[test] From 6124564297289ddadd5ab364bb961ea367fff8d0 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 25 Mar 2026 12:35:57 -0700 Subject: [PATCH 46/50] feat: add websocket auth for app-server (#14847) ## Summary This change adds websocket authentication at the app-server transport boundary and enforces it before JSON-RPC `initialize`, so authenticated deployments reject unauthenticated clients during the websocket handshake rather than after a connection has already been admitted. During rollout, websocket auth is opt-in for non-loopback listeners so we do not break existing remote clients. If `--ws-auth ...` is configured, the server enforces auth during websocket upgrade. If auth is not configured, non-loopback listeners still start, but app-server logs a warning and the startup banner calls out that auth should be configured before real remote use. The server supports two auth modes: a file-backed capability token, and a standard HMAC-signed JWT/JWS bearer token verified with the `jsonwebtoken` crate, with optional issuer, audience, and clock-skew validation. Capability tokens are normalized, hashed, and compared in constant time. Short shared secrets for signed bearer tokens are rejected at startup. Requests carrying an `Origin` header are rejected with `403` by transport middleware, and authenticated clients present credentials as `Authorization: Bearer ` during websocket upgrade. ## Validation - `cargo test -p codex-app-server transport::auth` - `cargo test -p codex-cli app_server_` - `cargo clippy -p codex-app-server --all-targets -- -D warnings` - `just bazel-lock-check` Note: in the broad `cargo test -p codex-app-server connection_handling_websocket` run, the touched websocket auth cases passed, but unrelated Unix shutdown tests failed with a timeout in this environment. --------- Co-authored-by: Eric Traut --- MODULE.bazel.lock | 2 + codex-rs/Cargo.lock | 31 + codex-rs/Cargo.toml | 3 + codex-rs/app-server/Cargo.toml | 4 + codex-rs/app-server/README.md | 9 + codex-rs/app-server/src/lib.rs | 7 + codex-rs/app-server/src/main.rs | 6 + codex-rs/app-server/src/transport.rs | 34 +- codex-rs/app-server/src/transport/auth.rs | 583 ++++++++++++++++++ .../suite/v2/connection_handling_websocket.rs | 375 +++++++++-- codex-rs/cli/src/main.rs | 162 +++-- 11 files changed, 1130 insertions(+), 86 deletions(-) create mode 100644 codex-rs/app-server/src/transport/auth.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index e0d384767b..ee89f6243f 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -971,6 +971,7 @@ "jni_0.21.1": "{\"dependencies\":[{\"name\":\"cesu8\",\"req\":\"^1.1.0\"},{\"name\":\"cfg-if\",\"req\":\"^1.0.0\"},{\"name\":\"combine\",\"req\":\"^4.1.0\"},{\"name\":\"java-locator\",\"optional\":true,\"req\":\"^0.1\"},{\"name\":\"jni-sys\",\"req\":\"^0.3.0\"},{\"name\":\"libloading\",\"optional\":true,\"req\":\"^0.7\"},{\"name\":\"log\",\"req\":\"^0.4.4\"},{\"name\":\"thiserror\",\"req\":\"^1.0.20\"},{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"rusty-fork\",\"req\":\"^0.3.0\"},{\"kind\":\"build\",\"name\":\"walkdir\",\"req\":\"^2\"},{\"features\":[\"Win32_Globalization\"],\"name\":\"windows-sys\",\"req\":\"^0.45.0\",\"target\":\"cfg(windows)\"},{\"kind\":\"dev\",\"name\":\"bytemuck\",\"req\":\"^1.13.0\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[],\"invocation\":[\"java-locator\",\"libloading\"]}}", "jobserver_0.1.34": "{\"dependencies\":[{\"features\":[\"std\"],\"name\":\"getrandom\",\"req\":\"^0.3.2\",\"target\":\"cfg(windows)\"},{\"name\":\"libc\",\"req\":\"^0.2.171\",\"target\":\"cfg(unix)\"},{\"features\":[\"fs\"],\"kind\":\"dev\",\"name\":\"nix\",\"req\":\"^0.28.0\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.10.1\"}],\"features\":{}}", "js-sys_0.3.85": "{\"dependencies\":[{\"default_features\":false,\"name\":\"once_cell\",\"req\":\"^1.12\"},{\"default_features\":false,\"name\":\"wasm-bindgen\",\"req\":\"=0.2.108\"}],\"features\":{\"default\":[\"std\"],\"std\":[\"wasm-bindgen/std\"]}}", + "jsonwebtoken_9.3.1": "{\"dependencies\":[{\"name\":\"base64\",\"req\":\"^0.22\"},{\"default_features\":false,\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.4\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"name\":\"js-sys\",\"req\":\"^0.3\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"name\":\"pem\",\"optional\":true,\"req\":\"^3\"},{\"features\":[\"std\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(not(target_arch = \\\"wasm32\\\"))\"},{\"features\":[\"std\",\"wasm32_unknown_unknown_js\"],\"name\":\"ring\",\"req\":\"^0.17.4\",\"target\":\"cfg(target_arch = \\\"wasm32\\\")\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"req\":\"^1.0\"},{\"name\":\"simple_asn1\",\"optional\":true,\"req\":\"^0.6\"},{\"features\":[\"wasm-bindgen\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\"))))\"},{\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\",\"target\":\"cfg(not(all(target_arch = \\\"wasm32\\\", not(any(target_os = \\\"emscripten\\\", target_os = \\\"wasi\\\")))))\"},{\"kind\":\"dev\",\"name\":\"wasm-bindgen-test\",\"req\":\"^0.3.1\"}],\"features\":{\"default\":[\"use_pem\"],\"use_pem\":[\"pem\",\"simple_asn1\"]}}", "keyring_3.6.3": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"base64\",\"req\":\"^0.22\"},{\"name\":\"byteorder\",\"optional\":true,\"req\":\"^1.2\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"features\":[\"derive\",\"wrap_help\"],\"kind\":\"dev\",\"name\":\"clap\",\"req\":\"^4\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.1\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.0-rc.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"dbus-secret-service\",\"optional\":true,\"req\":\"^4.0.1\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"env_logger\",\"req\":\"^0.11.5\"},{\"kind\":\"dev\",\"name\":\"fastrand\",\"req\":\"^2\"},{\"features\":[\"std\"],\"name\":\"linux-keyutils\",\"optional\":true,\"req\":\"^0.2\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"log\",\"req\":\"^0.4.22\"},{\"name\":\"openssl\",\"optional\":true,\"req\":\"^0.10.66\"},{\"kind\":\"dev\",\"name\":\"rpassword\",\"req\":\"^7\"},{\"kind\":\"dev\",\"name\":\"rprompt\",\"req\":\"^2\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"secret-service\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^2\",\"target\":\"cfg(target_os = \\\"ios\\\")\"},{\"name\":\"security-framework\",\"optional\":true,\"req\":\"^3\",\"target\":\"cfg(target_os = \\\"macos\\\")\"},{\"kind\":\"dev\",\"name\":\"whoami\",\"req\":\"^1.5\"},{\"features\":[\"Win32_Foundation\",\"Win32_Security_Credentials\"],\"name\":\"windows-sys\",\"optional\":true,\"req\":\"^0.60\",\"target\":\"cfg(target_os = \\\"windows\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"freebsd\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"linux\\\")\"},{\"name\":\"zbus\",\"optional\":true,\"req\":\"^4\",\"target\":\"cfg(target_os = \\\"openbsd\\\")\"},{\"name\":\"zeroize\",\"req\":\"^1.8.1\",\"target\":\"cfg(target_os = \\\"windows\\\")\"}],\"features\":{\"apple-native\":[\"dep:security-framework\"],\"async-io\":[\"zbus?/async-io\"],\"async-secret-service\":[\"dep:secret-service\",\"dep:zbus\"],\"crypto-openssl\":[\"dbus-secret-service?/crypto-openssl\",\"secret-service?/crypto-openssl\"],\"crypto-rust\":[\"dbus-secret-service?/crypto-rust\",\"secret-service?/crypto-rust\"],\"linux-native\":[\"dep:linux-keyutils\"],\"linux-native-async-persistent\":[\"linux-native\",\"async-secret-service\"],\"linux-native-sync-persistent\":[\"linux-native\",\"sync-secret-service\"],\"sync-secret-service\":[\"dep:dbus-secret-service\"],\"tokio\":[\"zbus?/tokio\"],\"vendored\":[\"dbus-secret-service?/vendored\",\"openssl?/vendored\"],\"windows-native\":[\"dep:windows-sys\",\"dep:byteorder\"]}}", "kqueue-sys_1.0.4": "{\"dependencies\":[{\"name\":\"bitflags\",\"req\":\"^1.2.1\"},{\"name\":\"libc\",\"req\":\"^0.2.74\"}],\"features\":{}}", "kqueue_1.1.1": "{\"dependencies\":[{\"features\":[\"html_reports\"],\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"dhat\",\"req\":\"^0.3.2\"},{\"name\":\"kqueue-sys\",\"req\":\"^1.0.4\"},{\"name\":\"libc\",\"req\":\"^0.2.17\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3.1.0\"}],\"features\":{}}", @@ -1281,6 +1282,7 @@ "simd-adler32_0.3.8": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"adler\",\"req\":\"^1.0.2\"},{\"kind\":\"dev\",\"name\":\"adler32\",\"req\":\"^1.2.0\"},{\"kind\":\"dev\",\"name\":\"criterion\",\"req\":\"^0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"}],\"features\":{\"const-generics\":[],\"default\":[\"std\",\"const-generics\"],\"nightly\":[],\"std\":[]}}", "simdutf8_0.1.5": "{\"dependencies\":[],\"features\":{\"aarch64_neon\":[],\"aarch64_neon_prefetch\":[],\"default\":[\"std\"],\"hints\":[],\"public_imp\":[],\"std\":[]}}", "similar_2.7.0": "{\"dependencies\":[{\"default_features\":false,\"name\":\"bstr\",\"optional\":true,\"req\":\"^1.5.0\"},{\"kind\":\"dev\",\"name\":\"console\",\"req\":\"^0.15.0\"},{\"kind\":\"dev\",\"name\":\"insta\",\"req\":\"^1.10.0\"},{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.130\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0.68\"},{\"name\":\"unicode-segmentation\",\"optional\":true,\"req\":\"^1.7.1\"},{\"name\":\"web-time\",\"optional\":true,\"req\":\"^1.1\"}],\"features\":{\"bytes\":[\"bstr\",\"text\"],\"default\":[\"text\"],\"inline\":[\"text\"],\"text\":[],\"unicode\":[\"text\",\"unicode-segmentation\",\"bstr?/unicode\",\"bstr?/std\"],\"wasm32_web_time\":[\"web-time\"]}}", + "simple_asn1_0.6.4": "{\"dependencies\":[{\"default_features\":false,\"name\":\"num-bigint\",\"req\":\"^0.4\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2\"},{\"kind\":\"dev\",\"name\":\"quickcheck\",\"req\":\"^1.0.3\"},{\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8.4\"},{\"default_features\":false,\"name\":\"thiserror\",\"req\":\"^2\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\"],\"name\":\"time\",\"req\":\"^0.3.47\"},{\"default_features\":false,\"features\":[\"formatting\",\"macros\",\"parsing\",\"quickcheck\"],\"kind\":\"dev\",\"name\":\"time\",\"req\":\"^0.3\"}],\"features\":{}}", "siphasher_1.0.2": "{\"dependencies\":[{\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"name\":\"serde_json\",\"optional\":true,\"req\":\"^1.0\"}],\"features\":{\"default\":[\"std\"],\"serde_no_std\":[\"serde/alloc\"],\"serde_std\":[\"std\",\"serde/std\"],\"std\":[]}}", "slab_0.4.12": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"alloc\"],\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0.95\"},{\"features\":[\"derive\"],\"kind\":\"dev\",\"name\":\"serde\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"serde_test\",\"req\":\"^1\"}],\"features\":{\"default\":[\"std\"],\"std\":[]}}", "smallvec_1.15.1": "{\"dependencies\":[{\"name\":\"arbitrary\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"bincode\",\"optional\":true,\"req\":\"^2\"},{\"kind\":\"dev\",\"name\":\"bincode1\",\"package\":\"bincode\",\"req\":\"^1.0.1\"},{\"kind\":\"dev\",\"name\":\"debugger_test\",\"req\":\"^0.1.0\"},{\"kind\":\"dev\",\"name\":\"debugger_test_parser\",\"req\":\"^0.1.0\"},{\"default_features\":false,\"name\":\"malloc_size_of\",\"optional\":true,\"req\":\"^0.1\"},{\"default_features\":false,\"name\":\"serde\",\"optional\":true,\"req\":\"^1\"},{\"default_features\":false,\"name\":\"unty\",\"optional\":true,\"req\":\"^0.0.4\"}],\"features\":{\"const_generics\":[],\"const_new\":[\"const_generics\"],\"debugger_visualizer\":[],\"drain_filter\":[],\"drain_keep_rest\":[\"drain_filter\"],\"impl_bincode\":[\"bincode\",\"unty\"],\"may_dangle\":[],\"specialization\":[],\"union\":[],\"write\":[]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index df44a7f83b..187fcca79a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1473,8 +1473,11 @@ dependencies = [ "codex-utils-cli", "codex-utils-json-to-toml", "codex-utils-pty", + "constant_time_eq", "core_test_support", "futures", + "hmac", + "jsonwebtoken", "opentelemetry", "opentelemetry_sdk", "owo-colors", @@ -1484,6 +1487,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "shlex", "tempfile", "time", @@ -5759,6 +5763,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "keyring" version = "3.6.3" @@ -9249,6 +9268,18 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.2" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 10b9485d20..350a98e451 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -202,6 +202,7 @@ chrono = "0.4.43" clap = "4" clap_complete = "4" color-eyre = "0.6.3" +constant_time_eq = "0.3.1" crossbeam-channel = "0.5.15" crossterm = "0.28.1" csv = "1.3.1" @@ -220,6 +221,7 @@ flate2 = "1.1.4" futures = { version = "0.3", default-features = false } gethostname = "1.1.0" globset = "0.4" +hmac = "0.12.1" http = "1.3.1" icu_decimal = "2.1" icu_locale_core = "2.1" @@ -232,6 +234,7 @@ indexmap = "2.12.0" insta = "1.46.3" inventory = "0.3.19" itertools = "0.14.0" +jsonwebtoken = "9.3.1" keyring = { version = "3.6", default-features = false } landlock = "0.4.4" lazy_static = "1" diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 1eb09e2605..f54bb7ad03 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -53,10 +53,14 @@ codex-utils-absolute-path = { workspace = true } codex-utils-json-to-toml = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive"] } +constant_time_eq = { workspace = true } futures = { workspace = true } +hmac = { workspace = true } +jsonwebtoken = { workspace = true } owo-colors = { workspace = true, features = ["supports-colors"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +sha2 = { workspace = true } tempfile = { workspace = true } time = { workspace = true } toml = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 1e186c0d5e..a1856af46d 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -34,6 +34,15 @@ When running with `--listen ws://IP:PORT`, the same listener also serves basic H Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. +Security note: + +- Loopback websocket listeners (`ws://127.0.0.1:PORT`) remain appropriate for localhost and SSH port-forwarding workflows. +- Non-loopback websocket listeners currently allow unauthenticated connections by default during rollout. If you expose one remotely, configure websocket auth explicitly now. +- Supported auth modes are app-server flags: + - `--ws-auth capability-token --ws-token-file /absolute/path` + - `--ws-auth signed-bearer-token --ws-shared-secret-file /absolute/path` for HMAC-signed JWT/JWS bearer tokens, with optional `--ws-issuer`, `--ws-audience`, `--ws-max-clock-skew-seconds` +- Clients present the credential as `Authorization: Bearer ` during the websocket handshake. Auth is enforced before JSON-RPC `initialize`. + Tracing/log output: - `RUST_LOG` controls log filtering/verbosity. diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index dc2512aaec..247270a26c 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -27,6 +27,7 @@ use crate::transport::CHANNEL_CAPACITY; use crate::transport::ConnectionState; use crate::transport::OutboundConnectionState; use crate::transport::TransportEvent; +use crate::transport::auth::policy_from_settings; use crate::transport::route_outgoing_envelope; use crate::transport::start_stdio_connection; use crate::transport::start_websocket_acceptor; @@ -81,6 +82,9 @@ mod transport; pub use crate::error_code::INPUT_TOO_LARGE_ERROR_CODE; pub use crate::error_code::INVALID_PARAMS_ERROR_CODE; pub use crate::transport::AppServerTransport; +pub use crate::transport::auth::AppServerWebsocketAuthArgs; +pub use crate::transport::auth::AppServerWebsocketAuthSettings; +pub use crate::transport::auth::WebsocketAuthCliMode; const LOG_FORMAT_ENV_VAR: &str = "LOG_FORMAT"; @@ -337,6 +341,7 @@ pub async fn run_main( default_analytics_enabled, AppServerTransport::Stdio, SessionSource::VSCode, + AppServerWebsocketAuthSettings::default(), ) .await } @@ -348,6 +353,7 @@ pub async fn run_main_with_transport( default_analytics_enabled: bool, transport: AppServerTransport, session_source: SessionSource, + auth: AppServerWebsocketAuthSettings, ) -> IoResult<()> { let (transport_event_tx, mut transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -375,6 +381,7 @@ pub async fn run_main_with_transport( bind_address, transport_event_tx.clone(), shutdown_token.clone(), + policy_from_settings(&auth)?, ) .await?; TransportRuntime::WebSocket { diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 60fa0a777b..fa95f973ea 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -1,5 +1,6 @@ use clap::Parser; use codex_app_server::AppServerTransport; +use codex_app_server::AppServerWebsocketAuthArgs; use codex_app_server::run_main_with_transport; use codex_arg0::Arg0DispatchPaths; use codex_arg0::arg0_dispatch_or_else; @@ -31,6 +32,9 @@ struct AppServerArgs { value_parser = SessionSource::from_startup_arg )] session_source: SessionSource, + + #[command(flatten)] + auth: AppServerWebsocketAuthArgs, } fn main() -> anyhow::Result<()> { @@ -43,6 +47,7 @@ fn main() -> anyhow::Result<()> { }; let transport = args.listen; let session_source = args.session_source; + let auth = args.auth.try_into_settings()?; run_main_with_transport( arg0_paths, @@ -51,6 +56,7 @@ fn main() -> anyhow::Result<()> { /*default_analytics_enabled*/ false, transport, session_source, + auth, ) .await?; Ok(()) diff --git a/codex-rs/app-server/src/transport.rs b/codex-rs/app-server/src/transport.rs index 21fbc9965b..020e441da5 100644 --- a/codex-rs/app-server/src/transport.rs +++ b/codex-rs/app-server/src/transport.rs @@ -1,3 +1,8 @@ +pub(crate) mod auth; + +use self::auth::WebsocketAuthPolicy; +use self::auth::authorize_upgrade; +use self::auth::should_warn_about_unauthenticated_non_loopback_listener; use crate::error_code::OVERLOADED_ERROR_CODE; use crate::message_processor::ConnectionSessionState; use crate::outgoing_message::ConnectionId; @@ -12,6 +17,7 @@ use axum::extract::State; use axum::extract::ws::Message as WebSocketMessage; use axum::extract::ws::WebSocket; use axum::extract::ws::WebSocketUpgrade; +use axum::http::HeaderMap; use axum::http::Request; use axum::http::StatusCode; use axum::http::header::ORIGIN; @@ -83,7 +89,7 @@ fn print_websocket_startup_banner(addr: SocketAddr) { ); } else { eprintln!( - " {note_label} this is a raw WS server; consider running behind TLS/auth for real remote use" + " {note_label} websocket auth is opt-in in this build; configure `--ws-auth ...` before real remote use" ); } } @@ -92,6 +98,7 @@ fn print_websocket_startup_banner(addr: SocketAddr) { struct WebSocketListenerState { transport_event_tx: mpsc::Sender, connection_counter: Arc, + auth_policy: Arc, } async fn health_check_handler() -> StatusCode { @@ -118,12 +125,23 @@ async fn websocket_upgrade_handler( websocket: WebSocketUpgrade, ConnectInfo(peer_addr): ConnectInfo, State(state): State, + headers: HeaderMap, ) -> impl IntoResponse { + if let Err(err) = authorize_upgrade(&headers, state.auth_policy.as_ref()) { + warn!( + %peer_addr, + message = err.message(), + "rejecting websocket client during upgrade" + ); + return (err.status_code(), err.message()).into_response(); + } let connection_id = ConnectionId(state.connection_counter.fetch_add(1, Ordering::Relaxed)); info!(%peer_addr, "websocket client connected"); - websocket.on_upgrade(move |stream| async move { - run_websocket_connection(connection_id, stream, state.transport_event_tx).await; - }) + websocket + .on_upgrade(move |stream| async move { + run_websocket_connection(connection_id, stream, state.transport_event_tx).await; + }) + .into_response() } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -333,7 +351,14 @@ pub(crate) async fn start_websocket_acceptor( bind_address: SocketAddr, transport_event_tx: mpsc::Sender, shutdown_token: CancellationToken, + auth_policy: WebsocketAuthPolicy, ) -> IoResult> { + if should_warn_about_unauthenticated_non_loopback_listener(bind_address, &auth_policy) { + warn!( + %bind_address, + "starting non-loopback websocket listener without auth; websocket auth is opt-in for now and will become the default in a future release" + ); + } let listener = TcpListener::bind(bind_address).await?; let local_addr = listener.local_addr()?; print_websocket_startup_banner(local_addr); @@ -347,6 +372,7 @@ pub(crate) async fn start_websocket_acceptor( .with_state(WebSocketListenerState { transport_event_tx, connection_counter: Arc::new(AtomicU64::new(1)), + auth_policy: Arc::new(auth_policy), }); let server = axum::serve( listener, diff --git a/codex-rs/app-server/src/transport/auth.rs b/codex-rs/app-server/src/transport/auth.rs new file mode 100644 index 0000000000..1c6080e748 --- /dev/null +++ b/codex-rs/app-server/src/transport/auth.rs @@ -0,0 +1,583 @@ +use anyhow::Context; +use axum::http::HeaderMap; +use axum::http::StatusCode; +use axum::http::header::AUTHORIZATION; +use clap::Args; +use clap::ValueEnum; +use codex_utils_absolute_path::AbsolutePathBuf; +use constant_time_eq::constant_time_eq_32; +use jsonwebtoken::Algorithm; +use jsonwebtoken::DecodingKey; +use jsonwebtoken::Validation; +use jsonwebtoken::decode; +use serde::Deserialize; +use sha2::Digest; +use sha2::Sha256; +use std::io; +use std::io::ErrorKind; +use std::net::SocketAddr; +use std::path::Path; +use std::path::PathBuf; +use time::OffsetDateTime; + +const DEFAULT_MAX_CLOCK_SKEW_SECONDS: u64 = 30; +const MIN_SIGNED_BEARER_SECRET_BYTES: usize = 32; +const INVALID_AUTHORIZATION_HEADER_MESSAGE: &str = "invalid authorization header"; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Args)] +pub struct AppServerWebsocketAuthArgs { + /// Websocket auth mode for non-loopback listeners. + #[arg(long = "ws-auth", value_name = "MODE", value_enum)] + pub ws_auth: Option, + + /// Absolute path to the capability-token file. + #[arg(long = "ws-token-file", value_name = "PATH")] + pub ws_token_file: Option, + + /// Absolute path to the shared secret file for signed JWT bearer tokens. + #[arg(long = "ws-shared-secret-file", value_name = "PATH")] + pub ws_shared_secret_file: Option, + + /// Expected issuer for signed JWT bearer tokens. + #[arg(long = "ws-issuer", value_name = "ISSUER")] + pub ws_issuer: Option, + + /// Expected audience for signed JWT bearer tokens. + #[arg(long = "ws-audience", value_name = "AUDIENCE")] + pub ws_audience: Option, + + /// Maximum clock skew when validating signed JWT bearer tokens. + #[arg(long = "ws-max-clock-skew-seconds", value_name = "SECONDS")] + pub ws_max_clock_skew_seconds: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum WebsocketAuthCliMode { + CapabilityToken, + SignedBearerToken, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct AppServerWebsocketAuthSettings { + pub config: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AppServerWebsocketAuthConfig { + CapabilityToken { + token_file: AbsolutePathBuf, + }, + SignedBearerToken { + shared_secret_file: AbsolutePathBuf, + issuer: Option, + audience: Option, + max_clock_skew_seconds: u64, + }, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct WebsocketAuthPolicy { + pub(crate) mode: Option, +} + +#[derive(Clone, Debug)] +pub(crate) enum WebsocketAuthMode { + CapabilityToken { + token_sha256: [u8; 32], + }, + SignedBearerToken { + shared_secret: Vec, + issuer: Option, + audience: Option, + max_clock_skew_seconds: i64, + }, +} + +#[derive(Debug)] +pub(crate) struct WebsocketAuthError { + status_code: StatusCode, + message: &'static str, +} + +#[derive(Deserialize)] +struct JwtClaims { + exp: i64, + nbf: Option, + iss: Option, + aud: Option, +} + +#[derive(Deserialize)] +#[serde(untagged)] +enum JwtAudienceClaim { + Single(String), + Multiple(Vec), +} + +impl WebsocketAuthError { + pub(crate) fn status_code(&self) -> StatusCode { + self.status_code + } + + pub(crate) fn message(&self) -> &'static str { + self.message + } +} + +impl AppServerWebsocketAuthArgs { + pub fn try_into_settings(self) -> anyhow::Result { + let normalize = |value: Option| { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) + }; + + let config = match self.ws_auth { + Some(WebsocketAuthCliMode::CapabilityToken) => { + if self.ws_shared_secret_file.is_some() + || self.ws_issuer.is_some() + || self.ws_audience.is_some() + || self.ws_max_clock_skew_seconds.is_some() + { + anyhow::bail!( + "`--ws-shared-secret-file`, `--ws-issuer`, `--ws-audience`, and `--ws-max-clock-skew-seconds` require `--ws-auth signed-bearer-token`" + ); + } + let token_file = self.ws_token_file.context( + "`--ws-token-file` is required when `--ws-auth capability-token` is set", + )?; + Some(AppServerWebsocketAuthConfig::CapabilityToken { + token_file: absolute_path_arg("--ws-token-file", token_file)?, + }) + } + Some(WebsocketAuthCliMode::SignedBearerToken) => { + if self.ws_token_file.is_some() { + anyhow::bail!( + "`--ws-token-file` requires `--ws-auth capability-token`, not `signed-bearer-token`" + ); + } + let shared_secret_file = self.ws_shared_secret_file.context( + "`--ws-shared-secret-file` is required when `--ws-auth signed-bearer-token` is set", + )?; + Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file: absolute_path_arg( + "--ws-shared-secret-file", + shared_secret_file, + )?, + issuer: normalize(self.ws_issuer), + audience: normalize(self.ws_audience), + max_clock_skew_seconds: self + .ws_max_clock_skew_seconds + .unwrap_or(DEFAULT_MAX_CLOCK_SKEW_SECONDS), + }) + } + None => { + if self.ws_token_file.is_some() + || self.ws_shared_secret_file.is_some() + || self.ws_issuer.is_some() + || self.ws_audience.is_some() + || self.ws_max_clock_skew_seconds.is_some() + { + anyhow::bail!( + "websocket auth flags require `--ws-auth capability-token` or `--ws-auth signed-bearer-token`" + ); + } + None + } + }; + + Ok(AppServerWebsocketAuthSettings { config }) + } +} + +pub(crate) fn policy_from_settings( + settings: &AppServerWebsocketAuthSettings, +) -> io::Result { + let mode = match settings.config.as_ref() { + Some(AppServerWebsocketAuthConfig::CapabilityToken { token_file }) => { + let token = read_trimmed_secret(token_file.as_ref())?; + Some(WebsocketAuthMode::CapabilityToken { + token_sha256: sha256_digest(token.as_bytes()), + }) + } + Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file, + issuer, + audience, + max_clock_skew_seconds, + }) => { + let shared_secret = read_trimmed_secret(shared_secret_file.as_ref())?.into_bytes(); + validate_signed_bearer_secret(shared_secret_file.as_ref(), &shared_secret)?; + let max_clock_skew_seconds = i64::try_from(*max_clock_skew_seconds).map_err(|_| { + io::Error::new( + ErrorKind::InvalidInput, + "websocket auth clock skew must fit in a signed 64-bit integer", + ) + })?; + Some(WebsocketAuthMode::SignedBearerToken { + shared_secret, + issuer: issuer.clone(), + audience: audience.clone(), + max_clock_skew_seconds, + }) + } + None => None, + }; + + Ok(WebsocketAuthPolicy { mode }) +} + +pub(crate) fn should_warn_about_unauthenticated_non_loopback_listener( + bind_address: SocketAddr, + policy: &WebsocketAuthPolicy, +) -> bool { + !bind_address.ip().is_loopback() && policy.mode.is_none() +} + +pub(crate) fn authorize_upgrade( + headers: &HeaderMap, + policy: &WebsocketAuthPolicy, +) -> Result<(), WebsocketAuthError> { + let Some(mode) = policy.mode.as_ref() else { + return Ok(()); + }; + + let token = bearer_token_from_headers(headers)?; + match mode { + WebsocketAuthMode::CapabilityToken { token_sha256 } => { + let actual_sha256 = sha256_digest(token.as_bytes()); + if constant_time_eq_32(token_sha256, &actual_sha256) { + Ok(()) + } else { + Err(unauthorized("invalid websocket bearer token")) + } + } + WebsocketAuthMode::SignedBearerToken { + shared_secret, + issuer, + audience, + max_clock_skew_seconds, + } => verify_signed_bearer_token( + token, + shared_secret, + issuer.as_deref(), + audience.as_deref(), + *max_clock_skew_seconds, + ), + } +} + +fn verify_signed_bearer_token( + token: &str, + shared_secret: &[u8], + issuer: Option<&str>, + audience: Option<&str>, + max_clock_skew_seconds: i64, +) -> Result<(), WebsocketAuthError> { + let claims = decode_jwt_claims(token, shared_secret)?; + validate_jwt_claims(&claims, issuer, audience, max_clock_skew_seconds) +} + +fn decode_jwt_claims(token: &str, shared_secret: &[u8]) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.required_spec_claims.clear(); + validation.validate_exp = false; + validation.validate_nbf = false; + validation.validate_aud = false; + + decode::(token, &DecodingKey::from_secret(shared_secret), &validation) + .map(|token_data| token_data.claims) + .map_err(|_| unauthorized("invalid websocket jwt")) +} + +fn validate_jwt_claims( + claims: &JwtClaims, + issuer: Option<&str>, + audience: Option<&str>, + max_clock_skew_seconds: i64, +) -> Result<(), WebsocketAuthError> { + let now = OffsetDateTime::now_utc().unix_timestamp(); + if now > claims.exp.saturating_add(max_clock_skew_seconds) { + return Err(unauthorized("expired websocket jwt")); + } + if let Some(nbf) = claims.nbf + && now < nbf.saturating_sub(max_clock_skew_seconds) + { + return Err(unauthorized("websocket jwt is not valid yet")); + } + if let Some(expected_issuer) = issuer + && claims.iss.as_deref() != Some(expected_issuer) + { + return Err(unauthorized("websocket jwt issuer mismatch")); + } + if let Some(expected_audience) = audience + && !audience_matches(claims.aud.as_ref(), expected_audience) + { + return Err(unauthorized("websocket jwt audience mismatch")); + } + + Ok(()) +} + +fn audience_matches(audience: Option<&JwtAudienceClaim>, expected_audience: &str) -> bool { + match audience { + Some(JwtAudienceClaim::Single(actual)) => actual == expected_audience, + Some(JwtAudienceClaim::Multiple(actual)) => { + actual.iter().any(|audience| audience == expected_audience) + } + None => false, + } +} + +fn bearer_token_from_headers(headers: &HeaderMap) -> Result<&str, WebsocketAuthError> { + let raw_header = headers + .get(AUTHORIZATION) + .ok_or_else(|| unauthorized("missing websocket bearer token"))?; + let header = raw_header + .to_str() + .map_err(|_| unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE))?; + let Some((scheme, token)) = header.split_once(' ') else { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + }; + if !scheme.eq_ignore_ascii_case("Bearer") { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + } + let token = token.trim(); + if token.is_empty() { + return Err(unauthorized(INVALID_AUTHORIZATION_HEADER_MESSAGE)); + } + Ok(token) +} + +fn validate_signed_bearer_secret(path: &Path, shared_secret: &[u8]) -> io::Result<()> { + if shared_secret.len() < MIN_SIGNED_BEARER_SECRET_BYTES { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!( + "signed websocket bearer secret {} must be at least {MIN_SIGNED_BEARER_SECRET_BYTES} bytes", + path.display() + ), + )); + } + Ok(()) +} + +fn read_trimmed_secret(path: &std::path::Path) -> io::Result { + let raw = std::fs::read_to_string(path).map_err(|err| { + io::Error::new( + err.kind(), + format!( + "failed to read websocket auth secret {}: {err}", + path.display() + ), + ) + })?; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(io::Error::new( + ErrorKind::InvalidInput, + format!("websocket auth secret {} must not be empty", path.display()), + )); + } + Ok(trimmed.to_string()) +} + +fn absolute_path_arg(flag_name: &str, path: PathBuf) -> anyhow::Result { + AbsolutePathBuf::try_from(path).with_context(|| format!("{flag_name} must be an absolute path")) +} + +fn sha256_digest(input: &[u8]) -> [u8; 32] { + let mut digest = [0u8; 32]; + digest.copy_from_slice(&Sha256::digest(input)); + digest +} + +fn unauthorized(message: &'static str) -> WebsocketAuthError { + WebsocketAuthError { + status_code: StatusCode::UNAUTHORIZED, + message, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use hmac::Hmac; + use hmac::Mac; + use serde_json::json; + + type HmacSha256 = Hmac; + + fn signed_token(shared_secret: &[u8], claims: serde_json::Value) -> String { + let header = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap()); + let payload = format!("{header}.{claims_segment}"); + let mut mac = HmacSha256::new_from_slice(shared_secret).unwrap(); + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + format!("{payload}.{signature}") + } + + #[test] + fn warns_about_unauthenticated_non_loopback_listener() { + let policy = WebsocketAuthPolicy::default(); + assert!(should_warn_about_unauthenticated_non_loopback_listener( + "0.0.0.0:8765".parse().unwrap(), + &policy, + )); + assert!(!should_warn_about_unauthenticated_non_loopback_listener( + "127.0.0.1:8765".parse().unwrap(), + &policy, + )); + assert!(!should_warn_about_unauthenticated_non_loopback_listener( + "0.0.0.0:8765".parse().unwrap(), + &WebsocketAuthPolicy { + mode: Some(WebsocketAuthMode::CapabilityToken { + token_sha256: [0u8; 32], + }), + }, + )); + } + + #[test] + fn capability_token_args_require_token_file() { + let err = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::CapabilityToken), + ..Default::default() + } + .try_into_settings() + .expect_err("capability-token mode should require a token file"); + assert!( + err.to_string().contains("--ws-token-file"), + "unexpected error: {err}" + ); + } + + #[test] + fn signed_bearer_args_require_mode_when_mode_specific_flags_are_set() { + let err = AppServerWebsocketAuthArgs { + ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), + ..Default::default() + } + .try_into_settings() + .expect_err("mode-specific flags should require --ws-auth"); + assert!( + err.to_string().contains("websocket auth flags require"), + "unexpected error: {err}" + ); + } + + #[test] + fn signed_bearer_args_default_clock_skew_and_trim_optional_claims() { + let settings = AppServerWebsocketAuthArgs { + ws_auth: Some(WebsocketAuthCliMode::SignedBearerToken), + ws_shared_secret_file: Some(PathBuf::from("/tmp/secret")), + ws_issuer: Some(" issuer ".to_string()), + ws_audience: Some(" ".to_string()), + ..Default::default() + } + .try_into_settings() + .expect("signed bearer args should parse"); + + assert_eq!( + settings, + AppServerWebsocketAuthSettings { + config: Some(AppServerWebsocketAuthConfig::SignedBearerToken { + shared_secret_file: AbsolutePathBuf::from_absolute_path("/tmp/secret") + .expect("absolute path"), + issuer: Some("issuer".to_string()), + audience: None, + max_clock_skew_seconds: DEFAULT_MAX_CLOCK_SKEW_SECONDS, + }), + } + ); + } + + #[test] + fn signed_bearer_token_verification_rejects_tampering() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + }), + ); + let tampered = token.replace(".eyJleHAi", ".eyJleHBi"); + let err = verify_signed_bearer_token(&tampered, shared_secret, None, None, 30) + .expect_err("tampered jwt should fail"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_token_verification_accepts_valid_token() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "issuer", + "aud": "audience", + }), + ); + verify_signed_bearer_token(&token, shared_secret, Some("issuer"), Some("audience"), 30) + .expect("valid signed token should verify"); + } + + #[test] + fn signed_bearer_token_verification_accepts_multiple_audiences() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "aud": ["other-audience", "audience"], + }), + ); + verify_signed_bearer_token(&token, shared_secret, None, Some("audience"), 30) + .expect("jwt audience arrays should verify"); + } + + #[test] + fn signed_bearer_token_verification_rejects_alg_none_tokens() { + let claims_segment = URL_SAFE_NO_PAD.encode( + serde_json::to_vec(&json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + })) + .unwrap(), + ); + let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"none","typ":"JWT"}"#); + let token = format!("{header_segment}.{claims_segment}."); + let err = + verify_signed_bearer_token(&token, b"0123456789abcdef0123456789abcdef", None, None, 30) + .expect_err("alg=none jwt should be rejected"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn signed_bearer_token_verification_rejects_missing_exp() { + let shared_secret = b"0123456789abcdef0123456789abcdef"; + let token = signed_token( + shared_secret, + json!({ + "iss": "issuer", + }), + ); + let err = verify_signed_bearer_token(&token, shared_secret, None, None, 30) + .expect_err("jwt without exp should be rejected"); + assert_eq!(err.status_code(), StatusCode::UNAUTHORIZED); + } + + #[test] + fn validate_signed_bearer_secret_rejects_short_secret() { + let err = validate_signed_bearer_secret(Path::new("/tmp/secret"), b"too-short") + .expect_err("short shared secret should be rejected"); + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string().contains("must be at least 32 bytes"), + "unexpected error: {err}" + ); + } +} 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 c5c76bf8e9..19158c68cb 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 @@ -2,6 +2,8 @@ use anyhow::Context; use anyhow::Result; use anyhow::bail; use app_test_support::create_mock_responses_server_sequence_unchecked; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; @@ -12,12 +14,16 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use futures::SinkExt; use futures::StreamExt; +use hmac::Hmac; +use hmac::Mac; use reqwest::StatusCode; use serde_json::json; +use sha2::Sha256; use std::net::SocketAddr; use std::path::Path; use std::process::Stdio; use tempfile::TempDir; +use time::OffsetDateTime; use tokio::io::AsyncBufReadExt; use tokio::io::BufReader; use tokio::process::Child; @@ -29,15 +35,17 @@ use tokio::time::timeout; use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::connect_async; -use tokio_tungstenite::tungstenite::Error as WebSocketError; +use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message as WebSocketMessage; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tokio_tungstenite::tungstenite::http::header::ORIGIN; pub(super) const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10); pub(super) type WsClient = WebSocketStream>; +type HmacSha256 = Hmac; #[tokio::test] async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> { @@ -112,46 +120,26 @@ async fn websocket_transport_serves_health_endpoints_on_same_listener() -> Resul } #[tokio::test] -async fn websocket_transport_rejects_requests_with_origin_header() -> Result<()> { +async fn websocket_transport_rejects_browser_origin_without_auth() -> 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 (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?; - let client = reqwest::Client::new(); - let deadline = Instant::now() + Duration::from_secs(10); - let healthz = loop { - match client - .get(format!("http://{bind_addr}/healthz")) - .header(ORIGIN.as_str(), "https://example.com") - .send() - .await - .with_context(|| format!("failed to GET http://{bind_addr}/healthz with Origin header")) - { - Ok(response) => break response, - Err(err) => { - if Instant::now() >= deadline { - bail!("failed to GET http://{bind_addr}/healthz with Origin header: {err}"); - } - sleep(Duration::from_millis(50)).await; - } - } - }; - assert_eq!(healthz.status(), StatusCode::FORBIDDEN); + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, 1, "ws_loopback_client").await?; + let init = read_response_for_id(&mut ws, 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + drop(ws); - let url = format!("ws://{bind_addr}"); - let mut request = url.into_client_request()?; - request - .headers_mut() - .insert(ORIGIN, HeaderValue::from_static("https://example.com")); - match connect_async(request).await { - Err(WebSocketError::Http(response)) => { - assert_eq!(response.status(), StatusCode::FORBIDDEN); - } - Ok(_) => bail!("expected websocket handshake with Origin header to be rejected"), - Err(err) => bail!("expected HTTP rejection for Origin header, got {err}"), - } + assert_websocket_connect_rejected_with_headers( + bind_addr, + None, + Some("https://evil.example"), + StatusCode::FORBIDDEN, + ) + .await?; process .kill() @@ -160,12 +148,205 @@ async fn websocket_transport_rejects_requests_with_origin_header() -> Result<()> Ok(()) } +#[tokio::test] +async fn websocket_transport_rejects_missing_and_invalid_capability_tokens() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let token_file = codex_home.path().join("app-server-token"); + std::fs::write(&token_file, "super-secret-token\n")?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let auth_args = vec![ + "--ws-auth".to_string(), + "capability-token".to_string(), + "--ws-token-file".to_string(), + token_file.display().to_string(), + ]; + + let (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; + + assert_websocket_connect_rejected(bind_addr, None).await?; + assert_websocket_connect_rejected(bind_addr, Some("wrong-token")).await?; + + let mut ws = connect_websocket_with_bearer(bind_addr, Some("super-secret-token")).await?; + send_initialize_request(&mut ws, 1, "ws_auth_client").await?; + let init = read_response_for_id(&mut ws, 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_verifies_signed_short_lived_bearer_tokens() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let shared_secret_file = codex_home.path().join("app-server-signing-secret"); + let shared_secret = "0123456789abcdef0123456789abcdef"; + std::fs::write(&shared_secret_file, format!("{shared_secret}\n"))?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let auth_args = vec![ + "--ws-auth".to_string(), + "signed-bearer-token".to_string(), + "--ws-shared-secret-file".to_string(), + shared_secret_file.display().to_string(), + "--ws-issuer".to_string(), + "codex-enroller".to_string(), + "--ws-audience".to_string(), + "codex-app-server".to_string(), + "--ws-max-clock-skew-seconds".to_string(), + "1".to_string(), + ]; + + let (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://127.0.0.1:0", &auth_args).await?; + let expired_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() - 30, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(expired_token.as_str())).await?; + + let malformed_token = "not-a-jwt"; + assert_websocket_connect_rejected(bind_addr, Some(malformed_token)).await?; + + let not_yet_valid_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "nbf": OffsetDateTime::now_utc().unix_timestamp() + 30, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(not_yet_valid_token.as_str())).await?; + + let wrong_issuer_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "someone-else", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_issuer_token.as_str())).await?; + + let wrong_audience_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "wrong-audience", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_audience_token.as_str())).await?; + + let wrong_signature_token = signed_bearer_token( + b"fedcba9876543210fedcba9876543210", + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + assert_websocket_connect_rejected(bind_addr, Some(wrong_signature_token.as_str())).await?; + + let valid_token = signed_bearer_token( + shared_secret.as_bytes(), + json!({ + "exp": OffsetDateTime::now_utc().unix_timestamp() + 60, + "iss": "codex-enroller", + "aud": "codex-app-server", + }), + )?; + let mut ws = connect_websocket_with_bearer(bind_addr, Some(valid_token.as_str())).await?; + send_initialize_request(&mut ws, 1, "ws_signed_auth_client").await?; + let init = read_response_for_id(&mut ws, 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_rejects_short_signed_bearer_secret_configuration() -> Result<()> { + let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await; + let codex_home = TempDir::new()?; + let shared_secret_file = codex_home.path().join("app-server-signing-secret"); + std::fs::write(&shared_secret_file, "too-short\n")?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + + let output = run_websocket_server_to_completion_with_args( + codex_home.path(), + "ws://127.0.0.1:0", + &[ + "--ws-auth".to_string(), + "signed-bearer-token".to_string(), + "--ws-shared-secret-file".to_string(), + shared_secret_file.display().to_string(), + ], + ) + .await?; + assert!( + !output.status.success(), + "short shared secret should fail websocket server startup" + ); + let stderr = String::from_utf8(output.stderr).context("stderr should be valid utf-8")?; + assert!( + stderr.contains("must be at least 32 bytes"), + "unexpected stderr: {stderr}" + ); + + Ok(()) +} + +#[tokio::test] +async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_default() -> 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 (mut process, bind_addr) = + spawn_websocket_server_with_args(codex_home.path(), "ws://0.0.0.0:0", &[]).await?; + + let mut ws = connect_websocket(bind_addr).await?; + send_initialize_request(&mut ws, 1, "ws_non_loopback_default_client").await?; + let init = read_response_for_id(&mut ws, 1).await?; + assert_eq!(init.id, RequestId::Integer(1)); + + process + .kill() + .await + .context("failed to stop websocket app-server process")?; + + Ok(()) +} + pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, SocketAddr)> { + spawn_websocket_server_with_args(codex_home, "ws://127.0.0.1:0", &[]).await +} + +pub(super) async fn spawn_websocket_server_with_args( + codex_home: &Path, + listen_url: &str, + extra_args: &[String], +) -> Result<(Child, SocketAddr)> { let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") .context("should find app-server binary")?; let mut cmd = Command::new(program); cmd.arg("--listen") - .arg("ws://127.0.0.1:0") + .arg(listen_url) + .args(extra_args) .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::piped()) @@ -230,10 +411,18 @@ pub(super) async fn spawn_websocket_server(codex_home: &Path) -> Result<(Child, } pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result { - let url = format!("ws://{bind_addr}"); + connect_websocket_with_bearer(bind_addr, None).await +} + +pub(super) async fn connect_websocket_with_bearer( + bind_addr: SocketAddr, + bearer_token: Option<&str>, +) -> Result { + let url = format!("ws://{}", connectable_bind_addr(bind_addr)); + let request = websocket_request(url.as_str(), bearer_token, None)?; let deadline = Instant::now() + Duration::from_secs(10); loop { - match connect_async(&url).await { + match connect_async(request.clone()).await { Ok((stream, _response)) => return Ok(stream), Err(err) => { if Instant::now() >= deadline { @@ -245,23 +434,83 @@ pub(super) async fn connect_websocket(bind_addr: SocketAddr) -> Result } } +async fn assert_websocket_connect_rejected( + bind_addr: SocketAddr, + bearer_token: Option<&str>, +) -> Result<()> { + assert_websocket_connect_rejected_with_headers( + bind_addr, + bearer_token, + None, + StatusCode::UNAUTHORIZED, + ) + .await +} + +async fn assert_websocket_connect_rejected_with_headers( + bind_addr: SocketAddr, + bearer_token: Option<&str>, + origin: Option<&str>, + expected_status: StatusCode, +) -> Result<()> { + let url = format!("ws://{}", connectable_bind_addr(bind_addr)); + let request = websocket_request(url.as_str(), bearer_token, origin)?; + + match connect_async(request).await { + Ok((_stream, response)) => { + bail!( + "expected websocket handshake rejection, got {}", + response.status() + ) + } + Err(WsError::Http(response)) => { + assert_eq!(response.status(), expected_status); + Ok(()) + } + Err(err) => bail!("expected http rejection during websocket handshake: {err}"), + } +} + +async fn run_websocket_server_to_completion_with_args( + codex_home: &Path, + listen_url: &str, + extra_args: &[String], +) -> Result { + let program = codex_utils_cargo_bin::cargo_bin("codex-app-server") + .context("should find app-server binary")?; + let mut cmd = Command::new(program); + cmd.arg("--listen") + .arg(listen_url) + .args(extra_args) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .env("CODEX_HOME", codex_home) + .env("RUST_LOG", "debug"); + timeout(Duration::from_secs(10), cmd.output()) + .await + .context("timed out waiting for websocket app-server to exit")? + .context("failed to run websocket app-server") +} + async fn http_get( client: &reqwest::Client, bind_addr: SocketAddr, path: &str, ) -> Result { + let connectable_bind_addr = connectable_bind_addr(bind_addr); let deadline = Instant::now() + Duration::from_secs(10); loop { match client - .get(format!("http://{bind_addr}{path}")) + .get(format!("http://{connectable_bind_addr}{path}")) .send() .await - .with_context(|| format!("failed to GET http://{bind_addr}{path}")) + .with_context(|| format!("failed to GET http://{connectable_bind_addr}{path}")) { Ok(response) => return Ok(response), Err(err) => { if Instant::now() >= deadline { - bail!("failed to GET http://{bind_addr}{path}: {err}"); + bail!("failed to GET http://{connectable_bind_addr}{path}: {err}"); } sleep(Duration::from_millis(50)).await; } @@ -269,6 +518,30 @@ async fn http_get( } } +fn websocket_request( + url: &str, + bearer_token: Option<&str>, + origin: Option<&str>, +) -> Result> { + let mut request = url + .into_client_request() + .context("failed to create websocket request")?; + if let Some(bearer_token) = bearer_token { + request.headers_mut().insert( + AUTHORIZATION, + HeaderValue::from_str(&format!("Bearer {bearer_token}")) + .context("invalid bearer token header")?, + ); + } + if let Some(origin) = origin { + request.headers_mut().insert( + ORIGIN, + HeaderValue::from_str(origin).context("invalid origin header")?, + ); + } + Ok(request) +} + pub(super) async fn send_initialize_request( stream: &mut WsClient, id: i64, @@ -459,3 +732,25 @@ stream_max_retries = 0 ), ) } + +fn connectable_bind_addr(bind_addr: SocketAddr) -> SocketAddr { + match bind_addr { + SocketAddr::V4(addr) if addr.ip().is_unspecified() => { + SocketAddr::from(([127, 0, 0, 1], addr.port())) + } + SocketAddr::V6(addr) if addr.ip().is_unspecified() => { + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], addr.port())) + } + _ => bind_addr, + } +} + +fn signed_bearer_token(shared_secret: &[u8], claims: serde_json::Value) -> Result { + let header_segment = URL_SAFE_NO_PAD.encode(br#"{"alg":"HS256","typ":"JWT"}"#); + let claims_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?); + let payload = format!("{header_segment}.{claims_segment}"); + let mut mac = HmacSha256::new_from_slice(shared_secret).context("failed to create hmac")?; + mac.update(payload.as_bytes()); + let signature = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()); + Ok(format!("{payload}.{signature}")) +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 82ede69320..5e0d405eb3 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -353,6 +353,9 @@ struct AppServerCommand { /// See https://developers.openai.com/codex/config-advanced/#metrics for more details. #[arg(long = "analytics-default-enabled")] analytics_default_enabled: bool, + + #[command(flatten)] + auth: codex_app_server::AppServerWebsocketAuthArgs, } #[derive(Debug, clap::Subcommand)] @@ -643,49 +646,59 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; } - Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand { - None => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?; - let transport = app_server_cli.listen; - codex_app_server::run_main_with_transport( - arg0_paths.clone(), - root_config_overrides, - codex_core::config_loader::LoaderOverrides::default(), - app_server_cli.analytics_default_enabled, - transport, - codex_protocol::protocol::SessionSource::VSCode, - ) - .await?; + Some(Subcommand::AppServer(app_server_cli)) => { + let AppServerCommand { + subcommand, + listen, + analytics_default_enabled, + auth, + } = app_server_cli; + match subcommand { + None => { + reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?; + let transport = listen; + let auth = auth.try_into_settings()?; + codex_app_server::run_main_with_transport( + arg0_paths.clone(), + root_config_overrides, + codex_core::config_loader::LoaderOverrides::default(), + analytics_default_enabled, + transport, + codex_protocol::protocol::SessionSource::VSCode, + auth, + ) + .await?; + } + Some(AppServerSubcommand::GenerateTs(gen_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + "app-server generate-ts", + )?; + let options = codex_app_server_protocol::GenerateTsOptions { + experimental_api: gen_cli.experimental, + ..Default::default() + }; + codex_app_server_protocol::generate_ts_with_options( + &gen_cli.out_dir, + gen_cli.prettier.as_deref(), + options, + )?; + } + Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + "app-server generate-json-schema", + )?; + codex_app_server_protocol::generate_json_with_experimental( + &gen_cli.out_dir, + gen_cli.experimental, + )?; + } + Some(AppServerSubcommand::GenerateInternalJsonSchema(gen_cli)) => { + codex_app_server_protocol::generate_internal_json_schema(&gen_cli.out_dir)?; + } } - Some(AppServerSubcommand::GenerateTs(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-ts", - )?; - let options = codex_app_server_protocol::GenerateTsOptions { - experimental_api: gen_cli.experimental, - ..Default::default() - }; - codex_app_server_protocol::generate_ts_with_options( - &gen_cli.out_dir, - gen_cli.prettier.as_deref(), - options, - )?; - } - Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-json-schema", - )?; - codex_app_server_protocol::generate_json_with_experimental( - &gen_cli.out_dir, - gen_cli.experimental, - )?; - } - Some(AppServerSubcommand::GenerateInternalJsonSchema(gen_cli)) => { - codex_app_server_protocol::generate_internal_json_schema(&gen_cli.out_dir)?; - } - }, + } #[cfg(target_os = "macos")] Some(Subcommand::App(app_cli)) => { reject_remote_mode_for_subcommand(root_remote.as_deref(), "app")?; @@ -1701,6 +1714,71 @@ mod tests { assert!(parse_result.is_err()); } + #[test] + fn app_server_capability_token_flags_parse() { + let app_server = app_server_from_args( + [ + "codex", + "app-server", + "--ws-auth", + "capability-token", + "--ws-token-file", + "/tmp/codex-token", + ] + .as_ref(), + ); + assert_eq!( + app_server.auth.ws_auth, + Some(codex_app_server::WebsocketAuthCliMode::CapabilityToken) + ); + assert_eq!( + app_server.auth.ws_token_file, + Some(PathBuf::from("/tmp/codex-token")) + ); + } + + #[test] + fn app_server_signed_bearer_flags_parse() { + let app_server = app_server_from_args( + [ + "codex", + "app-server", + "--ws-auth", + "signed-bearer-token", + "--ws-shared-secret-file", + "/tmp/codex-secret", + "--ws-issuer", + "issuer", + "--ws-audience", + "audience", + "--ws-max-clock-skew-seconds", + "9", + ] + .as_ref(), + ); + assert_eq!( + app_server.auth.ws_auth, + Some(codex_app_server::WebsocketAuthCliMode::SignedBearerToken) + ); + assert_eq!( + app_server.auth.ws_shared_secret_file, + Some(PathBuf::from("/tmp/codex-secret")) + ); + assert_eq!(app_server.auth.ws_issuer.as_deref(), Some("issuer")); + assert_eq!(app_server.auth.ws_audience.as_deref(), Some("audience")); + assert_eq!(app_server.auth.ws_max_clock_skew_seconds, Some(9)); + } + + #[test] + fn app_server_rejects_removed_insecure_non_loopback_flag() { + let parse_result = MultitoolCli::try_parse_from([ + "codex", + "app-server", + "--allow-unauthenticated-non-loopback-ws", + ]); + assert!(parse_result.is_err()); + } + #[test] fn features_enable_parses_feature_name() { let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"]) From e9996ec62a9ae4e2d7c6e4f1c701544da7067def Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 25 Mar 2026 16:50:39 -0300 Subject: [PATCH 47/50] fix(tui_app_server): preserve transcript events under backpressure (#15759) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR When running codex with `-c features.tui_app_server=true` we see corruption when streaming large amounts of data. This PR marks other event types as _critical_ by making them _must-deliver_. ## Problem When the TUI consumer falls behind the app-server event stream, the bounded `mpsc` channel fills up and the forwarding layer drops events via `try_send`. Previously only `TurnCompleted` was marked as must-deliver. Streamed assistant text (`AgentMessageDelta`) and the authoritative final item (`ItemCompleted`) were treated as droppable — the same as ephemeral command output deltas. Because the TUI renders markdown incrementally from these deltas, dropping any of them produces permanently corrupted or incomplete paragraphs that persist for the rest of the session. ## Mental model The app-server event stream has two tiers of importance: 1. **Lossless (transcript + terminal):** Events that form the authoritative record of what the assistant said or that signal turn lifecycle transitions. Losing any of these corrupts the visible output or leaves surfaces waiting forever. These are: `AgentMessageDelta`, `PlanDelta`, `ReasoningSummaryTextDelta`, `ReasoningTextDelta`, `ItemCompleted`, and `TurnCompleted`. 2. **Best-effort (everything else):** Ephemeral status events like `CommandExecutionOutputDelta` and progress notifications. Dropping these under load causes cosmetic gaps but no permanent corruption. The forwarding layer uses `try_send` for best-effort events (dropping on backpressure) and blocking `send().await` for lossless events (applying back-pressure to the producer until the consumer catches up). ## Non-goals - Eliminating backpressure entirely. The bounded queue is intentional; this change only widens the set of events that survive it. - Changing the event protocol or adding new notification types. - Addressing root causes of consumer slowness (e.g. TUI render cost). ## Tradeoffs Blocking on transcript events means a slow consumer can now stall the producer for the duration of those events. This is acceptable because: (a) the alternative is permanently broken output, which is worse; (b) the consumer already had to keep up with `TurnCompleted` blocking sends; and (c) transcript events arrive at model-output speed, not burst speed, so sustained saturation is unlikely in practice. ## Architecture Two parallel changes, one per transport: - **In-process path** (`lib.rs`): The inline forwarding logic was extracted into `forward_in_process_event`, a standalone async function that encapsulates the lag-marker / must-deliver / try-send decision tree. The worker loop now delegates to it. A new `server_notification_requires_delivery` function (shared `pub(crate)`) centralizes the notification classification. - **Remote path** (`remote.rs`): The local `event_requires_delivery` now delegates to the same shared `server_notification_requires_delivery`, keeping both transports in sync. ## Observability No new metrics or log lines. The existing `warn!` on event drops continues to fire for best-effort events. Lossless events that block will not produce a log line (they simply wait). ## Tests - `event_requires_delivery_marks_transcript_and_terminal_events`: unit test confirming the expanded classification covers `AgentMessageDelta`, `ItemCompleted`, `TurnCompleted`, and excludes `CommandExecutionOutputDelta` and `Lagged`. - `forward_in_process_event_preserves_transcript_notifications_under_backpressure`: integration-style test that fills a capacity-1 channel, verifies a best-effort event is dropped (skipped count increments), then sends lossless transcript events and confirms they all arrive in order with the correct lag marker preceding them. - `remote_backpressure_preserves_transcript_notifications`: end-to-end test over a real websocket that verifies the remote transport preserves transcript events under the same backpressure scenario. - `event_requires_delivery_marks_transcript_and_disconnect_events` (remote): unit test confirming the remote-side classification covers transcript events and `Disconnected`. --------- Co-authored-by: Eric Traut --- codex-rs/app-server-client/src/lib.rs | 500 +++++++++++++++++++---- codex-rs/app-server-client/src/remote.rs | 46 ++- 2 files changed, 457 insertions(+), 89 deletions(-) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 115e9808f0..1ea9f6fd87 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -85,17 +85,128 @@ impl From for AppServerEvent { } fn event_requires_delivery(event: &InProcessServerEvent) -> bool { - // These terminal events drive surface shutdown/completion state. Dropping - // them under backpressure can leave exec/TUI waiting forever even though - // the underlying turn has already ended. + // These transcript and terminal events must remain lossless. Dropping + // streamed assistant text or the authoritative completed item can leave + // the TUI with permanently corrupted markdown, while dropping completion + // notifications can leave surfaces waiting forever. + match event { + InProcessServerEvent::ServerNotification(notification) => { + server_notification_requires_delivery(notification) + } + _ => false, + } +} + +/// Returns `true` for notifications that must survive backpressure. +/// +/// Transcript events (`AgentMessageDelta`, `PlanDelta`, reasoning deltas) and +/// the authoritative `ItemCompleted` / `TurnCompleted` form the lossless tier +/// of the event stream. Dropping any of these corrupts the visible assistant +/// output or leaves surfaces waiting for a completion signal that already +/// fired. Everything else (`CommandExecutionOutputDelta`, progress, etc.) is +/// best-effort and may be dropped with only cosmetic impact. +/// +/// Both the in-process and remote transports delegate to this function so the +/// classification stays in sync. +pub(crate) fn server_notification_requires_delivery(notification: &ServerNotification) -> bool { matches!( - event, - InProcessServerEvent::ServerNotification( - codex_app_server_protocol::ServerNotification::TurnCompleted(_), - ) + notification, + ServerNotification::TurnCompleted(_) + | ServerNotification::ItemCompleted(_) + | ServerNotification::AgentMessageDelta(_) + | ServerNotification::PlanDelta(_) + | ServerNotification::ReasoningSummaryTextDelta(_) + | ServerNotification::ReasoningTextDelta(_) ) } +/// Outcome of attempting to forward a single event to the consumer channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ForwardEventResult { + /// The event was delivered (or intentionally dropped); the stream is healthy. + Continue, + /// The consumer channel is closed; the caller should stop producing events. + DisableStream, +} + +/// Forwards a single in-process event to the consumer, respecting the +/// lossless/best-effort split. +/// +/// Lossless events (transcript deltas, item/turn completions) block until the +/// consumer drains capacity. Best-effort events use `try_send` and increment +/// `skipped_events` on failure. When a lag marker needs to be flushed before a +/// lossless event, the flush itself blocks so the marker is never lost. +/// +/// If a dropped event is a `ServerRequest`, `reject_server_request` is called +/// so the server does not wait for a response that will never come. +async fn forward_in_process_event( + event_tx: &mpsc::Sender, + skipped_events: &mut usize, + event: InProcessServerEvent, + mut reject_server_request: F, +) -> ForwardEventResult +where + F: FnMut(ServerRequest), +{ + if *skipped_events > 0 { + if event_requires_delivery(&event) { + // Surface lag before the lossless event, but do not let the lag marker itself cause + // us to drop the transcript/completion notification the caller is blocked on. + if event_tx + .send(InProcessServerEvent::Lagged { + skipped: *skipped_events, + }) + .await + .is_err() + { + return ForwardEventResult::DisableStream; + } + *skipped_events = 0; + } else { + match event_tx.try_send(InProcessServerEvent::Lagged { + skipped: *skipped_events, + }) { + Ok(()) => { + *skipped_events = 0; + } + Err(mpsc::error::TrySendError::Full(_)) => { + *skipped_events = skipped_events.saturating_add(1); + warn!("dropping in-process app-server event because consumer queue is full"); + if let InProcessServerEvent::ServerRequest(request) = event { + reject_server_request(request); + } + return ForwardEventResult::Continue; + } + Err(mpsc::error::TrySendError::Closed(_)) => { + return ForwardEventResult::DisableStream; + } + } + } + } + + if event_requires_delivery(&event) { + // Block until the consumer catches up for transcript/completion notifications; this + // preserves the visible assistant output even when the queue is otherwise saturated. + if event_tx.send(event).await.is_err() { + return ForwardEventResult::DisableStream; + } + return ForwardEventResult::Continue; + } + + match event_tx.try_send(event) { + Ok(()) => ForwardEventResult::Continue, + Err(mpsc::error::TrySendError::Full(event)) => { + *skipped_events = skipped_events.saturating_add(1); + warn!("dropping in-process app-server event because consumer queue is full"); + if let InProcessServerEvent::ServerRequest(request) = event { + reject_server_request(request); + } + ForwardEventResult::Continue + } + Err(mpsc::error::TrySendError::Closed(_)) => ForwardEventResult::DisableStream, + } +} + /// Layered error for [`InProcessAppServerClient::request_typed`]. /// /// This keeps transport failures, server-side JSON-RPC failures, and response @@ -366,83 +477,26 @@ impl InProcessAppServerClient { continue; } - if skipped_events > 0 { - if event_requires_delivery(&event) { - // Surface lag before the terminal event, but - // do not let the lag marker itself cause us to - // drop the completion/abort notification that - // the caller is blocked on. - if event_tx - .send(InProcessServerEvent::Lagged { - skipped: skipped_events, - }) - .await - .is_err() - { - event_stream_enabled = false; - continue; - } - skipped_events = 0; - } else { - match event_tx.try_send(InProcessServerEvent::Lagged { - skipped: skipped_events, - }) { - Ok(()) => { - skipped_events = 0; - } - Err(mpsc::error::TrySendError::Full(_)) => { - skipped_events = skipped_events.saturating_add(1); - warn!( - "dropping in-process app-server event because consumer queue is full" - ); - if let InProcessServerEvent::ServerRequest(request) = event { - let _ = request_sender.fail_server_request( - request.id().clone(), - JSONRPCErrorError { - code: -32001, - message: "in-process app-server event queue is full".to_string(), - data: None, - }, - ); - } - continue; - } - Err(mpsc::error::TrySendError::Closed(_)) => { - event_stream_enabled = false; - continue; - } - } - } - } - - if event_requires_delivery(&event) { - // Block until the consumer catches up for - // terminal notifications; this preserves the - // completion signal even when the queue is - // otherwise saturated. - if event_tx.send(event).await.is_err() { - event_stream_enabled = false; - } - continue; - } - - match event_tx.try_send(event) { - Ok(()) => {} - Err(mpsc::error::TrySendError::Full(event)) => { - skipped_events = skipped_events.saturating_add(1); - warn!("dropping in-process app-server event because consumer queue is full"); - if let InProcessServerEvent::ServerRequest(request) = event { - let _ = request_sender.fail_server_request( - request.id().clone(), - JSONRPCErrorError { - code: -32001, - message: "in-process app-server event queue is full".to_string(), - data: None, - }, - ); - } - } - Err(mpsc::error::TrySendError::Closed(_)) => { + match forward_in_process_event( + &event_tx, + &mut skipped_events, + event, + |request| { + let _ = request_sender.fail_server_request( + request.id().clone(), + JSONRPCErrorError { + code: -32001, + message: "in-process app-server event queue is full" + .to_string(), + data: None, + }, + ); + }, + ) + .await + { + ForwardEventResult::Continue => {} + ForwardEventResult::DisableStream => { event_stream_enabled = false; } } @@ -933,6 +987,53 @@ mod tests { .expect("message should send"); } + fn command_execution_output_delta_notification(delta: &str) -> ServerNotification { + ServerNotification::CommandExecutionOutputDelta( + codex_app_server_protocol::CommandExecutionOutputDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: delta.to_string(), + }, + ) + } + + fn agent_message_delta_notification(delta: &str) -> ServerNotification { + ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: delta.to_string(), + }, + ) + } + + fn item_completed_notification(text: &str) -> ServerNotification { + ServerNotification::ItemCompleted(codex_app_server_protocol::ItemCompletedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item: codex_app_server_protocol::ThreadItem::AgentMessage { + id: "item".to_string(), + text: text.to_string(), + phase: None, + memory_citation: None, + }, + }) + } + + fn turn_completed_notification() -> ServerNotification { + ServerNotification::TurnCompleted(codex_app_server_protocol::TurnCompletedNotification { + thread_id: "thread".to_string(), + turn: codex_app_server_protocol::Turn { + id: "turn".to_string(), + items: Vec::new(), + status: codex_app_server_protocol::TurnStatus::Completed, + error: None, + }, + }) + } + fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs { RemoteAppServerConnectArgs { websocket_url, @@ -1043,6 +1144,94 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn forward_in_process_event_preserves_transcript_notifications_under_backpressure() { + let (event_tx, mut event_rx) = mpsc::channel(1); + event_tx + .send(InProcessServerEvent::ServerNotification( + command_execution_output_delta_notification("stdout-1"), + )) + .await + .expect("initial event should enqueue"); + + let mut skipped_events = 0usize; + let result = forward_in_process_event( + &event_tx, + &mut skipped_events, + InProcessServerEvent::ServerNotification(command_execution_output_delta_notification( + "stdout-2", + )), + |_| {}, + ) + .await; + assert_eq!(result, ForwardEventResult::Continue); + assert_eq!(skipped_events, 1); + + let receive_task = tokio::spawn(async move { + let mut events = Vec::new(); + for _ in 0..5 { + events.push( + timeout(Duration::from_secs(2), event_rx.recv()) + .await + .expect("event should arrive before timeout") + .expect("event stream should stay open"), + ); + } + events + }); + + for notification in [ + agent_message_delta_notification("hello"), + item_completed_notification("hello"), + turn_completed_notification(), + ] { + let result = forward_in_process_event( + &event_tx, + &mut skipped_events, + InProcessServerEvent::ServerNotification(notification), + |_| {}, + ) + .await; + assert_eq!(result, ForwardEventResult::Continue); + } + assert_eq!(skipped_events, 0); + + let events = receive_task + .await + .expect("receiver task should join successfully"); + assert!(matches!( + &events[0], + InProcessServerEvent::ServerNotification( + ServerNotification::CommandExecutionOutputDelta(notification) + ) if notification.delta == "stdout-1" + )); + assert!(matches!( + &events[1], + InProcessServerEvent::Lagged { skipped: 1 } + )); + assert!(matches!( + &events[2], + InProcessServerEvent::ServerNotification(ServerNotification::AgentMessageDelta( + notification + )) if notification.delta == "hello" + )); + assert!(matches!( + &events[3], + InProcessServerEvent::ServerNotification(ServerNotification::ItemCompleted( + notification + )) if matches!( + ¬ification.item, + codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } if text == "hello" + ) + )); + assert!(matches!( + &events[4], + InProcessServerEvent::ServerNotification(ServerNotification::TurnCompleted( + notification + )) if notification.turn.status == codex_app_server_protocol::TurnStatus::Completed + )); + } + #[tokio::test] async fn remote_typed_request_roundtrip_works() { let websocket_url = start_test_remote_server(|mut websocket| async move { @@ -1207,6 +1396,107 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_backpressure_preserves_transcript_notifications() { + let (done_tx, done_rx) = tokio::sync::oneshot::channel(); + let websocket_url = start_test_remote_server(|mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + for notification in [ + command_execution_output_delta_notification("stdout-1"), + command_execution_output_delta_notification("stdout-2"), + agent_message_delta_notification("hello"), + item_completed_notification("hello"), + turn_completed_notification(), + ] { + write_websocket_message( + &mut websocket, + JSONRPCMessage::Notification( + serde_json::from_value( + serde_json::to_value(notification) + .expect("notification should serialize"), + ) + .expect("notification should convert to JSON-RPC"), + ), + ) + .await; + } + let _ = done_rx.await; + }) + .await; + let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url, + client_name: "codex-app-server-client-test".to_string(), + client_version: "0.0.0-test".to_string(), + experimental_api: true, + opt_out_notification_methods: Vec::new(), + channel_capacity: 1, + }) + .await + .expect("remote client should connect"); + + let first_event = timeout(Duration::from_secs(2), client.next_event()) + .await + .expect("first event should arrive before timeout") + .expect("event stream should stay open"); + assert!(matches!( + first_event, + AppServerEvent::ServerNotification(ServerNotification::CommandExecutionOutputDelta( + notification + )) if notification.delta == "stdout-1" + )); + + let mut remaining_events = Vec::new(); + for _ in 0..4 { + remaining_events.push( + timeout(Duration::from_secs(2), client.next_event()) + .await + .expect("event should arrive before timeout") + .expect("event stream should stay open"), + ); + } + + let mut transcript_event_names = Vec::new(); + for event in &remaining_events { + match event { + AppServerEvent::Lagged { skipped: 1 } => {} + AppServerEvent::ServerNotification( + ServerNotification::CommandExecutionOutputDelta(notification), + ) if notification.delta == "stdout-2" => {} + AppServerEvent::ServerNotification(ServerNotification::AgentMessageDelta( + notification, + )) if notification.delta == "hello" => { + transcript_event_names.push("agent_message_delta"); + } + AppServerEvent::ServerNotification(ServerNotification::ItemCompleted( + notification, + )) if matches!( + ¬ification.item, + codex_app_server_protocol::ThreadItem::AgentMessage { text, .. } if text == "hello" + ) => + { + transcript_event_names.push("item_completed"); + } + AppServerEvent::ServerNotification(ServerNotification::TurnCompleted( + notification, + )) if notification.turn.status + == codex_app_server_protocol::TurnStatus::Completed => + { + transcript_event_names.push("turn_completed"); + } + _ => panic!("unexpected remaining event: {event:?}"), + } + } + assert_eq!( + transcript_event_names, + vec!["agent_message_delta", "item_completed", "turn_completed"] + ); + + done_tx + .send(()) + .expect("server completion signal should send"); + client.shutdown().await.expect("shutdown should complete"); + } + #[tokio::test] async fn remote_server_request_resolution_roundtrip_works() { let websocket_url = start_test_remote_server(|mut websocket| async move { @@ -1446,7 +1736,7 @@ mod tests { } #[test] - fn event_requires_delivery_marks_terminal_events() { + fn event_requires_delivery_marks_transcript_and_terminal_events() { assert!(event_requires_delivery( &InProcessServerEvent::ServerNotification( codex_app_server_protocol::ServerNotification::TurnCompleted( @@ -1462,9 +1752,49 @@ mod tests { ) ) )); + assert!(event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: "hello".to_string(), + } + ) + ) + )); + assert!(event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item: codex_app_server_protocol::ThreadItem::AgentMessage { + id: "item".to_string(), + text: "hello".to_string(), + phase: None, + memory_citation: None, + }, + } + ) + ) + )); assert!(!event_requires_delivery(&InProcessServerEvent::Lagged { skipped: 1 })); + assert!(!event_requires_delivery( + &InProcessServerEvent::ServerNotification( + codex_app_server_protocol::ServerNotification::CommandExecutionOutputDelta( + codex_app_server_protocol::CommandExecutionOutputDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: "stdout".to_string(), + } + ) + ) + )); } #[tokio::test] diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index b9179716fe..9cf37e262f 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -21,6 +21,7 @@ use crate::RequestResult; use crate::SHUTDOWN_TIMEOUT; use crate::TypedRequestError; use crate::request_method_name; +use crate::server_notification_requires_delivery; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientNotification; use codex_app_server_protocol::ClientRequest; @@ -854,11 +855,11 @@ async fn reject_if_server_request_dropped( fn event_requires_delivery(event: &AppServerEvent) -> bool { match event { - AppServerEvent::ServerNotification(ServerNotification::TurnCompleted(_)) => true, + AppServerEvent::ServerNotification(notification) => { + server_notification_requires_delivery(notification) + } AppServerEvent::Disconnected { .. } => true, - AppServerEvent::Lagged { .. } - | AppServerEvent::ServerNotification(_) - | AppServerEvent::ServerRequest(_) => false, + AppServerEvent::Lagged { .. } | AppServerEvent::ServerRequest(_) => false, } } @@ -905,3 +906,40 @@ async fn write_jsonrpc_message( )) }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_requires_delivery_marks_transcript_and_disconnect_events() { + assert!(event_requires_delivery( + &AppServerEvent::ServerNotification(ServerNotification::AgentMessageDelta( + codex_app_server_protocol::AgentMessageDeltaNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item_id: "item".to_string(), + delta: "hello".to_string(), + }, + ),) + )); + assert!(event_requires_delivery( + &AppServerEvent::ServerNotification(ServerNotification::ItemCompleted( + codex_app_server_protocol::ItemCompletedNotification { + thread_id: "thread".to_string(), + turn_id: "turn".to_string(), + item: codex_app_server_protocol::ThreadItem::Plan { + id: "item".to_string(), + text: "step".to_string(), + }, + } + ),) + )); + assert!(event_requires_delivery(&AppServerEvent::Disconnected { + message: "closed".to_string(), + })); + assert!(!event_requires_delivery(&AppServerEvent::Lagged { + skipped: 1 + })); + } +} From 9dbe0983490c2f952b8791e634b5ec3ce94edee9 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Wed, 25 Mar 2026 12:57:42 -0700 Subject: [PATCH 48/50] Extract codex-core-skills crate (#15749) ## Summary - move skill loading and management into codex-core-skills - leave codex-core with the thin integration layer and shared wiring ## Testing - CI --------- Co-authored-by: Codex --- codex-rs/Cargo.lock | 33 +- codex-rs/Cargo.toml | 2 + .../app-server/src/codex_message_processor.rs | 55 ++- codex-rs/config/Cargo.toml | 1 + codex-rs/config/src/lib.rs | 7 + codex-rs/config/src/project_root_markers.rs | 50 +++ codex-rs/config/src/skills_config.rs | 53 +++ codex-rs/core-skills/BUILD.bazel | 15 + codex-rs/core-skills/Cargo.toml | 40 ++ .../src}/config_rules.rs | 22 +- .../core-skills/src/env_var_dependencies.rs | 30 ++ .../skills => core-skills/src}/injection.rs | 36 +- .../src}/injection_tests.rs | 0 .../src}/invocation_utils.rs | 100 +---- .../src}/invocation_utils_tests.rs | 0 .../skills/mod.rs => core-skills/src/lib.rs} | 14 +- .../src/skills => core-skills/src}/loader.rs | 41 +- .../src}/loader_tests.rs | 141 ++++--- .../src/skills => core-skills/src}/manager.rs | 155 +++---- .../src}/manager_tests.rs | 378 +++++++----------- codex-rs/core-skills/src/mention_counts.rs | 24 ++ .../src/skills => core-skills/src}/model.rs | 0 .../src/skills => core-skills/src}/remote.rs | 16 +- .../src/skills => core-skills/src}/render.rs | 2 +- .../src/skills => core-skills/src}/system.rs | 0 codex-rs/core/Cargo.toml | 3 +- codex-rs/core/src/agent/role_tests.rs | 10 +- codex-rs/core/src/codex.rs | 109 ++++- codex-rs/core/src/codex_tests.rs | 39 +- codex-rs/core/src/codex_tests_guardian.rs | 6 +- codex-rs/core/src/config/mod.rs | 2 +- codex-rs/core/src/config/types.rs | 37 +- codex-rs/core/src/config_loader/mod.rs | 53 +-- codex-rs/core/src/instructions/mod.rs | 1 - codex-rs/core/src/lib.rs | 29 +- codex-rs/core/src/mcp/skill_dependencies.rs | 4 +- .../core/src/mcp/skill_dependencies_tests.rs | 2 +- codex-rs/core/src/plugins/manager.rs | 15 +- codex-rs/core/src/plugins/manifest.rs | 2 +- codex-rs/core/src/{ => plugins}/mentions.rs | 33 +- .../core/src/{ => plugins}/mentions_tests.rs | 0 codex-rs/core/src/plugins/mod.rs | 8 +- codex-rs/core/src/skills.rs | 230 +++++++++++ .../core/src/skills/env_var_dependencies.rs | 162 -------- codex-rs/core/src/skills_watcher.rs | 10 +- codex-rs/core/src/state/service.rs | 2 +- codex-rs/core/src/thread_manager.rs | 12 +- codex-rs/core/src/tools/handlers/shell.rs | 5 +- .../core/src/tools/handlers/unified_exec.rs | 7 +- codex-rs/core/src/tools/runtimes/mod.rs | 2 +- .../tools/runtimes/shell/unix_escalation.rs | 13 +- .../runtimes/shell/unix_escalation_tests.rs | 2 +- codex-rs/plugin/src/plugin_namespace.rs | 70 ++++ 53 files changed, 1201 insertions(+), 882 deletions(-) create mode 100644 codex-rs/config/src/project_root_markers.rs create mode 100644 codex-rs/config/src/skills_config.rs create mode 100644 codex-rs/core-skills/BUILD.bazel create mode 100644 codex-rs/core-skills/Cargo.toml rename codex-rs/{core/src/skills => core-skills/src}/config_rules.rs (88%) create mode 100644 codex-rs/core-skills/src/env_var_dependencies.rs rename codex-rs/{core/src/skills => core-skills/src}/injection.rs (93%) rename codex-rs/{core/src/skills => core-skills/src}/injection_tests.rs (100%) rename codex-rs/{core/src/skills => core-skills/src}/invocation_utils.rs (58%) rename codex-rs/{core/src/skills => core-skills/src}/invocation_utils_tests.rs (100%) rename codex-rs/{core/src/skills/mod.rs => core-skills/src/lib.rs} (55%) rename codex-rs/{core/src/skills => core-skills/src}/loader.rs (96%) rename codex-rs/{core/src/skills => core-skills/src}/loader_tests.rs (95%) rename codex-rs/{core/src/skills => core-skills/src}/manager.rs (67%) rename codex-rs/{core/src/skills => core-skills/src}/manager_tests.rs (67%) create mode 100644 codex-rs/core-skills/src/mention_counts.rs rename codex-rs/{core/src/skills => core-skills/src}/model.rs (100%) rename codex-rs/{core/src/skills => core-skills/src}/remote.rs (95%) rename codex-rs/{core/src/skills => core-skills/src}/render.rs (98%) rename codex-rs/{core/src/skills => core-skills/src}/system.rs (100%) rename codex-rs/core/src/{ => plugins}/mentions.rs (77%) rename codex-rs/core/src/{ => plugins}/mentions_tests.rs (100%) create mode 100644 codex-rs/core/src/skills.rs delete mode 100644 codex-rs/core/src/skills/env_var_dependencies.rs create mode 100644 codex-rs/plugin/src/plugin_namespace.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 187fcca79a..5e36191371 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1850,6 +1850,7 @@ dependencies = [ "futures", "multimap", "pretty_assertions", + "schemars 0.8.22", "serde", "serde_json", "serde_path_to_error", @@ -1899,6 +1900,7 @@ dependencies = [ "codex-code-mode", "codex-config", "codex-connectors", + "codex-core-skills", "codex-exec-server", "codex-execpolicy", "codex-features", @@ -1916,7 +1918,6 @@ dependencies = [ "codex-secrets", "codex-shell-command", "codex-shell-escalation", - "codex-skills", "codex-state", "codex-terminal-detection", "codex-test-macros", @@ -1966,7 +1967,6 @@ dependencies = [ "seccompiler", "serde", "serde_json", - "serde_yaml", "serial_test", "sha1", "shlex", @@ -1995,6 +1995,35 @@ dependencies = [ "zstd", ] +[[package]] +name = "codex-core-skills" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-analytics", + "codex-app-server-protocol", + "codex-config", + "codex-instructions", + "codex-login", + "codex-otel", + "codex-protocol", + "codex-skills", + "codex-utils-absolute-path", + "codex-utils-plugins", + "dirs", + "dunce", + "pretty_assertions", + "serde", + "serde_json", + "serde_yaml", + "shlex", + "tempfile", + "tokio", + "toml 0.9.11+spec-1.1.0", + "tracing", + "zip", +] + [[package]] name = "codex-debug-client" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 350a98e451..f19fb650f7 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -25,6 +25,7 @@ members = [ "shell-escalation", "skills", "core", + "core-skills", "hooks", "instructions", "secrets", @@ -119,6 +120,7 @@ codex-cloud-requirements = { path = "cloud-requirements" } codex-connectors = { path = "connectors" } codex-config = { path = "config" } codex-core = { path = "core" } +codex-core-skills = { path = "core-skills" } codex-exec = { path = "exec" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6e42f18e4c..22122a7991 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -203,6 +203,8 @@ use codex_core::config::types::McpServerTransportConfig; use codex_core::config_loader::CloudRequirementsLoadError; use codex_core::config_loader::CloudRequirementsLoadErrorCode; use codex_core::config_loader::CloudRequirementsLoader; +use codex_core::config_loader::LoaderOverrides; +use codex_core::config_loader::load_config_layers_state; use codex_core::default_client::set_default_client_residency_requirement; use codex_core::error::CodexErr; use codex_core::error::Result as CodexResult; @@ -282,6 +284,7 @@ use codex_state::StateRuntime; use codex_state::ThreadMetadata; use codex_state::ThreadMetadataBuilder; use codex_state::log_db::LogDbLayer; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_json_to_toml::json_to_toml; use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP; use std::collections::BTreeMap; @@ -5509,13 +5512,63 @@ impl CodexMessageProcessor { } }; let skills_manager = self.thread_manager.skills_manager(); + let plugins_manager = self.thread_manager.plugins_manager(); + let cli_overrides = self.current_cli_overrides(); let mut data = Vec::new(); for cwd in cwds { let extra_roots = extra_roots_by_cwd .get(&cwd) .map_or(&[][..], std::vec::Vec::as_slice); + let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) { + Ok(path) => path, + Err(err) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::SkillsListEntry { + cwd, + skills: Vec::new(), + errors: errors_to_info(&[codex_core::skills::SkillError { + path: error_path, + message: err.to_string(), + }]), + }); + continue; + } + }; + let config_layer_stack = match load_config_layers_state( + &self.config.codex_home, + Some(cwd_abs), + &cli_overrides, + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await + { + Ok(config_layer_stack) => config_layer_stack, + Err(err) => { + let error_path = cwd.clone(); + data.push(codex_app_server_protocol::SkillsListEntry { + cwd, + skills: Vec::new(), + errors: errors_to_info(&[codex_core::skills::SkillError { + path: error_path, + message: err.to_string(), + }]), + }); + continue; + } + }; + let effective_skill_roots = plugins_manager.effective_skill_roots_for_layer_stack( + &config_layer_stack, + config.features.enabled(Feature::Plugins), + ); + let skills_input = codex_core::skills::SkillsLoadInput::new( + cwd.clone(), + effective_skill_roots, + config_layer_stack, + config.bundled_skills_enabled(), + ); let outcome = skills_manager - .skills_for_cwd_with_extra_user_roots(&cwd, &config, force_reload, extra_roots) + .skills_for_cwd_with_extra_user_roots(&skills_input, force_reload, extra_roots) .await; let errors = errors_to_info(&outcome.errors); let skills = skills_to_info(&outcome.skills, &outcome.disabled_paths); diff --git a/codex-rs/config/Cargo.toml b/codex-rs/config/Cargo.toml index 02446ed6f3..f1bd2dba16 100644 --- a/codex-rs/config/Cargo.toml +++ b/codex-rs/config/Cargo.toml @@ -14,6 +14,7 @@ codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } futures = { workspace = true, features = ["alloc", "std"] } multimap = { workspace = true } +schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } serde_path_to_error = { workspace = true } diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 995a5b8db7..7bd572fea3 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -5,7 +5,9 @@ mod diagnostics; mod fingerprint; mod merge; mod overrides; +mod project_root_markers; mod requirements_exec_policy; +mod skills_config; mod state; pub const CONFIG_TOML_FILE: &str = "config.toml"; @@ -46,12 +48,17 @@ pub use diagnostics::io_error_from_config_error; pub use fingerprint::version_for_toml; pub use merge::merge_toml_values; pub use overrides::build_cli_overrides_layer; +pub use project_root_markers::default_project_root_markers; +pub use project_root_markers::project_root_markers_from_config; pub use requirements_exec_policy::RequirementsExecPolicy; pub use requirements_exec_policy::RequirementsExecPolicyDecisionToml; pub use requirements_exec_policy::RequirementsExecPolicyParseError; pub use requirements_exec_policy::RequirementsExecPolicyPatternTokenToml; pub use requirements_exec_policy::RequirementsExecPolicyPrefixRuleToml; pub use requirements_exec_policy::RequirementsExecPolicyToml; +pub use skills_config::BundledSkillsConfig; +pub use skills_config::SkillConfig; +pub use skills_config::SkillsConfig; pub use state::ConfigLayerEntry; pub use state::ConfigLayerStack; pub use state::ConfigLayerStackOrdering; diff --git a/codex-rs/config/src/project_root_markers.rs b/codex-rs/config/src/project_root_markers.rs new file mode 100644 index 0000000000..3061dacc6b --- /dev/null +++ b/codex-rs/config/src/project_root_markers.rs @@ -0,0 +1,50 @@ +use std::io; + +use toml::Value as TomlValue; + +const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; + +/// Reads `project_root_markers` from a merged `config.toml` [toml::Value]. +/// +/// Invariants: +/// - If `project_root_markers` is not specified, returns `Ok(None)`. +/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where +/// `markers` is a `Vec` (including `Ok(Some(Vec::new()))` for an +/// empty array, which indicates that root detection should be disabled). +/// - Returns an error if `project_root_markers` is specified but is not an +/// array of strings. +pub fn project_root_markers_from_config(config: &TomlValue) -> io::Result>> { + let Some(table) = config.as_table() else { + return Ok(None); + }; + let Some(markers_value) = table.get("project_root_markers") else { + return Ok(None); + }; + let TomlValue::Array(entries) = markers_value else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + if entries.is_empty() { + return Ok(Some(Vec::new())); + } + let mut markers = Vec::new(); + for entry in entries { + let Some(marker) = entry.as_str() else { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "project_root_markers must be an array of strings", + )); + }; + markers.push(marker.to_string()); + } + Ok(Some(markers)) +} + +pub fn default_project_root_markers() -> Vec { + DEFAULT_PROJECT_ROOT_MARKERS + .iter() + .map(ToString::to_string) + .collect() +} diff --git a/codex-rs/config/src/skills_config.rs b/codex-rs/config/src/skills_config.rs new file mode 100644 index 0000000000..3dbe65a245 --- /dev/null +++ b/codex-rs/config/src/skills_config.rs @@ -0,0 +1,53 @@ +//! Skill-related configuration types shared across crates. + +use codex_utils_absolute_path::AbsolutePathBuf; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +const fn default_enabled() -> bool { + true +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillConfig { + /// Path-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Name-based selector. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub enabled: bool, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct SkillsConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bundled: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] +#[schemars(deny_unknown_fields)] +pub struct BundledSkillsConfig { + #[serde(default = "default_enabled")] + pub enabled: bool, +} + +impl Default for BundledSkillsConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +impl TryFrom for SkillsConfig { + type Error = toml::de::Error; + + fn try_from(value: toml::Value) -> Result { + SkillsConfig::deserialize(value) + } +} diff --git a/codex-rs/core-skills/BUILD.bazel b/codex-rs/core-skills/BUILD.bazel new file mode 100644 index 0000000000..e80412a554 --- /dev/null +++ b/codex-rs/core-skills/BUILD.bazel @@ -0,0 +1,15 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "core-skills", + crate_name = "codex_core_skills", + compile_data = glob( + include = ["**"], + exclude = [ + "**/* *", + "BUILD.bazel", + "Cargo.toml", + ], + allow_empty = True, + ), +) diff --git a/codex-rs/core-skills/Cargo.toml b/codex-rs/core-skills/Cargo.toml new file mode 100644 index 0000000000..35c9ed0a57 --- /dev/null +++ b/codex-rs/core-skills/Cargo.toml @@ -0,0 +1,40 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-core-skills" +version.workspace = true + +[lib] +doctest = false +name = "codex_core_skills" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +anyhow = { workspace = true } +codex-analytics = { workspace = true } +codex-app-server-protocol = { workspace = true } +codex-config = { workspace = true } +codex-instructions = { workspace = true } +codex-login = { workspace = true } +codex-otel = { workspace = true } +codex-protocol = { workspace = true } +codex-skills = { workspace = true } +codex-utils-absolute-path = { workspace = true } +codex-utils-plugins = { workspace = true } +dirs = { workspace = true } +dunce = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +serde_yaml = { workspace = true } +shlex = { workspace = true } +tokio = { workspace = true, features = ["fs", "macros", "rt"] } +toml = { workspace = true } +tracing = { workspace = true } +zip = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/core/src/skills/config_rules.rs b/codex-rs/core-skills/src/config_rules.rs similarity index 88% rename from codex-rs/core/src/skills/config_rules.rs rename to codex-rs/core-skills/src/config_rules.rs index fa1be878bb..f613d494a2 100644 --- a/codex-rs/core/src/skills/config_rules.rs +++ b/codex-rs/core-skills/src/config_rules.rs @@ -3,34 +3,32 @@ use std::path::Path; use std::path::PathBuf; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::SkillConfig; +use codex_config::SkillsConfig; use tracing::warn; -use crate::config::types::SkillConfig; -use crate::config::types::SkillsConfig; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigLayerStackOrdering; -use crate::skills::SkillMetadata; +use crate::SkillMetadata; #[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] -pub(crate) enum SkillConfigRuleSelector { +pub enum SkillConfigRuleSelector { Name(String), Path(PathBuf), } #[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub(crate) struct SkillConfigRule { +pub struct SkillConfigRule { pub selector: SkillConfigRuleSelector, pub enabled: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq, Hash)] -pub(crate) struct SkillConfigRules { +pub struct SkillConfigRules { pub entries: Vec, } -pub(crate) fn skill_config_rules_from_stack( - config_layer_stack: &ConfigLayerStack, -) -> SkillConfigRules { +pub fn skill_config_rules_from_stack(config_layer_stack: &ConfigLayerStack) -> SkillConfigRules { let mut entries = Vec::new(); for layer in config_layer_stack.get_layers( ConfigLayerStackOrdering::LowestPrecedenceFirst, @@ -71,7 +69,7 @@ pub(crate) fn skill_config_rules_from_stack( SkillConfigRules { entries } } -pub(crate) fn resolve_disabled_skill_paths( +pub fn resolve_disabled_skill_paths( skills: &[SkillMetadata], rules: &SkillConfigRules, ) -> HashSet { diff --git a/codex-rs/core-skills/src/env_var_dependencies.rs b/codex-rs/core-skills/src/env_var_dependencies.rs new file mode 100644 index 0000000000..4d8e57164c --- /dev/null +++ b/codex-rs/core-skills/src/env_var_dependencies.rs @@ -0,0 +1,30 @@ +use crate::SkillMetadata; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillDependencyInfo { + pub skill_name: String, + pub name: String, + pub description: Option, +} + +pub fn collect_env_var_dependencies( + mentioned_skills: &[SkillMetadata], +) -> Vec { + let mut dependencies = Vec::new(); + for skill in mentioned_skills { + let Some(skill_dependencies) = &skill.dependencies else { + continue; + }; + for tool in &skill_dependencies.tools { + if tool.r#type != "env_var" || tool.value.is_empty() { + continue; + } + dependencies.push(SkillDependencyInfo { + skill_name: skill.name.clone(), + name: tool.value.clone(), + description: tool.description.clone(), + }); + } + } + dependencies +} diff --git a/codex-rs/core/src/skills/injection.rs b/codex-rs/core-skills/src/injection.rs similarity index 93% rename from codex-rs/core/src/skills/injection.rs rename to codex-rs/core-skills/src/injection.rs index dac05f8368..b31885b8c5 100644 --- a/codex-rs/core/src/skills/injection.rs +++ b/codex-rs/core-skills/src/injection.rs @@ -2,26 +2,26 @@ use std::collections::HashMap; use std::collections::HashSet; use std::path::PathBuf; -use crate::instructions::SkillInstructions; -use crate::mention_syntax::TOOL_MENTION_SIGIL; -use crate::mentions::build_skill_name_counts; -use crate::skills::SkillMetadata; +use crate::SkillMetadata; +use crate::build_skill_name_counts; use codex_analytics::AnalyticsEventsClient; use codex_analytics::InvocationType; use codex_analytics::SkillInvocation; use codex_analytics::TrackEventsContext; +use codex_instructions::SkillInstructions; use codex_otel::SessionTelemetry; use codex_protocol::models::ResponseItem; use codex_protocol::user_input::UserInput; +use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL; use tokio::fs; #[derive(Debug, Default)] -pub(crate) struct SkillInjections { - pub(crate) items: Vec, - pub(crate) warnings: Vec, +pub struct SkillInjections { + pub items: Vec, + pub warnings: Vec, } -pub(crate) async fn build_skill_injections( +pub async fn build_skill_injections( mentioned_skills: &[SkillMetadata], otel: Option<&SessionTelemetry>, analytics_client: &AnalyticsEventsClient, @@ -97,7 +97,7 @@ fn emit_skill_injected_metric( /// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where: /// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs, /// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input. -pub(crate) fn collect_explicit_skill_mentions( +pub fn collect_explicit_skill_mentions( inputs: &[UserInput], skills: &[SkillMetadata], disabled_paths: &HashSet, @@ -159,7 +159,7 @@ struct SkillSelectionContext<'a> { connector_slug_counts: &'a HashMap, } -pub(crate) struct ToolMentions<'a> { +pub struct ToolMentions<'a> { names: HashSet<&'a str>, paths: HashSet<&'a str>, plain_names: HashSet<&'a str>, @@ -170,17 +170,17 @@ impl<'a> ToolMentions<'a> { self.names.is_empty() && self.paths.is_empty() } - pub(crate) fn plain_names(&self) -> impl Iterator + '_ { + pub fn plain_names(&self) -> impl Iterator + '_ { self.plain_names.iter().copied() } - pub(crate) fn paths(&self) -> impl Iterator + '_ { + pub fn paths(&self) -> impl Iterator + '_ { self.paths.iter().copied() } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum ToolMentionKind { +pub enum ToolMentionKind { App, Mcp, Plugin, @@ -194,7 +194,7 @@ const PLUGIN_PATH_PREFIX: &str = "plugin://"; const SKILL_PATH_PREFIX: &str = "skill://"; const SKILL_FILENAME: &str = "SKILL.md"; -pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind { +pub fn tool_kind_for_path(path: &str) -> ToolMentionKind { if path.starts_with(APP_PATH_PREFIX) { ToolMentionKind::App } else if path.starts_with(MCP_PATH_PREFIX) { @@ -213,12 +213,12 @@ fn is_skill_filename(path: &str) -> bool { file_name.eq_ignore_ascii_case(SKILL_FILENAME) } -pub(crate) fn app_id_from_path(path: &str) -> Option<&str> { +pub fn app_id_from_path(path: &str) -> Option<&str> { path.strip_prefix(APP_PATH_PREFIX) .filter(|value| !value.is_empty()) } -pub(crate) fn plugin_config_name_from_path(path: &str) -> Option<&str> { +pub fn plugin_config_name_from_path(path: &str) -> Option<&str> { path.strip_prefix(PLUGIN_PATH_PREFIX) .filter(|value| !value.is_empty()) } @@ -232,11 +232,11 @@ pub(crate) fn normalize_skill_path(path: &str) -> &str { /// Supports explicit resource links in the form `[$tool-name](resource path)`. When a /// resource path is present, it is captured for exact path matching while also tracking /// the name for fallback matching. -pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { +pub fn extract_tool_mentions(text: &str) -> ToolMentions<'_> { extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL) } -pub(crate) fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> { +pub fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> { let text_bytes = text.as_bytes(); let mut mentioned_names: HashSet<&str> = HashSet::new(); let mut mentioned_paths: HashSet<&str> = HashSet::new(); diff --git a/codex-rs/core/src/skills/injection_tests.rs b/codex-rs/core-skills/src/injection_tests.rs similarity index 100% rename from codex-rs/core/src/skills/injection_tests.rs rename to codex-rs/core-skills/src/injection_tests.rs diff --git a/codex-rs/core/src/skills/invocation_utils.rs b/codex-rs/core-skills/src/invocation_utils.rs similarity index 58% rename from codex-rs/core/src/skills/invocation_utils.rs rename to codex-rs/core-skills/src/invocation_utils.rs index 084670fa86..1936bbe630 100644 --- a/codex-rs/core/src/skills/invocation_utils.rs +++ b/codex-rs/core-skills/src/invocation_utils.rs @@ -2,13 +2,8 @@ use std::collections::HashMap; use std::path::Path; use std::path::PathBuf; -use crate::codex::Session; -use crate::codex::TurnContext; -use crate::skills::SkillLoadOutcome; -use crate::skills::SkillMetadata; -use codex_analytics::InvocationType; -use codex_analytics::SkillInvocation; -use codex_analytics::build_track_events_context; +use crate::SkillLoadOutcome; +use crate::SkillMetadata; pub(crate) fn build_implicit_skill_path_indexes( skills: Vec, @@ -31,14 +26,12 @@ pub(crate) fn build_implicit_skill_path_indexes( (by_scripts_dir, by_skill_doc_path) } -fn detect_implicit_skill_invocation_for_command( +pub fn detect_implicit_skill_invocation_for_command( outcome: &SkillLoadOutcome, - turn_context: &TurnContext, command: &str, - workdir: Option<&str>, + workdir: &Path, ) -> Option { - let workdir = turn_context.resolve_path(workdir.map(str::to_owned)); - let workdir = normalize_path(workdir.as_path()); + let workdir = normalize_path(workdir); let tokens = tokenize_command(command); if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), workdir.as_path()) @@ -46,82 +39,12 @@ fn detect_implicit_skill_invocation_for_command( return Some(candidate); } - if let Some(candidate) = detect_skill_doc_read(outcome, tokens.as_slice(), workdir.as_path()) { - return Some(candidate); - } - - None -} - -pub(crate) async fn maybe_emit_implicit_skill_invocation( - sess: &Session, - turn_context: &TurnContext, - command: &str, - workdir: Option<&str>, -) { - let Some(candidate) = detect_implicit_skill_invocation_for_command( - &turn_context.turn_skills.outcome, - turn_context, - command, - workdir, - ) else { - return; - }; - let invocation = SkillInvocation { - skill_name: candidate.name, - skill_scope: candidate.scope, - skill_path: candidate.path_to_skills_md, - invocation_type: InvocationType::Implicit, - }; - let skill_scope = match invocation.skill_scope { - codex_protocol::protocol::SkillScope::User => "user", - codex_protocol::protocol::SkillScope::Repo => "repo", - codex_protocol::protocol::SkillScope::System => "system", - codex_protocol::protocol::SkillScope::Admin => "admin", - }; - let skill_path = invocation.skill_path.to_string_lossy(); - let skill_name = invocation.skill_name.clone(); - let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}"); - let inserted = { - let mut seen_skills = turn_context - .turn_skills - .implicit_invocation_seen_skills - .lock() - .await; - seen_skills.insert(seen_key) - }; - if !inserted { - return; - } - - turn_context.session_telemetry.counter( - "codex.skill.injected", - /*inc*/ 1, - &[ - ("status", "ok"), - ("skill", skill_name.as_str()), - ("invoke_type", "implicit"), - ], - ); - sess.services - .analytics_events_client - .track_skill_invocations( - build_track_events_context( - turn_context.model_info.slug.clone(), - sess.conversation_id.to_string(), - turn_context.sub_id.clone(), - ), - vec![invocation], - ); + detect_skill_doc_read(outcome, tokens.as_slice(), workdir.as_path()) } fn tokenize_command(command: &str) -> Vec { - shlex::split(command).unwrap_or_else(|| { - command - .split_whitespace() - .map(std::string::ToString::to_string) - .collect() - }) + shlex::split(command) + .unwrap_or_else(|| command.split_whitespace().map(str::to_string).collect()) } fn script_run_token(tokens: &[String]) -> Option<&str> { @@ -137,12 +60,9 @@ fn script_run_token(tokens: &[String]) -> Option<&str> { return None; } - let mut script_token: Option<&str> = None; + let mut script_token = None; for token in tokens.iter().skip(1) { - if token == "--" { - continue; - } - if token.starts_with('-') { + if token == "--" || token.starts_with('-') { continue; } script_token = Some(token.as_str()); diff --git a/codex-rs/core/src/skills/invocation_utils_tests.rs b/codex-rs/core-skills/src/invocation_utils_tests.rs similarity index 100% rename from codex-rs/core/src/skills/invocation_utils_tests.rs rename to codex-rs/core-skills/src/invocation_utils_tests.rs diff --git a/codex-rs/core/src/skills/mod.rs b/codex-rs/core-skills/src/lib.rs similarity index 55% rename from codex-rs/core/src/skills/mod.rs rename to codex-rs/core-skills/src/lib.rs index ad76cd9da0..b967e4dddc 100644 --- a/codex-rs/core/src/skills/mod.rs +++ b/codex-rs/core-skills/src/lib.rs @@ -1,22 +1,22 @@ -pub(crate) mod config_rules; +pub mod config_rules; mod env_var_dependencies; pub mod injection; pub(crate) mod invocation_utils; pub mod loader; pub mod manager; +mod mention_counts; pub mod model; pub mod remote; pub mod render; pub mod system; -pub(crate) use env_var_dependencies::collect_env_var_dependencies; -pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn; -pub(crate) use injection::SkillInjections; -pub(crate) use injection::build_skill_injections; -pub(crate) use injection::collect_explicit_skill_mentions; +pub use env_var_dependencies::SkillDependencyInfo; +pub use env_var_dependencies::collect_env_var_dependencies; pub(crate) use invocation_utils::build_implicit_skill_path_indexes; -pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation; +pub use invocation_utils::detect_implicit_skill_invocation_for_command; +pub use manager::SkillsLoadInput; pub use manager::SkillsManager; +pub use mention_counts::build_skill_name_counts; pub use model::SkillError; pub use model::SkillLoadOutcome; pub use model::SkillMetadata; diff --git a/codex-rs/core/src/skills/loader.rs b/codex-rs/core-skills/src/loader.rs similarity index 96% rename from codex-rs/core/src/skills/loader.rs rename to codex-rs/core-skills/src/loader.rs index 5672bdb0a3..b6ef000845 100644 --- a/codex-rs/core/src/skills/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -1,19 +1,18 @@ -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigLayerStackOrdering; -use crate::config_loader::default_project_root_markers; -use crate::config_loader::merge_toml_values; -use crate::config_loader::project_root_markers_from_config; -use crate::plugins::plugin_namespace_for_skill_path; -use crate::skills::model::SkillDependencies; -use crate::skills::model::SkillError; -use crate::skills::model::SkillInterface; -use crate::skills::model::SkillLoadOutcome; -use crate::skills::model::SkillManagedNetworkOverride; -use crate::skills::model::SkillMetadata; -use crate::skills::model::SkillPolicy; -use crate::skills::model::SkillToolDependency; -use crate::skills::system::system_cache_root_dir; +use crate::model::SkillDependencies; +use crate::model::SkillError; +use crate::model::SkillInterface; +use crate::model::SkillLoadOutcome; +use crate::model::SkillManagedNetworkOverride; +use crate::model::SkillMetadata; +use crate::model::SkillPolicy; +use crate::model::SkillToolDependency; +use crate::system::system_cache_root_dir; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_config::ConfigLayerStackOrdering; +use codex_config::default_project_root_markers; +use codex_config::merge_toml_values; +use codex_config::project_root_markers_from_config; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsSeatbeltProfileExtensions; use codex_protocol::models::NetworkPermissions; @@ -21,6 +20,7 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBufGuard; +use codex_utils_plugins::plugin_namespace_for_skill_path; use dirs::home_dir; use dunce::canonicalize as canonicalize_path; use serde::Deserialize; @@ -35,9 +35,6 @@ use std::path::PathBuf; use toml::Value as TomlValue; use tracing::error; -#[cfg(test)] -use crate::config::Config; - #[derive(Debug, Deserialize)] struct SkillFrontmatter { #[serde(default)] @@ -176,12 +173,12 @@ impl fmt::Display for SkillParseError { impl Error for SkillParseError {} -pub(crate) struct SkillRoot { - pub(crate) path: PathBuf, - pub(crate) scope: SkillScope, +pub struct SkillRoot { + pub path: PathBuf, + pub scope: SkillScope, } -pub(crate) fn load_skills_from_roots(roots: I) -> SkillLoadOutcome +pub fn load_skills_from_roots(roots: I) -> SkillLoadOutcome where I: IntoIterator, { diff --git a/codex-rs/core/src/skills/loader_tests.rs b/codex-rs/core-skills/src/loader_tests.rs similarity index 95% rename from codex-rs/core/src/skills/loader_tests.rs rename to codex-rs/core-skills/src/loader_tests.rs index 7f758735cb..5875b1a043 100644 --- a/codex-rs/core/src/skills/loader_tests.rs +++ b/codex-rs/core-skills/src/loader_tests.rs @@ -1,14 +1,9 @@ use super::*; -use crate::config::ConfigBuilder; -use crate::config::ConfigOverrides; -use crate::config::ConfigToml; -use crate::config::ProjectConfig; -use crate::config_loader::ConfigLayerEntry; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigRequirements; -use crate::config_loader::ConfigRequirementsToml; use codex_config::CONFIG_TOML_FILE; -use codex_protocol::config_types::TrustLevel; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirements; +use codex_config::ConfigRequirementsToml; use codex_protocol::models::FileSystemPermissions; use codex_protocol::models::MacOsAutomationPermission; use codex_protocol::models::MacOsContactsPermission; @@ -19,53 +14,109 @@ use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; -use std::collections::HashMap; use std::path::Path; use tempfile::TempDir; use toml::Value as TomlValue; const REPO_ROOT_CONFIG_DIR_NAME: &str = ".codex"; -async fn make_config(codex_home: &TempDir) -> Config { +struct TestConfig { + cwd: PathBuf, + config_layer_stack: ConfigLayerStack, +} + +async fn make_config(codex_home: &TempDir) -> TestConfig { make_config_for_cwd(codex_home, codex_home.path().to_path_buf()).await } -async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> Config { - let trust_root = cwd - .ancestors() - .find(|ancestor| ancestor.join(".git").exists()) - .map(Path::to_path_buf) - .unwrap_or_else(|| cwd.clone()); - - fs::write( - codex_home.path().join(CONFIG_TOML_FILE), - toml::to_string(&ConfigToml { - projects: Some(HashMap::from([( - trust_root.to_string_lossy().to_string(), - ProjectConfig { - trust_level: Some(TrustLevel::Trusted), - }, - )])), - ..Default::default() - }) - .expect("serialize config"), - ) - .unwrap(); - - let harness_overrides = ConfigOverrides { - cwd: Some(cwd), - ..Default::default() - }; - - ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(harness_overrides) - .build() - .await - .expect("defaults for test should always succeed") +fn config_file(path: PathBuf) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(path).expect("config file path should be absolute") } -fn load_skills_for_test(config: &Config) -> SkillLoadOutcome { +fn project_layers_for_cwd(cwd: &Path) -> Vec { + let cwd_dir = if cwd.is_dir() { + cwd.to_path_buf() + } else { + cwd.parent() + .expect("file cwd should have a parent directory") + .to_path_buf() + }; + let project_root = cwd_dir + .ancestors() + .find(|ancestor| ancestor.join(".git").exists()) + .unwrap_or(cwd_dir.as_path()) + .to_path_buf(); + + let mut layers = cwd_dir + .ancestors() + .scan(false, |done, dir| { + if *done { + None + } else { + if dir == project_root { + *done = true; + } + Some(dir.to_path_buf()) + } + }) + .collect::>(); + layers.reverse(); + + layers + .into_iter() + .filter_map(|dir| { + let dot_codex = dir.join(REPO_ROOT_CONFIG_DIR_NAME); + dot_codex.is_dir().then(|| { + ConfigLayerEntry::new( + ConfigLayerSource::Project { + dot_codex_folder: AbsolutePathBuf::from_absolute_path(dot_codex) + .expect("project .codex path should be absolute"), + }, + TomlValue::Table(toml::map::Map::new()), + ) + }) + }) + .collect() +} + +async fn make_config_for_cwd(codex_home: &TempDir, cwd: PathBuf) -> TestConfig { + let user_config_path = codex_home.path().join(CONFIG_TOML_FILE); + let system_config_path = codex_home.path().join("etc/codex/config.toml"); + fs::create_dir_all( + system_config_path + .parent() + .expect("system config path should have a parent"), + ) + .expect("create fake system config dir"); + + let mut layers = vec![ + ConfigLayerEntry::new( + ConfigLayerSource::System { + file: config_file(system_config_path), + }, + TomlValue::Table(toml::map::Map::new()), + ), + ConfigLayerEntry::new( + ConfigLayerSource::User { + file: config_file(user_config_path), + }, + TomlValue::Table(toml::map::Map::new()), + ), + ]; + layers.extend(project_layers_for_cwd(&cwd)); + + TestConfig { + cwd, + config_layer_stack: ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack"), + } +} + +fn load_skills_for_test(config: &TestConfig) -> SkillLoadOutcome { // Keep unit tests hermetic by never scanning the real `$HOME/.agents/skills`. super::load_skills_from_roots(super::skill_roots_with_home_dir( &config.config_layer_stack, diff --git a/codex-rs/core/src/skills/manager.rs b/codex-rs/core-skills/src/manager.rs similarity index 67% rename from codex-rs/core/src/skills/manager.rs rename to codex-rs/core-skills/src/manager.rs index 446580b6c7..cd3b427714 100644 --- a/codex-rs/core/src/skills/manager.rs +++ b/codex-rs/core-skills/src/manager.rs @@ -5,61 +5,67 @@ use std::path::PathBuf; use std::sync::Arc; use std::sync::RwLock; +use codex_config::ConfigLayerStack; use codex_protocol::protocol::Product; use codex_protocol::protocol::SkillScope; -use codex_utils_absolute_path::AbsolutePathBuf; -use toml::Value as TomlValue; use tracing::info; use tracing::warn; -use crate::config::Config; -use crate::config::types::SkillsConfig; -use crate::config_loader::CloudRequirementsLoader; -use crate::config_loader::LoaderOverrides; -use crate::config_loader::load_config_layers_state; -use crate::plugins::PluginsManager; -use crate::skills::SkillLoadOutcome; -use crate::skills::build_implicit_skill_path_indexes; -use crate::skills::config_rules::SkillConfigRules; -use crate::skills::config_rules::resolve_disabled_skill_paths; -use crate::skills::config_rules::skill_config_rules_from_stack; -use crate::skills::loader::SkillRoot; -use crate::skills::loader::load_skills_from_roots; -use crate::skills::loader::skill_roots; -use crate::skills::system::install_system_skills; -use crate::skills::system::uninstall_system_skills; +use crate::SkillLoadOutcome; +use crate::build_implicit_skill_path_indexes; +use crate::config_rules::SkillConfigRules; +use crate::config_rules::resolve_disabled_skill_paths; +use crate::config_rules::skill_config_rules_from_stack; +use crate::loader::SkillRoot; +use crate::loader::load_skills_from_roots; +use crate::loader::skill_roots; +use crate::system::install_system_skills; +use crate::system::uninstall_system_skills; +use codex_config::SkillsConfig; + +#[derive(Debug, Clone)] +pub struct SkillsLoadInput { + pub cwd: PathBuf, + pub effective_skill_roots: Vec, + pub config_layer_stack: ConfigLayerStack, + pub bundled_skills_enabled: bool, +} + +impl SkillsLoadInput { + pub fn new( + cwd: PathBuf, + effective_skill_roots: Vec, + config_layer_stack: ConfigLayerStack, + bundled_skills_enabled: bool, + ) -> Self { + Self { + cwd, + effective_skill_roots, + config_layer_stack, + bundled_skills_enabled, + } + } +} pub struct SkillsManager { codex_home: PathBuf, - plugins_manager: Arc, restriction_product: Option, cache_by_cwd: RwLock>, cache_by_config: RwLock>, } impl SkillsManager { - pub fn new( - codex_home: PathBuf, - plugins_manager: Arc, - bundled_skills_enabled: bool, - ) -> Self { - Self::new_with_restriction_product( - codex_home, - plugins_manager, - bundled_skills_enabled, - Some(Product::Codex), - ) + pub fn new(codex_home: PathBuf, bundled_skills_enabled: bool) -> Self { + Self::new_with_restriction_product(codex_home, bundled_skills_enabled, Some(Product::Codex)) } pub fn new_with_restriction_product( codex_home: PathBuf, - plugins_manager: Arc, bundled_skills_enabled: bool, restriction_product: Option, ) -> Self { let manager = Self { codex_home, - plugins_manager, restriction_product, cache_by_cwd: RwLock::new(HashMap::new()), cache_by_config: RwLock::new(HashMap::new()), @@ -80,9 +86,9 @@ impl SkillsManager { /// This path uses a cache keyed by the effective skill-relevant config state rather than just /// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen /// to share a directory. - pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome { - let roots = self.skill_roots_for_config(config); - let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack); + pub fn skills_for_config(&self, input: &SkillsLoadInput) -> SkillLoadOutcome { + let roots = self.skill_roots_for_config(input); + let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let cache_key = config_skills_cache_key(&roots, &skill_config_rules); if let Some(outcome) = self.cached_outcome_for_config(&cache_key) { return outcome; @@ -97,14 +103,13 @@ impl SkillsManager { outcome } - pub(crate) fn skill_roots_for_config(&self, config: &Config) -> Vec { - let loaded_plugins = self.plugins_manager.plugins_for_config(config); + pub fn skill_roots_for_config(&self, input: &SkillsLoadInput) -> Vec { let mut roots = skill_roots( - &config.config_layer_stack, - &config.cwd, - loaded_plugins.effective_skill_roots(), + &input.config_layer_stack, + input.cwd.as_path(), + input.effective_skill_roots.clone(), ); - if !config.bundled_skills_enabled() { + if !input.bundled_skills_enabled { roots.retain(|root| root.scope != SkillScope::System); } roots @@ -112,74 +117,34 @@ impl SkillsManager { pub async fn skills_for_cwd( &self, - cwd: &Path, - config: &Config, + input: &SkillsLoadInput, force_reload: bool, ) -> SkillLoadOutcome { - if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) { + if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(input.cwd.as_path()) { return outcome; } - self.skills_for_cwd_with_extra_user_roots(cwd, config, force_reload, &[]) + self.skills_for_cwd_with_extra_user_roots(input, force_reload, &[]) .await } pub async fn skills_for_cwd_with_extra_user_roots( &self, - cwd: &Path, - config: &Config, + input: &SkillsLoadInput, force_reload: bool, extra_user_roots: &[PathBuf], ) -> SkillLoadOutcome { - if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) { + if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(input.cwd.as_path()) { return outcome; } let normalized_extra_user_roots = normalize_extra_user_roots(extra_user_roots); - let cwd_abs = match AbsolutePathBuf::try_from(cwd) { - Ok(cwd_abs) => cwd_abs, - Err(err) => { - return SkillLoadOutcome { - errors: vec![crate::skills::model::SkillError { - path: cwd.to_path_buf(), - message: err.to_string(), - }], - ..Default::default() - }; - } - }; - - let cli_overrides: Vec<(String, TomlValue)> = Vec::new(); - let config_layer_stack = match load_config_layers_state( - &self.codex_home, - Some(cwd_abs), - &cli_overrides, - LoaderOverrides::default(), - CloudRequirementsLoader::default(), - ) - .await - { - Ok(config_layer_stack) => config_layer_stack, - Err(err) => { - return SkillLoadOutcome { - errors: vec![crate::skills::model::SkillError { - path: cwd.to_path_buf(), - message: err.to_string(), - }], - ..Default::default() - }; - } - }; - - let loaded_plugins = self - .plugins_manager - .plugins_for_config_with_force_reload(config, force_reload); let mut roots = skill_roots( - &config_layer_stack, - cwd, - loaded_plugins.effective_skill_roots(), + &input.config_layer_stack, + input.cwd.as_path(), + input.effective_skill_roots.clone(), ); - if !bundled_skills_enabled_from_stack(&config_layer_stack) { + if !bundled_skills_enabled_from_stack(&input.config_layer_stack) { roots.retain(|root| root.scope != SkillScope::System); } roots.extend( @@ -191,13 +156,13 @@ impl SkillsManager { scope: SkillScope::User, }), ); - let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack); + let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack); let outcome = self.build_skill_outcome(roots, &skill_config_rules); let mut cache = self .cache_by_cwd .write() .unwrap_or_else(std::sync::PoisonError::into_inner); - cache.insert(cwd.to_path_buf(), outcome.clone()); + cache.insert(input.cwd.clone(), outcome.clone()); outcome } @@ -206,7 +171,7 @@ impl SkillsManager { roots: Vec, skill_config_rules: &SkillConfigRules, ) -> SkillLoadOutcome { - let outcome = crate::skills::filter_skill_load_outcome_for_product( + let outcome = crate::filter_skill_load_outcome_for_product( load_skills_from_roots(roots), self.restriction_product, ); @@ -261,8 +226,8 @@ struct ConfigSkillsCacheKey { skill_config_rules: SkillConfigRules, } -pub(crate) fn bundled_skills_enabled_from_stack( - config_layer_stack: &crate::config_loader::ConfigLayerStack, +pub fn bundled_skills_enabled_from_stack( + config_layer_stack: &codex_config::ConfigLayerStack, ) -> bool { let effective_config = config_layer_stack.effective_config(); let Some(skills_value) = effective_config diff --git a/codex-rs/core/src/skills/manager_tests.rs b/codex-rs/core-skills/src/manager_tests.rs similarity index 67% rename from codex-rs/core/src/skills/manager_tests.rs rename to codex-rs/core-skills/src/manager_tests.rs index d9e5859f4e..2507fc9823 100644 --- a/codex-rs/core/src/skills/manager_tests.rs +++ b/codex-rs/core-skills/src/manager_tests.rs @@ -1,15 +1,15 @@ use super::*; -use crate::config::ConfigBuilder; -use crate::config::ConfigOverrides; -use crate::config_loader::ConfigLayerEntry; -use crate::config_loader::ConfigLayerStack; -use crate::config_loader::ConfigRequirementsToml; -use crate::plugins::PluginsManager; -use crate::skills::SkillMetadata; -use crate::skills::config_rules::resolve_disabled_skill_paths; -use crate::skills::config_rules::skill_config_rules_from_stack; +use crate::SkillMetadata; +use crate::config_rules::resolve_disabled_skill_paths; +use crate::config_rules::skill_config_rules_from_stack; use codex_app_server_protocol::ConfigLayerSource; +use codex_config::CONFIG_TOML_FILE; +use codex_config::ConfigLayerEntry; +use codex_config::ConfigLayerStack; +use codex_config::ConfigRequirementsToml; +use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; +use std::collections::HashSet; use std::fs; use std::path::PathBuf; use tempfile::TempDir; @@ -64,6 +64,77 @@ fn test_skill(name: &str, path: PathBuf) -> SkillMetadata { } } +fn user_config_layer(codex_home: &TempDir, config_toml: &str) -> ConfigLayerEntry { + let config_path = AbsolutePathBuf::try_from(codex_home.path().join(CONFIG_TOML_FILE)) + .expect("user config path should be absolute"); + ConfigLayerEntry::new( + ConfigLayerSource::User { file: config_path }, + toml::from_str(config_toml).expect("user layer toml"), + ) +} + +fn config_stack(codex_home: &TempDir, user_config_toml: &str) -> ConfigLayerStack { + ConfigLayerStack::new( + vec![user_config_layer(codex_home, user_config_toml)], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack") +} + +fn config_stack_with_session_flags( + codex_home: &TempDir, + user_config_toml: &str, + session_flags_toml: &str, +) -> ConfigLayerStack { + ConfigLayerStack::new( + vec![ + user_config_layer(codex_home, user_config_toml), + ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + toml::from_str(session_flags_toml).expect("session layer toml"), + ), + ], + Default::default(), + ConfigRequirementsToml::default(), + ) + .expect("valid config layer stack") +} + +fn path_toggle_config(path: &std::path::Path, enabled: bool) -> String { + format!( + r#"[[skills.config]] +path = "{}" +enabled = {enabled} +"#, + path.display() + ) +} + +fn name_toggle_config(name: &str, enabled: bool) -> String { + format!( + r#"[[skills.config]] +name = "{name}" +enabled = {enabled} +"# + ) +} + +fn skills_for_config_with_stack( + skills_manager: &SkillsManager, + cwd: &TempDir, + config_layer_stack: &ConfigLayerStack, + effective_skill_roots: &[PathBuf], +) -> SkillLoadOutcome { + let skills_input = SkillsLoadInput::new( + cwd.path().to_path_buf(), + effective_skill_roots.to_vec(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(config_layer_stack), + ); + skills_manager.skills_for_config(&skills_input) +} + #[test] fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { let codex_home = tempfile::tempdir().expect("tempdir"); @@ -72,9 +143,7 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n") .expect("write stale system skill"); - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let _skills_manager = - SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, false); + let _skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false); assert!( !codex_home.path().join("skills/.system").exists(), @@ -86,22 +155,11 @@ fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() { async fn skills_for_config_reuses_cache_for_same_effective_config() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); - - let cfg = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("defaults for test should always succeed"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); write_user_skill(&codex_home, "a", "skill-a", "from a"); - let outcome1 = skills_manager.skills_for_config(&cfg); + let outcome1 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); assert!( outcome1.skills.iter().any(|s| s.name == "skill-a"), "expected skill-a to be discovered" @@ -110,7 +168,7 @@ async fn skills_for_config_reuses_cache_for_same_effective_config() { // Write a new skill after the first call; the second call should reuse the config-aware cache // entry because the effective skill config is unchanged. write_user_skill(&codex_home, "b", "skill-b", "from b"); - let outcome2 = skills_manager.skills_for_config(&cfg); + let outcome2 = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); assert_eq!(outcome2.errors, outcome1.errors); assert_eq!(outcome2.skills, outcome1.skills); } @@ -127,39 +185,23 @@ async fn skills_for_config_disables_plugin_skills_by_name() { "sample-search", "search sample data", ); - fs::write( - codex_home.path().join(crate::config::CONFIG_TOML_FILE), - r#"[features] -plugins = true - -[[skills.config]] -name = "sample:sample-search" -enabled = false - -[plugins."sample@test"] -enabled = true -"#, - ) - .expect("write config"); - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("load config"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = SkillsManager::new( - codex_home.path().to_path_buf(), - plugins_manager, - config.bundled_skills_enabled(), + let config_layer_stack = config_stack( + &codex_home, + &name_toggle_config("sample:sample-search", false), ); + let plugin_skill_root = skill_path + .parent() + .and_then(std::path::Path::parent) + .expect("plugin skill should live under a skills root") + .to_path_buf(); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); - let outcome = skills_manager.skills_for_config(&config); + let outcome = skills_for_config_with_stack( + &skills_manager, + &cwd, + &config_layer_stack, + &[plugin_skill_root], + ); let skill = outcome .skills .iter() @@ -182,27 +224,21 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { let codex_home = tempfile::tempdir().expect("tempdir"); let cwd = tempfile::tempdir().expect("tempdir"); let extra_root = tempfile::tempdir().expect("tempdir"); - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("defaults for test should always succeed"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); - let _ = skills_manager.skills_for_config(&config); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); write_user_skill(&extra_root, "x", "extra-skill", "from extra root"); let extra_root_path = extra_root.path().to_path_buf(); + let base_input = SkillsLoadInput::new( + cwd.path().to_path_buf(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); let outcome_with_extra = skills_manager .skills_for_cwd_with_extra_user_roots( - cwd.path(), - &config, + &base_input, true, std::slice::from_ref(&extra_root_path), ) @@ -222,9 +258,13 @@ async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() { // The cwd-only API returns the current cached entry for this cwd, even when that entry // was produced with extra roots. - let outcome_without_extra = skills_manager - .skills_for_cwd(cwd.path(), &config, false) - .await; + let base_input = SkillsLoadInput::new( + cwd.path().to_path_buf(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); + let outcome_without_extra = skills_manager.skills_for_cwd(&base_input, false).await; assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills); assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors); } @@ -240,29 +280,8 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { "---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n", ) .expect("write bundled skill"); - - fs::write( - codex_home.path().join(crate::config::CONFIG_TOML_FILE), - "[skills.bundled]\nenabled = false\n", - ) - .expect("write config"); - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("load config"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = SkillsManager::new( - codex_home.path().to_path_buf(), - plugins_manager, - config.bundled_skills_enabled(), - ); + let config_layer_stack = config_stack(&codex_home, "[skills.bundled]\nenabled = false\n"); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), false); // Recreate the cached bundled skill after startup cleanup so this assertion exercises // root selection rather than relying on directory removal succeeding. @@ -273,7 +292,7 @@ async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() { ) .expect("rewrite bundled skill"); - let outcome = skills_manager.skills_for_config(&config); + let outcome = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); assert!( outcome .skills @@ -294,29 +313,23 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { let cwd = tempfile::tempdir().expect("tempdir"); let extra_root_a = tempfile::tempdir().expect("tempdir"); let extra_root_b = tempfile::tempdir().expect("tempdir"); - - let config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("defaults for test should always succeed"); - - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); - let _ = skills_manager.skills_for_config(&config); + let config_layer_stack = config_stack(&codex_home, ""); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let _ = skills_for_config_with_stack(&skills_manager, &cwd, &config_layer_stack, &[]); write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a"); write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b"); let extra_root_a_path = extra_root_a.path().to_path_buf(); + let base_input = SkillsLoadInput::new( + cwd.path().to_path_buf(), + Vec::new(), + config_layer_stack.clone(), + bundled_skills_enabled_from_stack(&config_layer_stack), + ); let outcome_a = skills_manager .skills_for_cwd_with_extra_user_roots( - cwd.path(), - &config, + &base_input, true, std::slice::from_ref(&extra_root_a_path), ) @@ -337,8 +350,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { let extra_root_b_path = extra_root_b.path().to_path_buf(); let outcome_b = skills_manager .skills_for_cwd_with_extra_user_roots( - cwd.path(), - &config, + &base_input, false, std::slice::from_ref(&extra_root_b_path), ) @@ -358,8 +370,7 @@ async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() { let outcome_reloaded = skills_manager .skills_for_cwd_with_extra_user_roots( - cwd.path(), - &config, + &base_input, true, std::slice::from_ref(&extra_root_b_path), ) @@ -399,25 +410,11 @@ fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() { .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = false -"#, - skill_path.display() - )) - .expect("user layer toml"), + toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"), ); let session_layer = ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = true -"#, - skill_path.display() - )) - .expect("session layer toml"), + toml::from_str(&path_toggle_config(&skill_path, true)).expect("session layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer, session_layer], @@ -443,25 +440,11 @@ fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill( .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = true -"#, - skill_path.display() - )) - .expect("user layer toml"), + toml::from_str(&path_toggle_config(&skill_path, true)).expect("user layer toml"), ); let session_layer = ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = false -"#, - skill_path.display() - )) - .expect("session layer toml"), + toml::from_str(&path_toggle_config(&skill_path, false)).expect("session layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer, session_layer], @@ -487,13 +470,7 @@ fn disabled_paths_for_skills_disables_matching_name_selectors() { .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str( - r#"[[skills.config]] -name = "github:yeet" -enabled = false -"#, - ) - .expect("user layer toml"), + toml::from_str(&name_toggle_config("github:yeet", false)).expect("user layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer], @@ -519,24 +496,11 @@ fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() { .expect("user config path should be absolute"); let user_layer = ConfigLayerEntry::new( ConfigLayerSource::User { file: user_file }, - toml::from_str(&format!( - r#"[[skills.config]] -path = "{}" -enabled = false -"#, - skill_path.display() - )) - .expect("user layer toml"), + toml::from_str(&path_toggle_config(&skill_path, false)).expect("user layer toml"), ); let session_layer = ConfigLayerEntry::new( ConfigLayerSource::SessionFlags, - toml::from_str( - r#"[[skills.config]] -name = "github:yeet" -enabled = true -"#, - ) - .expect("session layer toml"), + toml::from_str(&name_toggle_config("github:yeet", true)).expect("session layer toml"), ); let stack = ConfigLayerStack::new( vec![user_layer, session_layer], @@ -565,58 +529,20 @@ async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() "---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n", ) .expect("write skill"); - fs::write( - codex_home.path().join(crate::config::CONFIG_TOML_FILE), - format!( - r#"[[skills.config]] -path = "{}" -enabled = false -"#, - skill_path.display() - ), - ) - .expect("write config"); - - let parent_config = ConfigBuilder::default() - .codex_home(codex_home.path().to_path_buf()) - .harness_overrides(ConfigOverrides { - cwd: Some(cwd.path().to_path_buf()), - ..Default::default() - }) - .build() - .await - .expect("load parent config"); - let role_path = codex_home.path().join("enable-role.toml"); - fs::write( - &role_path, - format!( - r#"[[skills.config]] -path = "{}" -enabled = true -"#, - skill_path.display() - ), - ) - .expect("write role config"); - let mut child_config = parent_config.clone(); - child_config.agent_roles.insert( - "custom".to_string(), - crate::config::AgentRoleConfig { - description: None, - config_file: Some(role_path), - nickname_candidates: None, - }, + let disabled_skill_config = path_toggle_config(&skill_path, false); + let enabled_skill_config = path_toggle_config(&skill_path, true); + let parent_stack = config_stack(&codex_home, &disabled_skill_config); + let child_stack = + config_stack_with_session_flags(&codex_home, &disabled_skill_config, &enabled_skill_config); + let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), true); + let parent_input = SkillsLoadInput::new( + cwd.path().to_path_buf(), + Vec::new(), + parent_stack.clone(), + bundled_skills_enabled_from_stack(&parent_stack), ); - crate::agent::role::apply_role_to_config(&mut child_config, Some("custom")) - .await - .expect("custom role should apply"); - let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf())); - let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true); - - let parent_outcome = skills_manager - .skills_for_cwd(cwd.path(), &parent_config, true) - .await; + let parent_outcome = skills_manager.skills_for_cwd(&parent_input, true).await; let parent_skill = parent_outcome .skills .iter() @@ -624,7 +550,7 @@ enabled = true .expect("demo skill should be discovered"); assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false); - let child_outcome = skills_manager.skills_for_config(&child_config); + let child_outcome = skills_for_config_with_stack(&skills_manager, &cwd, &child_stack, &[]); let child_skill = child_outcome .skills .iter() diff --git a/codex-rs/core-skills/src/mention_counts.rs b/codex-rs/core-skills/src/mention_counts.rs new file mode 100644 index 0000000000..a9b3da9d30 --- /dev/null +++ b/codex-rs/core-skills/src/mention_counts.rs @@ -0,0 +1,24 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::path::PathBuf; + +use super::SkillMetadata; + +/// Counts how often each skill name appears (exact and ASCII-lowercase), excluding disabled paths. +pub fn build_skill_name_counts( + skills: &[SkillMetadata], + disabled_paths: &HashSet, +) -> (HashMap, HashMap) { + let mut exact_counts: HashMap = HashMap::new(); + let mut lower_counts: HashMap = HashMap::new(); + for skill in skills { + if disabled_paths.contains(&skill.path_to_skills_md) { + continue; + } + *exact_counts.entry(skill.name.clone()).or_insert(0) += 1; + *lower_counts + .entry(skill.name.to_ascii_lowercase()) + .or_insert(0) += 1; + } + (exact_counts, lower_counts) +} diff --git a/codex-rs/core/src/skills/model.rs b/codex-rs/core-skills/src/model.rs similarity index 100% rename from codex-rs/core/src/skills/model.rs rename to codex-rs/core-skills/src/model.rs diff --git a/codex-rs/core/src/skills/remote.rs b/codex-rs/core-skills/src/remote.rs similarity index 95% rename from codex-rs/core/src/skills/remote.rs rename to codex-rs/core-skills/src/remote.rs index 165c450635..2dc620b864 100644 --- a/codex-rs/core/src/skills/remote.rs +++ b/codex-rs/core-skills/src/remote.rs @@ -6,9 +6,8 @@ use std::path::Path; use std::path::PathBuf; use std::time::Duration; -use crate::auth::CodexAuth; -use crate::config::Config; -use crate::default_client::build_reqwest_client; +use codex_login::CodexAuth; +use codex_login::default_client::build_reqwest_client; const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30); @@ -88,13 +87,13 @@ struct RemoteSkill { } pub async fn list_remote_skills( - config: &Config, + chatgpt_base_url: String, auth: Option<&CodexAuth>, scope: RemoteSkillScope, product_surface: RemoteSkillProductSurface, enabled: Option, ) -> Result> { - let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let base_url = chatgpt_base_url.trim_end_matches('/'); let auth = ensure_chatgpt_auth(auth)?; let url = format!("{base_url}/hazelnuts"); @@ -146,14 +145,15 @@ pub async fn list_remote_skills( } pub async fn export_remote_skill( - config: &Config, + chatgpt_base_url: String, + codex_home: PathBuf, auth: Option<&CodexAuth>, skill_id: &str, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let client = build_reqwest_client(); - let base_url = config.chatgpt_base_url.trim_end_matches('/'); + let base_url = chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/hazelnuts/{skill_id}/export"); let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT); @@ -181,7 +181,7 @@ pub async fn export_remote_skill( anyhow::bail!("Downloaded remote skill payload is not a zip archive"); } - let output_dir = config.codex_home.join("skills").join(skill_id); + let output_dir = codex_home.join("skills").join(skill_id); tokio::fs::create_dir_all(&output_dir) .await .context("Failed to create downloaded skills directory")?; diff --git a/codex-rs/core/src/skills/render.rs b/codex-rs/core-skills/src/render.rs similarity index 98% rename from codex-rs/core/src/skills/render.rs rename to codex-rs/core-skills/src/render.rs index 797d53db21..9089a05436 100644 --- a/codex-rs/core/src/skills/render.rs +++ b/codex-rs/core-skills/src/render.rs @@ -1,4 +1,4 @@ -use crate::skills::model::SkillMetadata; +use crate::model::SkillMetadata; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG; use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG; diff --git a/codex-rs/core/src/skills/system.rs b/codex-rs/core-skills/src/system.rs similarity index 100% rename from codex-rs/core/src/skills/system.rs rename to codex-rs/core-skills/src/system.rs diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 42ca739fa2..25e44d7631 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -35,11 +35,11 @@ codex-async-utils = { workspace = true } codex-code-mode = { workspace = true } codex-connectors = { workspace = true } codex-config = { workspace = true } +codex-core-skills = { workspace = true } codex-exec-server = { workspace = true } codex-features = { workspace = true } codex-login = { workspace = true } codex-shell-command = { workspace = true } -codex-skills = { workspace = true } codex-execpolicy = { workspace = true } codex-git-utils = { workspace = true } codex-hooks = { workspace = true } @@ -93,7 +93,6 @@ rmcp = { workspace = true, default-features = false, features = [ schemars = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -serde_yaml = { workspace = true } sha1 = { workspace = true } shlex = { workspace = true } similar = { workspace = true } diff --git a/codex-rs/core/src/agent/role_tests.rs b/codex-rs/core/src/agent/role_tests.rs index 7726ce7957..c96bd2acf7 100644 --- a/codex-rs/core/src/agent/role_tests.rs +++ b/codex-rs/core/src/agent/role_tests.rs @@ -1,9 +1,10 @@ use super::*; +use crate::SkillsManager; use crate::config::CONFIG_TOML_FILE; use crate::config::ConfigBuilder; use crate::config_loader::ConfigLayerStackOrdering; use crate::plugins::PluginsManager; -use crate::skills::SkillsManager; +use crate::skills_load_input_from_config; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::config_types::Verbosity; use codex_protocol::openai_models::ReasoningEffort; @@ -629,8 +630,11 @@ enabled = false .expect("custom role should apply"); let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf())); - let skills_manager = SkillsManager::new(home.path().to_path_buf(), plugins_manager, true); - let outcome = skills_manager.skills_for_config(&config); + let skills_manager = SkillsManager::new(home.path().to_path_buf(), true); + let plugin_outcome = plugins_manager.plugins_for_config(&config); + let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let skills_input = skills_load_input_from_config(&config, effective_skill_roots); + let outcome = skills_manager.skills_for_config(&skills_input); let skill = outcome .skills .iter() diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 7da6799d63..ecc9d3dada 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -35,8 +35,9 @@ use crate::realtime_conversation::handle_audio as handle_realtime_conversation_a use crate::realtime_conversation::handle_close as handle_realtime_conversation_close; use crate::realtime_conversation::handle_start as handle_realtime_conversation_start; use crate::realtime_conversation::handle_text as handle_realtime_conversation_text; +use crate::render_skills_section; use crate::rollout::session_index; -use crate::skills::render_skills_section; +use crate::skills_load_input_from_config; use crate::stream_events_utils::HandleOutputCtx; use crate::stream_events_utils::handle_non_tool_response_item; use crate::stream_events_utils::handle_output_item_done; @@ -49,6 +50,10 @@ use async_channel::Receiver; use async_channel::Sender; use chrono::Local; use chrono::Utc; +use codex_analytics::AnalyticsEventsClient; +use codex_analytics::AppInvocation; +use codex_analytics::InvocationType; +use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_exec_server::Environment; @@ -230,6 +235,14 @@ pub(crate) struct PreviousTurnSettings { pub(crate) realtime_active: Option, } +use crate::SkillError; +use crate::SkillInjections; +use crate::SkillLoadOutcome; +use crate::SkillMetadata; +use crate::SkillsManager; +use crate::build_skill_injections; +use crate::collect_env_var_dependencies; +use crate::collect_explicit_skill_mentions; use crate::exec_policy::ExecPolicyUpdateError; use crate::feedback_tags; use crate::guardian::GuardianReviewSessionManager; @@ -239,6 +252,9 @@ use crate::hook_runtime::record_additional_contexts; use crate::hook_runtime::record_pending_input; use crate::hook_runtime::run_pending_session_start_hooks; use crate::hook_runtime::run_user_prompt_submit_hooks; +use crate::injection::ToolMentionKind; +use crate::injection::app_id_from_path; +use crate::injection::tool_kind_for_path; use crate::instructions::UserInstructions; use crate::mcp::CODEX_APPS_MCP_SERVER_NAME; use crate::mcp::McpManager; @@ -296,6 +312,7 @@ use crate::protocol::TokenUsage; use crate::protocol::TokenUsageInfo; use crate::protocol::TurnDiffEvent; use crate::protocol::WarningEvent; +use crate::resolve_skill_dependencies_for_turn; use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorderParams; use crate::rollout::map_session_init_error; @@ -304,18 +321,6 @@ use crate::rollout::policy::EventPersistenceMode; use crate::session_startup_prewarm::SessionStartupPrewarmHandle; use crate::shell; use crate::shell_snapshot::ShellSnapshot; -use crate::skills::SkillError; -use crate::skills::SkillInjections; -use crate::skills::SkillLoadOutcome; -use crate::skills::SkillMetadata; -use crate::skills::SkillsManager; -use crate::skills::build_skill_injections; -use crate::skills::collect_env_var_dependencies; -use crate::skills::collect_explicit_skill_mentions; -use crate::skills::injection::ToolMentionKind; -use crate::skills::injection::app_id_from_path; -use crate::skills::injection::tool_kind_for_path; -use crate::skills::resolve_skill_dependencies_for_turn; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::state::ActiveTurn; @@ -345,10 +350,6 @@ use crate::turn_timing::record_turn_ttft_metric; use crate::unified_exec::UnifiedExecProcessManager; use crate::util::backoff; use crate::windows_sandbox::WindowsSandboxLevelExt; -use codex_analytics::AnalyticsEventsClient; -use codex_analytics::AppInvocation; -use codex_analytics::InvocationType; -use codex_analytics::build_track_events_context; use codex_async_utils::OrCancelExt; use codex_git_utils::get_git_repo_root; use codex_otel::SessionTelemetry; @@ -472,7 +473,10 @@ impl Codex { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); - let loaded_skills = skills_manager.skills_for_config(&config); + let plugin_outcome = plugins_manager.plugins_for_config(&config); + let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let skills_input = skills_load_input_from_config(&config, effective_skill_roots); + let loaded_skills = skills_manager.skills_for_config(&skills_input); for err in &loaded_skills.errors { error!( @@ -2434,10 +2438,16 @@ impl Session { &per_turn_config, ) .await; + let plugin_outcome = self + .services + .plugins_manager + .plugins_for_config(&per_turn_config); + let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots); let skills_outcome = Arc::new( self.services .skills_manager - .skills_for_config(&per_turn_config), + .skills_for_config(&skills_input), ); let mut turn_context: TurnContext = Self::make_turn_context( self.conversation_id, @@ -4492,8 +4502,14 @@ mod handlers { use crate::codex::SessionSettingsUpdate; use crate::codex::SteerInputError; + use crate::SkillError; use crate::codex::spawn_review_thread; use crate::config::Config; + use crate::config_loader::CloudRequirementsLoader; + use crate::config_loader::LoaderOverrides; + use crate::config_loader::load_config_layers_state; + use codex_features::Feature; + use codex_utils_absolute_path::AbsolutePathBuf; use crate::mcp::auth::compute_auth_statuses; use crate::mcp::collect_mcp_snapshot_from_manager; @@ -4929,11 +4945,64 @@ mod handlers { }; let skills_manager = &sess.services.skills_manager; + let plugins_manager = &sess.services.plugins_manager; let config = sess.get_config().await; + let codex_home = sess.codex_home().await; let mut skills = Vec::new(); + let empty_cli_overrides: &[(String, toml::Value)] = &[]; for cwd in cwds { + let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) { + Ok(path) => path, + Err(err) => { + let message = err.to_string(); + let cwd_for_entry = cwd.clone(); + skills.push(SkillsListEntry { + cwd: cwd_for_entry.clone(), + skills: Vec::new(), + errors: super::errors_to_info(&[SkillError { + path: cwd_for_entry, + message, + }]), + }); + continue; + } + }; + let config_layer_stack = match load_config_layers_state( + &codex_home, + Some(cwd_abs), + empty_cli_overrides, + LoaderOverrides::default(), + CloudRequirementsLoader::default(), + ) + .await + { + Ok(config_layer_stack) => config_layer_stack, + Err(err) => { + let message = err.to_string(); + let cwd_for_entry = cwd.clone(); + skills.push(SkillsListEntry { + cwd: cwd_for_entry.clone(), + skills: Vec::new(), + errors: super::errors_to_info(&[SkillError { + path: cwd_for_entry, + message, + }]), + }); + continue; + } + }; + let effective_skill_roots = plugins_manager.effective_skill_roots_for_layer_stack( + &config_layer_stack, + config.features.enabled(Feature::Plugins), + ); + let skills_input = crate::SkillsLoadInput::new( + cwd.clone(), + effective_skill_roots, + config_layer_stack, + config.bundled_skills_enabled(), + ); let outcome = skills_manager - .skills_for_cwd(&cwd, config.as_ref(), force_reload) + .skills_for_cwd(&skills_input, force_reload) .await; let errors = super::errors_to_info(&outcome.errors); let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths); diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index 82371f5be7..74aea12965 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -2341,7 +2341,10 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { let parent_outcome = session .services .skills_manager - .skills_for_cwd(&parent_config.cwd, &parent_config, true) + .skills_for_cwd( + &crate::skills_load_input_from_config(&parent_config, Vec::new()), + true, + ) .await; let parent_skill = parent_outcome .skills @@ -2537,11 +2540,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - Arc::clone(&plugins_manager), - true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let result = Session::new( session_configuration, Arc::clone(&config), @@ -2642,11 +2641,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - Arc::clone(&plugins_manager), - true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(None) @@ -2714,7 +2709,13 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { config.js_repl_node_module_dirs.clone(), )); - let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); + let plugin_outcome = services + .plugins_manager + .plugins_for_config(&per_turn_config); + let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let skills_input = + crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); + let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input)); let turn_context = Session::make_turn_context( conversation_id, Some(Arc::clone(&auth_manager)), @@ -3478,11 +3479,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( let state = SessionState::new(session_configuration.clone()); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - Arc::clone(&plugins_manager), - true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let network_approval = Arc::new(NetworkApprovalService::default()); let environment = Arc::new( codex_exec_server::Environment::create(None) @@ -3550,7 +3547,13 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( config.js_repl_node_module_dirs.clone(), )); - let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config)); + let plugin_outcome = services + .plugins_manager + .plugins_for_config(&per_turn_config); + let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let skills_input = + crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots); + let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input)); let turn_context = Arc::new(Session::make_turn_context( conversation_id, Some(Arc::clone(&auth_manager)), diff --git a/codex-rs/core/src/codex_tests_guardian.rs b/codex-rs/core/src/codex_tests_guardian.rs index b576ff3eba..54f7405be2 100644 --- a/codex-rs/core/src/codex_tests_guardian.rs +++ b/codex-rs/core/src/codex_tests_guardian.rs @@ -429,11 +429,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { CollaborationModesConfig::default(), )); let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone())); - let skills_manager = Arc::new(SkillsManager::new( - config.codex_home.clone(), - Arc::clone(&plugins_manager), - true, - )); + let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true)); let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); let skills_watcher = Arc::new(SkillsWatcher::noop()); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c785b04618..e799f1b9ba 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2849,7 +2849,7 @@ impl Config { } pub fn bundled_skills_enabled(&self) -> bool { - crate::skills::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack) + crate::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack) } } diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index 7a94700ce9..c9f83482aa 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -801,17 +801,9 @@ impl Notice { pub(crate) const TABLE_KEY: &'static str = "notice"; } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct SkillConfig { - /// Path-based selector. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub path: Option, - /// Name-based selector. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - pub enabled: bool, -} +pub use codex_config::BundledSkillsConfig; +pub use codex_config::SkillConfig; +pub use codex_config::SkillsConfig; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -820,29 +812,6 @@ pub struct PluginConfig { pub enabled: bool, } -#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct SkillsConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bundled: Option, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub config: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)] -#[schemars(deny_unknown_fields)] -pub struct BundledSkillsConfig { - #[serde(default = "default_enabled")] - pub enabled: bool, -} - -impl Default for BundledSkillsConfig { - fn default() -> Self { - Self { enabled: true } - } -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct SandboxWorkspaceWrite { diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index dab130dad1..e4c5039588 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -52,10 +52,12 @@ pub use codex_config::TextRange; pub use codex_config::WebSearchModeRequirement; pub(crate) use codex_config::build_cli_overrides_layer; pub(crate) use codex_config::config_error_from_toml; +pub use codex_config::default_project_root_markers; pub use codex_config::format_config_error; pub use codex_config::format_config_error_with_source; pub(crate) use codex_config::io_error_from_config_error; pub use codex_config::merge_toml_values; +pub use codex_config::project_root_markers_from_config; #[cfg(test)] pub(crate) use codex_config::version_for_toml; @@ -67,8 +69,6 @@ pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml"; #[cfg(windows)] const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData"; -const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"]; - pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option { codex_config::first_layer_config_error::(layers, CONFIG_TOML_FILE).await } @@ -529,55 +529,6 @@ async fn load_requirements_from_legacy_scheme( Ok(()) } -/// Reads `project_root_markers` from the [toml::Value] produced by merging -/// `config.toml` from the config layers in the stack preceding -/// [ConfigLayerSource::Project]. -/// -/// Invariants: -/// - If `project_root_markers` is not specified, returns `Ok(None)`. -/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where -/// `markers` is a `Vec` (including `Ok(Some(Vec::new()))` for an -/// empty array, which indicates that root detection should be disabled). -/// - Returns an error if `project_root_markers` is specified but is not an -/// array of strings. -pub(crate) fn project_root_markers_from_config( - config: &TomlValue, -) -> io::Result>> { - let Some(table) = config.as_table() else { - return Ok(None); - }; - let Some(markers_value) = table.get("project_root_markers") else { - return Ok(None); - }; - let TomlValue::Array(entries) = markers_value else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "project_root_markers must be an array of strings", - )); - }; - if entries.is_empty() { - return Ok(Some(Vec::new())); - } - let mut markers = Vec::new(); - for entry in entries { - let Some(marker) = entry.as_str() else { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "project_root_markers must be an array of strings", - )); - }; - markers.push(marker.to_string()); - } - Ok(Some(markers)) -} - -pub(crate) fn default_project_root_markers() -> Vec { - DEFAULT_PROJECT_ROOT_MARKERS - .iter() - .map(ToString::to_string) - .collect() -} - struct ProjectTrustContext { project_root: AbsolutePathBuf, project_root_key: String, diff --git a/codex-rs/core/src/instructions/mod.rs b/codex-rs/core/src/instructions/mod.rs index 0ef48ff620..5af9a6e890 100644 --- a/codex-rs/core/src/instructions/mod.rs +++ b/codex-rs/core/src/instructions/mod.rs @@ -1,3 +1,2 @@ -pub(crate) use codex_instructions::SkillInstructions; pub use codex_instructions::USER_INSTRUCTIONS_PREFIX; pub(crate) use codex_instructions::UserInstructions; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index b4426c9cdf..446a1a27e5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -61,18 +61,44 @@ pub use text_encoding::bytes_to_string_smart; mod mcp_tool_call; mod memories; pub mod mention_syntax; -mod mentions; pub mod message_history; mod model_provider_info; pub mod utils; pub use utils::path_utils; pub mod personality_migration; pub mod plugins; +pub(crate) mod mentions { + pub(crate) use crate::plugins::build_connector_slug_counts; + pub(crate) use crate::plugins::build_skill_name_counts; + pub(crate) use crate::plugins::collect_explicit_app_ids; + pub(crate) use crate::plugins::collect_explicit_plugin_mentions; + pub(crate) use crate::plugins::collect_tool_mentions_from_messages; +} mod sandbox_tags; pub mod sandboxing; mod session_prefix; mod session_startup_prewarm; mod shell_detect; +pub mod skills; +pub(crate) use skills::SkillError; +pub(crate) use skills::SkillInjections; +pub(crate) use skills::SkillLoadOutcome; +pub(crate) use skills::SkillMetadata; +pub(crate) use skills::SkillsLoadInput; +pub(crate) use skills::SkillsManager; +pub(crate) use skills::build_skill_injections; +pub(crate) use skills::build_skill_name_counts; +pub(crate) use skills::collect_env_var_dependencies; +pub(crate) use skills::collect_explicit_skill_mentions; +pub(crate) use skills::config_rules; +pub(crate) use skills::injection; +pub(crate) use skills::loader; +pub(crate) use skills::manager; +pub(crate) use skills::maybe_emit_implicit_skill_invocation; +pub(crate) use skills::model; +pub(crate) use skills::render_skills_section; +pub(crate) use skills::resolve_skill_dependencies_for_turn; +pub(crate) use skills::skills_load_input_from_config; mod skills_watcher; mod stream_events_utils; pub mod test_support; @@ -126,7 +152,6 @@ pub mod seatbelt; mod session_rollout_init_error; pub mod shell; pub mod shell_snapshot; -pub mod skills; pub mod spawn; pub mod state_db_bridge; pub use codex_rollout::state_db; diff --git a/codex-rs/core/src/mcp/skill_dependencies.rs b/codex-rs/core/src/mcp/skill_dependencies.rs index dc2dc360e2..489dc2e438 100644 --- a/codex-rs/core/src/mcp/skill_dependencies.rs +++ b/codex-rs/core/src/mcp/skill_dependencies.rs @@ -15,6 +15,7 @@ use super::auth::McpOAuthLoginSupport; use super::auth::oauth_login_support; use super::auth::resolve_oauth_scopes; use super::auth::should_retry_without_scopes; +use crate::SkillMetadata; use crate::codex::Session; use crate::codex::TurnContext; use crate::config::Config; @@ -24,8 +25,7 @@ use crate::config::types::McpServerConfig; use crate::config::types::McpServerTransportConfig; use crate::default_client::is_first_party_originator; use crate::default_client::originator; -use crate::skills::SkillMetadata; -use crate::skills::model::SkillToolDependency; +use crate::model::SkillToolDependency; use codex_features::Feature; const SKILL_MCP_DEPENDENCY_PROMPT_ID: &str = "skill_mcp_dependency_install"; diff --git a/codex-rs/core/src/mcp/skill_dependencies_tests.rs b/codex-rs/core/src/mcp/skill_dependencies_tests.rs index 49ba4e9f7f..ebabe16f80 100644 --- a/codex-rs/core/src/mcp/skill_dependencies_tests.rs +++ b/codex-rs/core/src/mcp/skill_dependencies_tests.rs @@ -1,5 +1,5 @@ use super::*; -use crate::skills::model::SkillDependencies; +use crate::model::SkillDependencies; use codex_protocol::protocol::SkillScope; use pretty_assertions::assert_eq; use std::path::PathBuf; diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 261cdf88dc..3f3097d5e3 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -27,6 +27,7 @@ use super::store::PluginStore; use super::store::PluginStoreError; use super::sync_openai_plugins_repo; use crate::AuthManager; +use crate::SkillMetadata; use crate::auth::CodexAuth; use crate::config::Config; use crate::config::ConfigService; @@ -36,12 +37,12 @@ use crate::config::edit::ConfigEditsBuilder; use crate::config::types::McpServerConfig; use crate::config::types::PluginConfig; use crate::config_loader::ConfigLayerStack; -use crate::skills::SkillMetadata; -use crate::skills::config_rules::SkillConfigRules; -use crate::skills::config_rules::resolve_disabled_skill_paths; -use crate::skills::config_rules::skill_config_rules_from_stack; -use crate::skills::loader::SkillRoot; -use crate::skills::loader::load_skills_from_roots; +use crate::config_rules::SkillConfigRules; +use crate::config_rules::resolve_disabled_skill_paths; +use crate::config_rules::skill_config_rules_from_stack; +use crate::loader::SkillRoot; +use crate::loader::load_skills_from_roots; +use codex_analytics::AnalyticsEventsClient; use codex_app_server_protocol::ConfigValueWriteParams; use codex_app_server_protocol::MergeStrategy; use codex_features::Feature; @@ -73,8 +74,6 @@ use toml_edit::value; use tracing::info; use tracing::warn; -use crate::AnalyticsEventsClient; - const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; diff --git a/codex-rs/core/src/plugins/manifest.rs b/codex-rs/core/src/plugins/manifest.rs index d1b2c648e0..8fbacb4565 100644 --- a/codex-rs/core/src/plugins/manifest.rs +++ b/codex-rs/core/src/plugins/manifest.rs @@ -1,5 +1,5 @@ use codex_utils_absolute_path::AbsolutePathBuf; -pub(crate) use codex_utils_plugins::PLUGIN_MANIFEST_PATH; +use codex_utils_plugins::PLUGIN_MANIFEST_PATH; use serde::Deserialize; use serde_json::Value as JsonValue; use std::fs; diff --git a/codex-rs/core/src/mentions.rs b/codex-rs/core/src/plugins/mentions.rs similarity index 77% rename from codex-rs/core/src/mentions.rs rename to codex-rs/core/src/plugins/mentions.rs index fec7c40e4b..5e11b9b91e 100644 --- a/codex-rs/core/src/mentions.rs +++ b/codex-rs/core/src/plugins/mentions.rs @@ -1,19 +1,18 @@ use std::collections::HashMap; use std::collections::HashSet; -use std::path::PathBuf; use codex_protocol::user_input::UserInput; use crate::connectors; +use crate::injection::ToolMentionKind; +use crate::injection::app_id_from_path; +use crate::injection::extract_tool_mentions_with_sigil; +use crate::injection::plugin_config_name_from_path; +use crate::injection::tool_kind_for_path; use crate::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL; use crate::mention_syntax::TOOL_MENTION_SIGIL; -use crate::plugins::PluginCapabilitySummary; -use crate::skills::SkillMetadata; -use crate::skills::injection::ToolMentionKind; -use crate::skills::injection::app_id_from_path; -use crate::skills::injection::extract_tool_mentions_with_sigil; -use crate::skills::injection::plugin_config_name_from_path; -use crate::skills::injection::tool_kind_for_path; + +use super::PluginCapabilitySummary; pub(crate) struct CollectedToolMentions { pub(crate) plain_names: HashSet, @@ -102,23 +101,7 @@ pub(crate) fn collect_explicit_plugin_mentions( .collect() } -pub(crate) fn build_skill_name_counts( - skills: &[SkillMetadata], - disabled_paths: &HashSet, -) -> (HashMap, HashMap) { - let mut exact_counts: HashMap = HashMap::new(); - let mut lower_counts: HashMap = HashMap::new(); - for skill in skills { - if disabled_paths.contains(&skill.path_to_skills_md) { - continue; - } - *exact_counts.entry(skill.name.clone()).or_insert(0) += 1; - *lower_counts - .entry(skill.name.to_ascii_lowercase()) - .or_insert(0) += 1; - } - (exact_counts, lower_counts) -} +pub(crate) use crate::build_skill_name_counts; pub(crate) fn build_connector_slug_counts( connectors: &[connectors::AppInfo], diff --git a/codex-rs/core/src/mentions_tests.rs b/codex-rs/core/src/plugins/mentions_tests.rs similarity index 100% rename from codex-rs/core/src/mentions_tests.rs rename to codex-rs/core/src/plugins/mentions_tests.rs diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index c16ebfcc58..3e4902214c 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -5,6 +5,7 @@ mod injection; mod manager; mod manifest; mod marketplace; +mod mentions; mod remote; mod render; mod startup_sync; @@ -23,7 +24,6 @@ pub use codex_plugin::PluginTelemetryMetadata; pub type LoadedPlugin = codex_plugin::LoadedPlugin; pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome; -pub(crate) use codex_plugin::plugin_namespace_for_skill_path; pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; pub use manager::ConfiguredMarketplace; @@ -61,3 +61,9 @@ pub(crate) use startup_sync::curated_plugins_repo_path; pub(crate) use startup_sync::read_curated_plugins_sha; pub(crate) use startup_sync::sync_openai_plugins_repo; pub use toggles::collect_plugin_enabled_candidates; + +pub(crate) use mentions::build_connector_slug_counts; +pub(crate) use mentions::build_skill_name_counts; +pub(crate) use mentions::collect_explicit_app_ids; +pub(crate) use mentions::collect_explicit_plugin_mentions; +pub(crate) use mentions::collect_tool_mentions_from_messages; diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs new file mode 100644 index 0000000000..8d6886222a --- /dev/null +++ b/codex-rs/core/src/skills.rs @@ -0,0 +1,230 @@ +use std::collections::HashMap; +use std::collections::HashSet; +use std::env; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; + +use crate::codex::Session; +use crate::codex::TurnContext; +use crate::config::Config; +use codex_analytics::InvocationType; +use codex_analytics::SkillInvocation; +use codex_analytics::build_track_events_context; +use codex_protocol::protocol::SkillScope; +use codex_protocol::request_user_input::RequestUserInputArgs; +use codex_protocol::request_user_input::RequestUserInputQuestion; +use codex_protocol::request_user_input::RequestUserInputResponse; +use tracing::warn; + +pub use codex_core_skills::SkillDependencyInfo; +pub use codex_core_skills::SkillError; +pub use codex_core_skills::SkillLoadOutcome; +pub use codex_core_skills::SkillMetadata; +pub use codex_core_skills::SkillPolicy; +pub use codex_core_skills::SkillsLoadInput; +pub use codex_core_skills::SkillsManager; +pub use codex_core_skills::build_skill_name_counts; +pub use codex_core_skills::collect_env_var_dependencies; +pub use codex_core_skills::config_rules; +pub use codex_core_skills::detect_implicit_skill_invocation_for_command; +pub use codex_core_skills::filter_skill_load_outcome_for_product; +pub use codex_core_skills::injection; +pub use codex_core_skills::injection::SkillInjections; +pub use codex_core_skills::injection::build_skill_injections; +pub use codex_core_skills::injection::collect_explicit_skill_mentions; +pub use codex_core_skills::loader; +pub use codex_core_skills::manager; +pub use codex_core_skills::model; +pub use codex_core_skills::remote; +pub use codex_core_skills::render; +pub use codex_core_skills::render_skills_section; +pub use codex_core_skills::system; + +pub(crate) fn skills_load_input_from_config( + config: &Config, + effective_skill_roots: Vec, +) -> SkillsLoadInput { + SkillsLoadInput::new( + config.cwd.clone().to_path_buf(), + effective_skill_roots, + config.config_layer_stack.clone(), + config.bundled_skills_enabled(), + ) +} + +pub(crate) async fn resolve_skill_dependencies_for_turn( + sess: &Arc, + turn_context: &Arc, + dependencies: &[SkillDependencyInfo], +) { + if dependencies.is_empty() { + return; + } + + let existing_env = sess.dependency_env().await; + let mut loaded_values = HashMap::new(); + let mut missing = Vec::new(); + let mut seen_names = HashSet::new(); + + for dependency in dependencies { + let name = dependency.name.clone(); + if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) { + continue; + } + match env::var(&name) { + Ok(value) => { + loaded_values.insert(name.clone(), value); + } + Err(env::VarError::NotPresent) => { + missing.push(dependency.clone()); + } + Err(err) => { + warn!("failed to read env var {name}: {err}"); + missing.push(dependency.clone()); + } + } + } + + if !loaded_values.is_empty() { + sess.set_dependency_env(loaded_values).await; + } + + if !missing.is_empty() { + request_skill_dependencies(sess, turn_context, &missing).await; + } +} + +async fn request_skill_dependencies( + sess: &Arc, + turn_context: &Arc, + dependencies: &[SkillDependencyInfo], +) { + let questions = dependencies + .iter() + .map(|dependency| { + let requirement = dependency.description.as_ref().map_or_else( + || { + format!( + "The skill \"{}\" requires \"{}\" to be set.", + dependency.skill_name, dependency.name + ) + }, + |description| { + format!( + "The skill \"{}\" requires \"{}\" to be set ({}).", + dependency.skill_name, dependency.name, description + ) + }, + ); + RequestUserInputQuestion { + id: dependency.name.clone(), + header: "Skill requires environment variable".to_string(), + question: format!( + "{requirement} This is an experimental internal feature. The value is stored in memory for this session only." + ), + is_other: false, + is_secret: true, + options: None, + } + }) + .collect::>(); + if questions.is_empty() { + return; + } + + let response = sess + .request_user_input( + turn_context, + format!("skill-deps-{}", turn_context.sub_id), + RequestUserInputArgs { questions }, + ) + .await + .unwrap_or_else(|| RequestUserInputResponse { + answers: HashMap::new(), + }); + if response.answers.is_empty() { + return; + } + + let mut values = HashMap::new(); + for (name, answer) in response.answers { + let mut user_note = None; + for entry in &answer.answers { + if let Some(note) = entry.strip_prefix("user_note: ") + && !note.trim().is_empty() + { + user_note = Some(note.trim().to_string()); + } + } + if let Some(value) = user_note { + values.insert(name, value); + } + } + if values.is_empty() { + return; + } + + sess.set_dependency_env(values).await; +} + +pub(crate) async fn maybe_emit_implicit_skill_invocation( + sess: &Session, + turn_context: &TurnContext, + command: &str, + workdir: &Path, +) { + let Some(candidate) = detect_implicit_skill_invocation_for_command( + turn_context.turn_skills.outcome.as_ref(), + command, + workdir, + ) else { + return; + }; + let invocation = SkillInvocation { + skill_name: candidate.name, + skill_scope: candidate.scope, + skill_path: candidate.path_to_skills_md, + invocation_type: InvocationType::Implicit, + }; + let skill_scope = match invocation.skill_scope { + SkillScope::User => "user", + SkillScope::Repo => "repo", + SkillScope::System => "system", + SkillScope::Admin => "admin", + }; + let skill_path = invocation.skill_path.to_string_lossy(); + let skill_name = invocation.skill_name.clone(); + let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}"); + let inserted = { + let mut seen_skills = turn_context + .turn_skills + .implicit_invocation_seen_skills + .lock() + .await; + seen_skills.insert(seen_key) + }; + if !inserted { + return; + } + + turn_context.session_telemetry.counter( + "codex.skill.injected", + /*inc*/ 1, + &[ + ("status", "ok"), + ("skill", skill_name.as_str()), + ("invoke_type", "implicit"), + ], + ); + sess.services + .analytics_events_client + .track_skill_invocations( + build_track_events_context( + turn_context.model_info.slug.clone(), + sess.conversation_id.to_string(), + turn_context.sub_id.clone(), + ), + vec![invocation], + ); +} diff --git a/codex-rs/core/src/skills/env_var_dependencies.rs b/codex-rs/core/src/skills/env_var_dependencies.rs deleted file mode 100644 index 00f5bad8cc..0000000000 --- a/codex-rs/core/src/skills/env_var_dependencies.rs +++ /dev/null @@ -1,162 +0,0 @@ -use std::collections::HashMap; -use std::collections::HashSet; -use std::env; -use std::sync::Arc; - -use codex_protocol::request_user_input::RequestUserInputArgs; -use codex_protocol::request_user_input::RequestUserInputQuestion; -use codex_protocol::request_user_input::RequestUserInputResponse; -use tracing::warn; - -use crate::codex::Session; -use crate::codex::TurnContext; -use crate::skills::SkillMetadata; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SkillDependencyInfo { - pub(crate) skill_name: String, - pub(crate) name: String, - pub(crate) description: Option, -} - -/// Resolve required dependency values (session cache, then env vars), -/// and prompt the UI for any missing ones. -pub(crate) async fn resolve_skill_dependencies_for_turn( - sess: &Arc, - turn_context: &Arc, - dependencies: &[SkillDependencyInfo], -) { - if dependencies.is_empty() { - return; - } - - let existing_env = sess.dependency_env().await; - let mut loaded_values = HashMap::new(); - let mut missing = Vec::new(); - let mut seen_names = HashSet::new(); - - for dependency in dependencies { - let name = dependency.name.clone(); - if !seen_names.insert(name.clone()) { - continue; - } - if existing_env.contains_key(&name) { - continue; - } - match env::var(&name) { - Ok(value) => { - loaded_values.insert(name.clone(), value); - continue; - } - Err(env::VarError::NotPresent) => {} - Err(err) => { - warn!("failed to read env var {name}: {err}"); - } - } - missing.push(dependency.clone()); - } - - if !loaded_values.is_empty() { - sess.set_dependency_env(loaded_values).await; - } - - if !missing.is_empty() { - request_skill_dependencies(sess, turn_context, &missing).await; - } -} - -pub(crate) fn collect_env_var_dependencies( - mentioned_skills: &[SkillMetadata], -) -> Vec { - let mut dependencies = Vec::new(); - for skill in mentioned_skills { - let Some(skill_dependencies) = &skill.dependencies else { - continue; - }; - for tool in &skill_dependencies.tools { - if tool.r#type != "env_var" { - continue; - } - if tool.value.is_empty() { - continue; - } - dependencies.push(SkillDependencyInfo { - skill_name: skill.name.clone(), - name: tool.value.clone(), - description: tool.description.clone(), - }); - } - } - dependencies -} - -/// Prompt via request_user_input to gather missing env vars. -pub(crate) async fn request_skill_dependencies( - sess: &Arc, - turn_context: &Arc, - dependencies: &[SkillDependencyInfo], -) { - let questions = dependencies - .iter() - .map(|dep| { - let requirement = dep.description.as_ref().map_or_else( - || format!("The skill \"{}\" requires \"{}\" to be set.", dep.skill_name, dep.name), - |description| { - format!( - "The skill \"{}\" requires \"{}\" to be set ({}).", - dep.skill_name, dep.name, description - ) - }, - ); - let question = format!( - "{requirement} This is an experimental internal feature. The value is stored in memory for this session only.", - ); - RequestUserInputQuestion { - id: dep.name.clone(), - header: "Skill requires environment variable".to_string(), - question, - is_other: false, - is_secret: true, - options: None, - } - }) - .collect::>(); - - if questions.is_empty() { - return; - } - - let args = RequestUserInputArgs { questions }; - let call_id = format!("skill-deps-{}", turn_context.sub_id); - let response = sess - .request_user_input(turn_context, call_id, args) - .await - .unwrap_or_else(|| RequestUserInputResponse { - answers: HashMap::new(), - }); - - if response.answers.is_empty() { - return; - } - - let mut values = HashMap::new(); - for (name, answer) in response.answers { - let mut user_note = None; - for entry in &answer.answers { - if let Some(note) = entry.strip_prefix("user_note: ") - && !note.trim().is_empty() - { - user_note = Some(note.trim().to_string()); - } - } - if let Some(value) = user_note { - values.insert(name, value); - } - } - - if values.is_empty() { - return; - } - - sess.set_dependency_env(values).await; -} diff --git a/codex-rs/core/src/skills_watcher.rs b/codex-rs/core/src/skills_watcher.rs index fc0b86d90d..9471373532 100644 --- a/codex-rs/core/src/skills_watcher.rs +++ b/codex-rs/core/src/skills_watcher.rs @@ -8,6 +8,7 @@ use tokio::runtime::Handle; use tokio::sync::broadcast; use tracing::warn; +use crate::SkillsManager; use crate::config::Config; use crate::file_watcher::FileWatcher; use crate::file_watcher::FileWatcherSubscriber; @@ -15,7 +16,8 @@ use crate::file_watcher::Receiver; use crate::file_watcher::ThrottledWatchReceiver; use crate::file_watcher::WatchPath; use crate::file_watcher::WatchRegistration; -use crate::skills::SkillsManager; +use crate::plugins::PluginsManager; +use crate::skills_load_input_from_config; #[cfg(not(test))] const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10); @@ -56,9 +58,13 @@ impl SkillsWatcher { &self, config: &Config, skills_manager: &SkillsManager, + plugins_manager: &PluginsManager, ) -> WatchRegistration { + let plugin_outcome = plugins_manager.plugins_for_config(config); + let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let skills_input = skills_load_input_from_config(config, effective_skill_roots); let roots = skills_manager - .skill_roots_for_config(config) + .skill_roots_for_config(&skills_input) .into_iter() .map(|root| WatchPath { path: root.path, diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index ad980db1a7..d44c5eea77 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::AuthManager; use crate::RolloutRecorder; +use crate::SkillsManager; use crate::agent::AgentControl; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; @@ -11,7 +12,6 @@ use crate::mcp::McpManager; use crate::mcp_connection_manager::McpConnectionManager; use crate::models_manager::manager::ModelsManager; use crate::plugins::PluginsManager; -use crate::skills::SkillsManager; use crate::skills_watcher::SkillsWatcher; use crate::state_db::StateDbHandle; use crate::tools::code_mode::CodeModeService; diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 6ce55c6229..511a6fc97b 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -2,6 +2,7 @@ use crate::AuthManager; use crate::CodexAuth; use crate::ModelProviderInfo; use crate::OPENAI_PROVIDER_ID; +use crate::SkillsManager; use crate::agent::AgentControl; use crate::codex::Codex; use crate::codex::CodexSpawnArgs; @@ -22,7 +23,6 @@ use crate::protocol::SessionConfiguredEvent; use crate::rollout::RolloutRecorder; use crate::rollout::truncation; use crate::shell_snapshot::ShellSnapshot; -use crate::skills::SkillsManager; use crate::skills_watcher::SkillsWatcher; use crate::skills_watcher::SkillsWatcherEvent; use crate::tasks::interrupted_turn_history_marker; @@ -231,7 +231,6 @@ impl ThreadManager { let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( codex_home.clone(), - Arc::clone(&plugins_manager), config.bundled_skills_enabled(), restriction_product, )); @@ -297,7 +296,6 @@ impl ThreadManager { let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager))); let skills_manager = Arc::new(SkillsManager::new_with_restriction_product( codex_home.clone(), - Arc::clone(&plugins_manager), /*bundled_skills_enabled*/ true, restriction_product, )); @@ -835,9 +833,11 @@ impl ThreadManagerState { parent_trace: Option, user_shell_override: Option, ) -> CodexResult { - let watch_registration = self - .skills_watcher - .register_config(&config, self.skills_manager.as_ref()); + let watch_registration = self.skills_watcher.register_config( + &config, + self.skills_manager.as_ref(), + self.plugins_manager.as_ref(), + ); let CodexSpawnOk { codex, thread_id, .. } = Codex::spawn(CodexSpawnArgs { diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 446ca100b7..3c8bbe1320 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -11,9 +11,9 @@ use crate::exec_env::create_env; use crate::exec_policy::ExecApprovalRequest; use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; +use crate::maybe_emit_implicit_skill_invocation; use crate::protocol::ExecCommandSource; use crate::shell::Shell; -use crate::skills::maybe_emit_implicit_skill_invocation; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -288,11 +288,12 @@ impl ToolHandler for ShellCommandHandler { let cwd = resolve_workdir_base_path(&arguments, turn.cwd.as_path())?; let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, cwd.as_path())?; + let workdir = turn.resolve_path(params.workdir.clone()); maybe_emit_implicit_skill_invocation( session.as_ref(), turn.as_ref(), ¶ms.command, - params.workdir.as_deref(), + &workdir, ) .await; let prefix_rule = params.prefix_rule.clone(); diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index 54543277f5..463168808e 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,11 +1,11 @@ use crate::function_tool::FunctionCallError; use crate::is_safe_command::is_known_safe_command; +use crate::maybe_emit_implicit_skill_invocation; use crate::protocol::EventMsg; use crate::protocol::TerminalInteractionEvent; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; use crate::shell::get_shell_by_model_provided_path; -use crate::skills::maybe_emit_implicit_skill_invocation; use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; use crate::tools::context::ToolPayload; @@ -147,11 +147,12 @@ impl ToolHandler for UnifiedExecHandler { let cwd = resolve_workdir_base_path(&arguments, context.turn.cwd.as_path())?; let args: ExecCommandArgs = parse_arguments_with_base_path(&arguments, cwd.as_path())?; + let workdir = context.turn.resolve_path(args.workdir.clone()); maybe_emit_implicit_skill_invocation( session.as_ref(), - turn.as_ref(), + context.turn.as_ref(), &args.cmd, - args.workdir.as_deref(), + &workdir, ) .await; let process_id = manager.allocate_process_id().await; diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index bc2f56a430..aea23163f9 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -4,9 +4,9 @@ Module: runtimes Concrete ToolRuntime implementations for specific tools. Each runtime stays small and focused and reuses the orchestrator for approvals + sandbox + retry. */ +use crate::SkillMetadata; use crate::path_utils; use crate::shell::Shell; -use crate::skills::SkillMetadata; use crate::tools::sandboxing::ToolError; use codex_protocol::models::PermissionProfile; use codex_sandboxing::SandboxCommand; diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs index 9df94e828f..f40d09daf8 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation.rs @@ -1,4 +1,5 @@ use super::ShellRequest; +use crate::SkillMetadata; use crate::error::CodexErr; use crate::error::SandboxErr; use crate::exec::ExecCapturePolicy; @@ -12,7 +13,7 @@ use crate::sandboxing::ExecOptions; use crate::sandboxing::ExecRequest; use crate::sandboxing::SandboxPermissions; use crate::shell::ShellType; -use crate::skills::SkillMetadata; +use crate::skills_load_input_from_config; use crate::tools::runtimes::ExecveSessionApproval; use crate::tools::runtimes::build_sandbox_command; use crate::tools::sandboxing::SandboxAttempt; @@ -487,11 +488,19 @@ impl CoreShellActionProvider { /// any skills. async fn find_skill(&self, program: &AbsolutePathBuf) -> Option { let force_reload = false; + let turn_config = self.turn.config.as_ref(); + let plugin_outcome = self + .session + .services + .plugins_manager + .plugins_for_config(turn_config); + let effective_skill_roots = plugin_outcome.effective_skill_roots(); + let skills_input = skills_load_input_from_config(turn_config, effective_skill_roots); let skills_outcome = self .session .services .skills_manager - .skills_for_cwd(&self.turn.cwd, self.turn.config.as_ref(), force_reload) + .skills_for_cwd(&skills_input, force_reload) .await; let program_path = program.as_path(); diff --git a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs index 25cbb71c02..844e9dcad6 100644 --- a/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs +++ b/codex-rs/core/src/tools/runtimes/shell/unix_escalation_tests.rs @@ -8,6 +8,7 @@ use super::evaluate_intercepted_exec_policy; use super::extract_shell_script; use super::join_program_and_argv; use super::map_exec_result; +use crate::SkillMetadata; #[cfg(target_os = "macos")] use crate::config::Constrained; #[cfg(target_os = "macos")] @@ -19,7 +20,6 @@ use crate::protocol::GranularApprovalConfig; use crate::protocol::ReadOnlyAccess; use crate::protocol::SandboxPolicy; use crate::sandboxing::SandboxPermissions; -use crate::skills::SkillMetadata; use codex_execpolicy::Decision; use codex_execpolicy::Evaluation; use codex_execpolicy::PolicyParser; diff --git a/codex-rs/plugin/src/plugin_namespace.rs b/codex-rs/plugin/src/plugin_namespace.rs new file mode 100644 index 0000000000..6688ae0469 --- /dev/null +++ b/codex-rs/plugin/src/plugin_namespace.rs @@ -0,0 +1,70 @@ +//! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. + +use std::fs; +use std::path::Path; + +/// Relative path from a plugin root to its manifest file. +pub const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json"; + +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawPluginManifestName { + #[serde(default)] + name: String, +} + +fn plugin_manifest_name(plugin_root: &Path) -> Option { + let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH); + if !manifest_path.is_file() { + return None; + } + let contents = fs::read_to_string(&manifest_path).ok()?; + let RawPluginManifestName { name: raw_name } = serde_json::from_str(&contents).ok()?; + Some( + plugin_root + .file_name() + .and_then(|entry| entry.to_str()) + .filter(|_| raw_name.trim().is_empty()) + .unwrap_or(raw_name.as_str()) + .to_string(), + ) +} + +/// Returns the plugin manifest `name` for the nearest ancestor of `path` that contains a valid +/// plugin manifest (same `name` rules as full manifest loading in codex-core). +pub fn plugin_namespace_for_skill_path(path: &Path) -> Option { + for ancestor in path.ancestors() { + if let Some(name) = plugin_manifest_name(ancestor) { + return Some(name); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::plugin_namespace_for_skill_path; + use std::fs; + use tempfile::tempdir; + + #[test] + fn uses_manifest_name() { + let tmp = tempdir().expect("tempdir"); + let plugin_root = tmp.path().join("plugins/sample"); + let skill_path = plugin_root.join("skills/search/SKILL.md"); + + fs::create_dir_all(skill_path.parent().expect("parent")).expect("mkdir"); + fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("mkdir manifest"); + fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"sample"}"#, + ) + .expect("write manifest"); + fs::write(&skill_path, "---\ndescription: search\n---\n").expect("write skill"); + + assert_eq!( + plugin_namespace_for_skill_path(&skill_path), + Some("sample".to_string()) + ); + } +} From e25d698cb3b597fe1d19236a76a44bcb76bb6cd0 Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 25 Mar 2026 13:43:12 -0700 Subject: [PATCH 49/50] test cleanup --- codex-rs/core/tests/suite/thread_metadata.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/codex-rs/core/tests/suite/thread_metadata.rs b/codex-rs/core/tests/suite/thread_metadata.rs index 0984ebeb62..a307d25059 100644 --- a/codex-rs/core/tests/suite/thread_metadata.rs +++ b/codex-rs/core/tests/suite/thread_metadata.rs @@ -122,16 +122,14 @@ async fn resumed_thread_emits_thread_initialized_analytics() -> Result<()> { codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(), ); config.personality = Some(Personality::Friendly); - config.ephemeral = true; } }) .build(&server) .await?; let rollout_path = initial - .session_configured - .rollout_path - .clone() + .codex + .rollout_path() .expect("rollout path for initial thread"); let resume_deadline = Instant::now() + Duration::from_secs(10); loop { @@ -160,7 +158,6 @@ async fn resumed_thread_emits_thread_initialized_analytics() -> Result<()> { codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy(), ); config.personality = Some(Personality::Friendly); - config.ephemeral = true; }) .resume(&server, initial.home.clone(), rollout_path) .await?; From 2dc789b211b121f6b349ec6fe9e657f0d1ce130e Mon Sep 17 00:00:00 2001 From: Roy Han Date: Wed, 25 Mar 2026 13:49:47 -0700 Subject: [PATCH 50/50] rebase cleanup after migration --- codex-rs/core/src/codex.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 886d69057e..7fb1e80d75 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,10 +52,6 @@ use async_channel::Receiver; use async_channel::Sender; use chrono::Local; use chrono::Utc; -use codex_analytics::AnalyticsEventsClient; -use codex_analytics::AppInvocation; -use codex_analytics::InvocationType; -use codex_analytics::build_track_events_context; use codex_app_server_protocol::McpServerElicitationRequest; use codex_app_server_protocol::McpServerElicitationRequestParams; use codex_exec_server::Environment;