From 610b86fefb1873d5178a2fe30e04f807c4f6206a Mon Sep 17 00:00:00 2001 From: Eric Ning Date: Wed, 13 May 2026 11:00:54 -0700 Subject: [PATCH 01/47] Pass Codex product SKU to ChatGPT backend (#22366) # Description We need to set the appropriate Product SKU for full functionality for the apps endpoints for each type of client # Testing `./target/debug/codex --enable app` CleanShot 2026-05-12 at 11 51 25@2x Regular slack flows seem to work, also curling these endpoints with the correct SKU returns the right apps --- codex-rs/chatgpt/src/chatgpt_client.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/codex-rs/chatgpt/src/chatgpt_client.rs b/codex-rs/chatgpt/src/chatgpt_client.rs index 05d8186686..372f62e696 100644 --- a/codex-rs/chatgpt/src/chatgpt_client.rs +++ b/codex-rs/chatgpt/src/chatgpt_client.rs @@ -6,6 +6,9 @@ use anyhow::Context; use serde::de::DeserializeOwned; use std::time::Duration; +const OAI_PRODUCT_SKU_HEADER: &str = "OAI-Product-Sku"; +const CODEX_PRODUCT_SKU: &str = "codex"; + /// Make a GET request to the ChatGPT backend API. pub(crate) async fn chatgpt_get_request( config: &Config, @@ -46,6 +49,7 @@ pub(crate) async fn chatgpt_get_request_with_timeout( let mut request = client .get(&url) .headers(codex_model_provider::auth_provider_from_auth(&auth).to_auth_headers()) + .header(OAI_PRODUCT_SKU_HEADER, CODEX_PRODUCT_SKU) .header("Content-Type", "application/json"); if let Some(timeout) = timeout { From 4454e1411b82dd755e487e4b7ecd4d949c0ed336 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 13 May 2026 11:15:25 -0700 Subject: [PATCH 02/47] Deprecate TurnContext cwd and resolve_path (#22519) ## Why `TurnContext::cwd` and `TurnContext::resolve_path` are being phased out in favor of using the selected turn environment cwd directly. Deprecating both APIs makes any new direct dependency visible while preserving the existing migration path for current callers. ## What Changed - Marked `TurnContext::cwd` and `TurnContext::resolve_path` as deprecated with guidance to use the selected turn environment cwd instead. - Added exact `#[allow(deprecated)]` suppressions at each existing direct usage site, including tests, rather than adding crate-wide suppression. - Kept the change behavior-preserving: current cwd reads, writes, and path resolution continue to use the same values. ## Verification - `just fmt` - `cargo check -p codex-core` - `cargo check -p codex-core --tests` - `git diff --check` --- codex-rs/core/src/codex_delegate.rs | 11 ++++- codex-rs/core/src/codex_delegate_tests.rs | 1 + codex-rs/core/src/context_manager/updates.rs | 1 + codex-rs/core/src/guardian/review_session.rs | 2 + codex-rs/core/src/hook_runtime.rs | 7 +++ codex-rs/core/src/mcp_openai_file.rs | 16 +++++-- codex-rs/core/src/mcp_tool_call.rs | 1 + codex-rs/core/src/mcp_tool_call_tests.rs | 18 ++++++-- codex-rs/core/src/memory_usage.rs | 21 ++++----- codex-rs/core/src/session/handlers.rs | 1 + codex-rs/core/src/session/mcp.rs | 1 + codex-rs/core/src/session/mod.rs | 3 ++ codex-rs/core/src/session/review.rs | 2 + .../session/rollout_reconstruction_tests.rs | 8 ++++ codex-rs/core/src/session/tests.rs | 45 ++++++++++++++----- .../core/src/session/tests/guardian_tests.rs | 12 +++-- codex-rs/core/src/session/turn.rs | 5 +++ codex-rs/core/src/session/turn_context.rs | 9 ++++ codex-rs/core/src/tasks/user_shell.rs | 2 + .../agent_jobs/spawn_agents_on_csv.rs | 6 ++- .../src/tools/handlers/multi_agents_common.rs | 4 +- .../src/tools/handlers/multi_agents_tests.rs | 22 ++++++--- .../src/tools/handlers/request_permissions.rs | 1 + codex-rs/core/src/tools/handlers/shell.rs | 2 + .../src/tools/handlers/shell/shell_command.rs | 6 ++- .../core/src/tools/handlers/shell_tests.rs | 1 + .../core/src/tools/handlers/view_image.rs | 5 ++- codex-rs/core/src/tools/network_approval.rs | 1 + codex-rs/core/src/tools/orchestrator.rs | 1 + codex-rs/core/src/tools/registry.rs | 1 + codex-rs/core/src/unified_exec/mod_tests.rs | 9 +++- .../src/unified_exec/process_manager_tests.rs | 3 ++ 32 files changed, 183 insertions(+), 45 deletions(-) diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index 5a01384bf0..493771e28c 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -530,7 +530,10 @@ async fn handle_patch_approval( let guardian_decision = if routes_approval_to_guardian(parent_ctx) { let files = changes .keys() - .map(|path| parent_ctx.cwd.join(path)) + .map(|path| { + #[allow(deprecated)] + parent_ctx.cwd.join(path) + }) .collect::>(); let review_cancel = cancel_token.child_token(); let patch = changes @@ -566,6 +569,7 @@ async fn handle_patch_approval( new_guardian_review_id(), GuardianApprovalRequest::ApplyPatch { id: approval_id.clone(), + #[allow(deprecated)] cwd: parent_ctx.cwd.clone(), files, patch, @@ -739,7 +743,10 @@ async fn handle_request_permissions( reason: event.reason, permissions: event.permissions, }; - let cwd = event.cwd.unwrap_or_else(|| parent_ctx.cwd.clone()); + let cwd = event.cwd.unwrap_or_else(|| { + #[allow(deprecated)] + parent_ctx.cwd.clone() + }); let response_fut = parent_session.request_permissions_for_cwd( parent_ctx, call_id.clone(), diff --git a/codex-rs/core/src/codex_delegate_tests.rs b/codex-rs/core/src/codex_delegate_tests.rs index ecd392e3e7..66cde8d1ea 100644 --- a/codex-rs/core/src/codex_delegate_tests.rs +++ b/codex-rs/core/src/codex_delegate_tests.rs @@ -207,6 +207,7 @@ async fn handle_request_permissions_uses_tool_call_id_for_round_trip() { scope: PermissionGrantScope::Turn, strict_auto_review: false, }; + #[allow(deprecated)] let delegated_cwd = parent_ctx.cwd.join("delegated-cwd"); let cancel_token = CancellationToken::new(); let request_call_id = call_id.clone(); diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index 0cdbc0b010..d7302bbbf8 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -61,6 +61,7 @@ fn build_permissions_update_item( next.approval_policy.value(), next.config.approvals_reviewer, exec_policy, + #[allow(deprecated)] &next.cwd, next.features.enabled(Feature::ExecPermissionApprovals), next.features.enabled(Feature::RequestPermissionsTool), diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index a419d7cbfa..d3fae0f2e5 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -708,6 +708,7 @@ async fn run_review_on_session( Box::pin(review_session.codex.submit(Op::UserTurn { environments: None, items: prompt_items.items, + #[allow(deprecated)] cwd: params.parent_turn.cwd.to_path_buf(), approval_policy: AskForApproval::Never, approvals_reviewer: None, @@ -1086,6 +1087,7 @@ mod tests { let reasoning_effort = turn.reasoning_effort; let reasoning_summary = turn.reasoning_summary; let personality = turn.personality; + #[allow(deprecated)] let cwd = turn.cwd.clone(); let spawn_config = build_guardian_review_session_config( turn.config.as_ref(), diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 3d72e4a7c6..78fc24833e 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -116,6 +116,7 @@ pub(crate) async fn run_pending_session_start_hooks( let request = codex_hooks::SessionStartRequest { session_id: sess.session_id().into(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -150,6 +151,7 @@ pub(crate) async fn run_pre_tool_use_hooks( let request = PreToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -209,6 +211,7 @@ pub(crate) async fn run_permission_request_hooks( let request = PermissionRequestRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -249,6 +252,7 @@ pub(crate) async fn run_post_tool_use_hooks( let request = PostToolUseRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -276,6 +280,7 @@ pub(crate) async fn run_pre_compact_hooks( let request = codex_hooks::PreCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -313,6 +318,7 @@ pub(crate) async fn run_post_compact_hooks( let request = codex_hooks::PostCompactRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -338,6 +344,7 @@ pub(crate) async fn run_user_prompt_submit_hooks( let request = UserPromptSubmitRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 0e0d4a6008..ae44515c62 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -102,6 +102,7 @@ async fn build_uploaded_local_argument_value( index: Option, file_path: &str, ) -> Result { + #[allow(deprecated)] let resolved_path = turn_context.resolve_path(Some(file_path.to_string())); let Some(auth) = auth else { return Err( @@ -216,7 +217,10 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -297,7 +301,10 @@ mod tests { tokio::fs::write(&local_path, b"hello") .await .expect("write local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); @@ -411,7 +418,10 @@ mod tests { tokio::fs::write(dir.path().join("two.csv"), b"two") .await .expect("write second local file"); - turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + #[allow(deprecated)] + { + turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path"); + } let mut config = (*turn_context.config).clone(); config.chatgpt_base_url = format!("{}/backend-api", server.uri()); diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 8855aae62e..d1faa421d9 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -732,6 +732,7 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( permission_profile: Some(turn_context.permission_profile()), sandbox_policy: turn_context.sandbox_policy(), codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(), + #[allow(deprecated)] sandbox_cwd: turn_context.cwd.to_path_buf(), use_legacy_landlock: turn_context.features.use_legacy_landlock(), })?; diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 65f8977283..8b4f1ba223 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -1221,7 +1221,10 @@ async fn install_host_owned_codex_apps_manager(session: &Session, turn_context: turn_context.sub_id.clone(), session.get_tx_event(), turn_context.permission_profile(), - codex_mcp::McpRuntimeEnvironment::new(environment, turn_context.cwd.to_path_buf()), + codex_mcp::McpRuntimeEnvironment::new(environment, { + #[allow(deprecated)] + turn_context.cwd.to_path_buf() + }), turn_context.config.codex_home.to_path_buf(), codex_mcp::codex_apps_tools_cache_key(auth.as_ref()), /*host_owned_codex_apps_enabled*/ true, @@ -2230,7 +2233,10 @@ async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_serve .build() .await .expect("load project config"); - turn_context.cwd = config.cwd.clone(); + #[allow(deprecated)] + { + turn_context.cwd = config.cwd.clone(); + } turn_context.config = Arc::new(config); let key = McpToolApprovalKey { server: "docs".to_string(), @@ -2431,12 +2437,14 @@ async fn permission_request_hook_allows_mcp_tool_call() { .lines() .map(|line| serde_json::from_str::(line).expect("parse hook input")) .collect::>(); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); assert_eq!( inputs, vec![serde_json::json!({ "session_id": session.session_id(), "turn_id": "turn_id", - "cwd": turn_context.cwd, + "cwd": turn_cwd, "transcript_path": null, "model": turn_context.model_info.slug, "permission_mode": "default", @@ -2491,12 +2499,14 @@ async fn permission_request_hook_uses_hook_tool_name_without_metadata() { .lines() .map(|line| serde_json::from_str::(line).expect("parse hook input")) .collect::>(); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); assert_eq!( inputs, vec![serde_json::json!({ "session_id": session.session_id(), "turn_id": "turn_id", - "cwd": turn_context.cwd, + "cwd": turn_cwd, "transcript_path": null, "model": turn_context.model_info.slug, "permission_mode": "default", diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index bd856d5e6f..b5bc6eef53 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -44,10 +44,9 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec Option<(Vec serde_json::from_str::(arguments) .ok() @@ -71,10 +69,9 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec None, } diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs index ab40cb8eed..5cb37f9828 100644 --- a/codex-rs/core/src/session/handlers.rs +++ b/codex-rs/core/src/session/handlers.rs @@ -699,6 +699,7 @@ pub async fn review( .await; sess.refresh_mcp_servers_if_requested(&turn_context, Some(sess.mcp_elicitation_reviewer())) .await; + #[allow(deprecated)] match resolve_review_request(review_request, &turn_context.cwd) { Ok(resolved) => { spawn_review_thread( diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index d32ecf2b19..fcaaa17c57 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -296,6 +296,7 @@ impl Session { .environment_manager .default_environment() .unwrap_or_else(|| self.services.environment_manager.local_environment()), + #[allow(deprecated)] turn_context.cwd.to_path_buf(), ), }; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 3ef9466812..fd3f31771d 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -2051,6 +2051,7 @@ impl Session { turn_context, call_id, args, + #[allow(deprecated)] turn_context.cwd.clone(), cancellation_token, ) @@ -2630,6 +2631,7 @@ impl Session { turn_context.approval_policy.value(), turn_context.config.approvals_reviewer, self.services.exec_policy.current().as_ref(), + #[allow(deprecated)] &turn_context.cwd, turn_context .features @@ -2763,6 +2765,7 @@ impl Session { contextual_user_sections.push( UserInstructions { text: user_instructions.to_string(), + #[allow(deprecated)] directory: turn_context.cwd.to_string_lossy().into_owned(), } .render(), diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 083aac8c04..e84465508a 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -107,6 +107,7 @@ pub(super) async fn spawn_review_thread( sess.thread_id().to_string(), parent_turn_context.thread_source, review_turn_id.clone(), + #[allow(deprecated)] parent_turn_context.cwd.clone(), &parent_turn_context.permission_profile, parent_turn_context.windows_sandbox_level, @@ -143,6 +144,7 @@ pub(super) async fn spawn_review_thread( network: parent_turn_context.network.clone(), windows_sandbox_level: parent_turn_context.windows_sandbox_level, shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), + #[allow(deprecated)] cwd: parent_turn_context.cwd.clone(), final_output_json_schema: None, codex_self_exe: parent_turn_context.codex_self_exe.clone(), diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs index 5cfcc38053..143b23d3a3 100644 --- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs +++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs @@ -60,6 +60,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -101,6 +102,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -911,6 +913,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -989,6 +992,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1020,6 +1024,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1135,6 +1140,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1249,6 +1255,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -1401,6 +1408,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 9ef67bcb8e..577b2fa5ec 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -2216,6 +2216,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(), + #[allow(deprecated)] cwd: turn_context.cwd.to_path_buf(), current_date: turn_context.current_date.clone(), timezone: turn_context.timezone.clone(), @@ -3769,6 +3770,7 @@ async fn session_configuration_apply_preserves_absolute_cwd_write_root_on_cwd_up #[tokio::test] async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { let (session, turn_context) = make_session_and_context().await; + #[allow(deprecated)] let updated_cwd = turn_context.cwd.join("project"); std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir"); @@ -3788,8 +3790,12 @@ async fn session_update_settings_does_not_rewrite_sticky_environment_cwds() { 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); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + #[allow(deprecated)] + let next_turn_cwd = next_turn.cwd.clone(); + assert_eq!(config.cwd, turn_cwd); + assert_eq!(next_turn_cwd, updated_cwd); assert_eq!(next_turn.config.cwd, updated_cwd); } @@ -3873,7 +3879,9 @@ async fn absolute_cwd_update_with_turn_environment_is_allowed() { .await .expect("absolute cwd with explicit environments should succeed"); - assert_eq!(turn_context.cwd, absolute_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, absolute_cwd); assert_eq!(turn_context.config.cwd, absolute_cwd); assert_eq!(turn_context.environments.turn_environments.len(), 1); } @@ -4674,7 +4682,9 @@ async fn request_permissions_emits_event_when_granular_policy_allows_requests() panic!("expected request_permissions event"); }; assert_eq!(request.call_id, call_id); - assert_eq!(request.cwd, Some(turn_context.cwd.clone())); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(request.cwd, Some(turn_cwd)); session .notify_request_permissions_response(&request.call_id, expected_response.clone()) @@ -5103,7 +5113,9 @@ async fn turn_environments_set_primary_environment() { &turn_environments.turn_environments[0].environment )); assert!(!turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd.as_path(), selected_cwd.as_path()); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd.as_path(), selected_cwd.as_path()); assert_eq!(turn_context.config.cwd.as_path(), selected_cwd.as_path()); } @@ -5134,7 +5146,9 @@ async fn default_turn_overlays_session_cwd_onto_stored_thread_environments() { &turn_environment.environment, &turn_environments.turn_environments[0].environment )); - assert_eq!(turn_context.cwd, session_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); } @@ -5152,7 +5166,9 @@ async fn default_turn_honors_empty_stored_thread_environments() { assert!(turn_context.environments.primary().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd, session_cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session_cwd); assert_eq!(turn_context.config.cwd, session_cwd); assert_eq!(turn_context.environments.turn_environments.len(), 0); } @@ -5161,6 +5177,7 @@ async fn default_turn_honors_empty_stored_thread_environments() { async fn primary_environment_uses_first_turn_environment() { let (_session, mut turn_context) = make_session_and_context().await; let first_environment = turn_context.environments.turn_environments[0].clone(); + #[allow(deprecated)] let second_cwd = turn_context.cwd.join("second"); turn_context .environments @@ -5214,7 +5231,9 @@ async fn empty_turn_environments_clear_primary_environment() { assert!(turn_context.environments.primary().is_none()); assert!(turn_context.environments.turn_environments.is_empty()); - assert_eq!(turn_context.cwd, session.get_config().await.cwd); + #[allow(deprecated)] + let turn_cwd = turn_context.cwd.clone(); + assert_eq!(turn_cwd, session.get_config().await.cwd); assert_eq!(turn_context.config.cwd, session.get_config().await.cwd); } @@ -7033,13 +7052,16 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is } fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSystemSandboxPolicy { + #[allow(deprecated)] let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &turn_context.sandbox_policy(), &turn_context.cwd, ); + #[allow(deprecated)] + let cwd_display = turn_context.cwd.as_path().display().to_string(); policy.entries.push(FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { - pattern: format!("{}/**/*.env", turn_context.cwd.as_path().display()), + pattern: format!("{cwd_display}/**/*.env"), }, access: FileSystemAccessMode::None, }); @@ -9381,6 +9403,8 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { let call_id = "test-call".to_string(); let handler = ShellCommandHandler::from(ShellCommandBackendConfig::Classic); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -9393,7 +9417,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { payload: ToolPayload::Function { arguments: serde_json::json!({ "command": command_script, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, "timeout_ms": timeout_ms, "sandbox_permissions": sandbox_permissions, "justification": Some("test"), @@ -9433,6 +9457,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() { approval_policy: turn_context.approval_policy.value(), permission_profile: turn_context.permission_profile(), file_system_sandbox_policy: &file_system_sandbox_policy, + #[allow(deprecated)] sandbox_cwd: turn_context.cwd.as_path(), sandbox_permissions: SandboxPermissions::UseDefault, prefix_rule: None, diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index ffb0d94d80..718b9bf05f 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -291,6 +291,8 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli let handler = crate::tools::handlers::ShellCommandHandler::from( codex_tools::ShellCommandBackendConfig::Classic, ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -304,7 +306,7 @@ async fn guardian_allows_shell_command_additional_permissions_requests_past_poli arguments: serde_json::json!({ "command": "echo hi", "login": false, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, "timeout_ms": expiration_ms, "sandbox_permissions": SandboxPermissions::WithAdditionalPermissions, "additional_permissions": PermissionProfile { @@ -392,6 +394,8 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ let handler = crate::tools::handlers::ShellCommandHandler::from( codex_tools::ShellCommandBackendConfig::Classic, ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -405,7 +409,7 @@ async fn strict_auto_review_turn_grant_forces_guardian_for_shell_command_policy_ arguments: serde_json::json!({ "command": "echo hi", "login": false, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, "timeout_ms": 1_000_u64, }) .to_string(), @@ -558,6 +562,8 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per let handler = crate::tools::handlers::ShellCommandHandler::from( codex_tools::ShellCommandBackendConfig::Classic, ); + #[allow(deprecated)] + let workdir = Some(turn_context.cwd.to_string_lossy().to_string()); let resp = handler .handle(ToolInvocation { session: Arc::clone(&session), @@ -572,7 +578,7 @@ async fn shell_command_allows_sticky_turn_permissions_without_inline_request_per "command": "echo hi", "login": false, "timeout_ms": 1_000_u64, - "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), + "workdir": workdir, }) .to_string(), }, diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 2dd84213ed..d910e03b21 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -366,6 +366,7 @@ pub(crate) async fn run_turn( let mut stop_hook_active = false; // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. + #[allow(deprecated)] let display_root = get_git_repo_root(turn_context.cwd.as_path()) .unwrap_or_else(|| turn_context.cwd.clone().into_path_buf()); let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::with_display_root( @@ -524,6 +525,7 @@ pub(crate) async fn run_turn( let stop_request = codex_hooks::StopRequest { session_id: sess.session_id().into(), turn_id: turn_context.sub_id.clone(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), transcript_path: sess.hook_transcript_path().await, model: turn_context.model_info.slug.clone(), @@ -573,6 +575,7 @@ pub(crate) async fn run_turn( .hooks() .dispatch(HookPayload { session_id: sess.session_id().into(), + #[allow(deprecated)] cwd: turn_context.cwd.clone(), client: turn_context.app_server_client_name.clone(), triggered_at: chrono::Utc::now(), @@ -697,6 +700,7 @@ async fn track_turn_resolved_config_analytics( model: turn_context.model_info.slug.clone(), model_provider: turn_context.config.model_provider_id.clone(), permission_profile: turn_context.permission_profile(), + #[allow(deprecated)] permission_profile_cwd: turn_context.cwd.to_path_buf(), reasoning_effort: turn_context.reasoning_effort, reasoning_summary: Some(turn_context.reasoning_summary), @@ -993,6 +997,7 @@ pub(crate) fn build_prompt( } #[allow(clippy::too_many_arguments)] +#[allow(deprecated)] #[instrument(level = "trace", skip_all, fields( diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 7b22ddcc39..8caec7aaea 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -69,6 +69,7 @@ pub struct TurnContext { /// 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()`. + #[deprecated(note = "use the selected turn environment cwd instead")] pub(crate) cwd: AbsolutePathBuf, pub(crate) current_date: Option, pub(crate) timezone: Option, @@ -118,6 +119,7 @@ impl TurnContext { &self.permission_profile, &file_system_sandbox_policy, network_sandbox_policy, + #[allow(deprecated)] &self.cwd, ) } @@ -253,6 +255,7 @@ impl TurnContext { session_source: self.session_source.clone(), thread_source: self.thread_source, environments: self.environments.clone(), + #[allow(deprecated)] cwd: self.cwd.clone(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -288,7 +291,9 @@ impl TurnContext { } } + #[deprecated(note = "resolve paths from the selected turn environment cwd instead")] pub(crate) fn resolve_path(&self, path: Option) -> AbsolutePathBuf { + #[allow(deprecated)] path.as_ref() .map_or_else(|| self.cwd.clone(), |path| self.cwd.join(path)) } @@ -314,6 +319,7 @@ impl TurnContext { ); FileSystemSandboxContext { permissions, + #[allow(deprecated)] cwd: Some(self.cwd.clone()), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self @@ -332,6 +338,7 @@ impl TurnContext { let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd( &self.sandbox_policy(), + #[allow(deprecated)] &self.cwd, ); let file_system_sandbox_policy = self.file_system_sandbox_policy(); @@ -349,6 +356,7 @@ impl TurnContext { TurnContextItem { turn_id: Some(self.sub_id.clone()), trace_id: self.trace_id.clone(), + #[allow(deprecated)] cwd: self.cwd.to_path_buf(), current_date: self.current_date.clone(), timezone: self.timezone.clone(), @@ -566,6 +574,7 @@ impl Session { session_source, thread_source: session_configuration.thread_source, environments, + #[allow(deprecated)] cwd, current_date: Some(current_date), timezone: Some(timezone), diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 683856b90e..23f3882eda 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -148,6 +148,7 @@ pub(crate) async fn execute_user_shell_command( let exec_command = maybe_wrap_shell_lc_with_snapshot( &display_command, session_shell.as_ref(), + #[allow(deprecated)] &turn_context.cwd, &turn_context.shell_environment_policy.r#set, &exec_env_map, @@ -155,6 +156,7 @@ pub(crate) async fn execute_user_shell_command( let call_id = Uuid::new_v4().to_string(); let raw_command = command; + #[allow(deprecated)] let cwd = turn_context.cwd.clone(); let parsed_cmd = parse_command(&display_command); diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 6dd7ea2b31..e1a37be6b6 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -68,6 +68,7 @@ pub async fn handle( } let db = required_state_db(&session)?; + #[allow(deprecated)] let input_path = turn.resolve_path(Some(args.csv_path)); let input_path_display = input_path.display().to_string(); let csv_content = tokio::fs::read_to_string(&input_path) @@ -141,7 +142,10 @@ pub async fn handle( let job_id = Uuid::new_v4().to_string(); let output_csv_path = args.output_csv_path.map_or_else( || default_output_csv_path(&input_path, job_id.as_str()), - |path| turn.resolve_path(Some(path)), + |path| { + #[allow(deprecated)] + turn.resolve_path(Some(path)) + }, ); let job_suffix = &job_id[..8]; let job_name = format!("agent-job-{job_suffix}"); diff --git a/codex-rs/core/src/tools/handlers/multi_agents_common.rs b/codex-rs/core/src/tools/handlers/multi_agents_common.rs index cc4e9ebfca..216a420e32 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_common.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_common.rs @@ -268,7 +268,9 @@ pub(crate) fn apply_spawn_agent_runtime_overrides( })?; config.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); config.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - config.cwd = turn.cwd.clone(); + #[allow(deprecated)] + let turn_cwd = turn.cwd.clone(); + config.cwd = turn_cwd; config .permissions .set_permission_profile(turn.permission_profile()) 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 f051fb0f70..1a58561737 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -2088,6 +2088,7 @@ async fn spawn_agent_reapplies_runtime_sandbox_after_role_config() { let manager = thread_manager(); session.services.agent_control = manager.agent_control(); let expected_sandbox = turn.config.legacy_sandbox_policy(); + #[allow(deprecated)] let mut expected_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&expected_sandbox, &turn.cwd); expected_file_system_sandbox_policy @@ -3767,15 +3768,20 @@ async fn build_agent_spawn_config_uses_turn_context_values() { ..ShellEnvironmentPolicy::default() }; let temp_dir = tempfile::tempdir().expect("temp dir"); - turn.cwd = temp_dir.abs(); + #[allow(deprecated)] + { + turn.cwd = temp_dir.abs(); + } turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo")); + #[allow(deprecated)] + let turn_cwd = turn.cwd.clone(); let sandbox_policy = pick_allowed_sandbox_policy( &turn.config.permissions.permission_profile, turn.config.legacy_sandbox_policy(), - turn.cwd.as_path(), + turn_cwd.as_path(), ); let file_system_sandbox_policy = - FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &turn.cwd); + FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&sandbox_policy, &turn_cwd); let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy); let permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( SandboxEnforcement::from_legacy_sandbox_policy(&sandbox_policy), @@ -3798,7 +3804,10 @@ async fn build_agent_spawn_config_uses_turn_context_values() { expected.compact_prompt = turn.compact_prompt.clone(); expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); + #[allow(deprecated)] + { + expected.cwd = turn.cwd.clone(); + } expected .permissions .approval_policy @@ -3849,7 +3858,10 @@ async fn build_agent_resume_config_clears_base_instructions() { expected.compact_prompt = turn.compact_prompt.clone(); expected.permissions.shell_environment_policy = turn.shell_environment_policy.clone(); expected.codex_linux_sandbox_exe = turn.codex_linux_sandbox_exe.clone(); - expected.cwd = turn.cwd.clone(); + #[allow(deprecated)] + { + expected.cwd = turn.cwd.clone(); + } expected .permissions .approval_policy diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 243ed52d15..5d49ad861b 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -47,6 +47,7 @@ impl ToolExecutor for RequestPermissionsHandler { } }; + #[allow(deprecated)] let mut args: RequestPermissionsArgs = parse_arguments_with_base_path(&arguments, &turn.cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index e6ce6908f3..84f400854d 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -92,6 +92,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { ))); }; + #[allow(deprecated)] let cwd = resolve_workdir_base_path(&arguments, &turn.cwd)?; let params: ShellCommandToolCallParams = parse_arguments_with_base_path(&arguments, &cwd)?; + #[allow(deprecated)] let workdir = turn.resolve_path(params.workdir.clone()); maybe_emit_implicit_skill_invocation( session.as_ref(), diff --git a/codex-rs/core/src/tools/handlers/shell_tests.rs b/codex-rs/core/src/tools/handlers/shell_tests.rs index 9db561d577..660bac6789 100644 --- a/codex-rs/core/src/tools/handlers/shell_tests.rs +++ b/codex-rs/core/src/tools/handlers/shell_tests.rs @@ -88,6 +88,7 @@ async fn shell_command_handler_to_exec_params_uses_session_shell_and_turn_contex let expected_command = session .user_shell() .derive_exec_args(&command, /*use_login_shell*/ true); + #[allow(deprecated)] let expected_cwd = turn_context.resolve_path(workdir.clone()); let expected_env = create_env( &turn_context.shell_environment_policy, diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index 3efbedc0ee..bb74adb86d 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -282,7 +282,10 @@ mod tests { let (session, mut turn) = make_session_and_context().await; let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); - turn.cwd = image_cwd.clone(); + #[allow(deprecated)] + { + turn.cwd = image_cwd.clone(); + } turn.environments .turn_environments .first_mut() diff --git a/codex-rs/core/src/tools/network_approval.rs b/codex-rs/core/src/tools/network_approval.rs index 14af2c9c5f..39441d96f5 100644 --- a/codex-rs/core/src/tools/network_approval.rs +++ b/codex-rs/core/src/tools/network_approval.rs @@ -525,6 +525,7 @@ impl NetworkApprovalService { guardian_approval_id, /*approval_id*/ None, prompt_command, + #[allow(deprecated)] turn_context.cwd.clone(), Some(prompt_reason), Some(network_approval_context.clone()), diff --git a/codex-rs/core/src/tools/orchestrator.rs b/codex-rs/core/src/tools/orchestrator.rs index 648d3f79e8..deb9ae596e 100644 --- a/codex-rs/core/src/tools/orchestrator.rs +++ b/codex-rs/core/src/tools/orchestrator.rs @@ -234,6 +234,7 @@ impl ToolOrchestrator { // Platform-specific flag gating is handled by SandboxManager::select_initial. let use_legacy_landlock = turn_ctx.features.use_legacy_landlock(); + #[allow(deprecated)] let sandbox_cwd = tool.sandbox_cwd(req).unwrap_or(&turn_ctx.cwd); let initial_attempt = SandboxAttempt { sandbox: initial_sandbox, diff --git a/codex-rs/core/src/tools/registry.rs b/codex-rs/core/src/tools/registry.rs index 85c346f48f..6486e7db71 100644 --- a/codex-rs/core/src/tools/registry.rs +++ b/codex-rs/core/src/tools/registry.rs @@ -409,6 +409,7 @@ impl ToolRegistry { "sandbox_policy", permission_profile_policy_tag( &invocation.turn.permission_profile, + #[allow(deprecated)] invocation.turn.cwd.as_path(), ), ), diff --git a/codex-rs/core/src/unified_exec/mod_tests.rs b/codex-rs/core/src/unified_exec/mod_tests.rs index f8a1480af8..6926f9cbf1 100644 --- a/codex-rs/core/src/unified_exec/mod_tests.rs +++ b/codex-rs/core/src/unified_exec/mod_tests.rs @@ -83,6 +83,7 @@ async fn exec_command_with_tty( ) -> Result { let manager = &session.services.unified_exec_manager; let process_id = manager.allocate_process_id().await; + #[allow(deprecated)] let cwd = workdir .as_ref() .map_or_else(|| turn.cwd.clone(), |workdir| turn.cwd.join(workdir)); @@ -501,10 +502,12 @@ async fn reusing_completed_process_returns_unknown_process() -> anyhow::Result<( #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn completed_pipe_commands_preserve_exit_code() -> anyhow::Result<()> { let (_, turn) = make_session_and_context().await; + #[allow(deprecated)] + let cwd = turn.cwd.clone(); let request = test_exec_request( &turn, vec!["bash".to_string(), "-lc".to_string(), "exit 17".to_string()], - turn.cwd.clone(), + cwd, shell_env(), ); @@ -598,10 +601,12 @@ async fn remote_exec_server_rejects_inherited_fd_launches() -> anyhow::Result<() turn.environments.turn_environments[0].environment = Arc::new(remote_test_env.environment().clone()); + #[allow(deprecated)] + let cwd = turn.cwd.clone(); let request = test_exec_request( &turn, vec!["bash".to_string(), "-lc".to_string(), "echo ok".to_string()], - turn.cwd.clone(), + cwd, shell_env(), ); diff --git a/codex-rs/core/src/unified_exec/process_manager_tests.rs b/codex-rs/core/src/unified_exec/process_manager_tests.rs index 5ef5994030..87d9553dd3 100644 --- a/codex-rs/core/src/unified_exec/process_manager_tests.rs +++ b/codex-rs/core/src/unified_exec/process_manager_tests.rs @@ -175,7 +175,9 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { process_id: 123, yield_time_ms: 1000, max_output_tokens: None, + #[allow(deprecated)] cwd: turn.cwd.clone(), + #[allow(deprecated)] sandbox_cwd: turn.cwd.clone(), environment: turn .environments @@ -200,6 +202,7 @@ async fn failed_initial_end_for_unstored_process_uses_fallback_output() { /*process_started_alive*/ false, &context, &request, + #[allow(deprecated)] turn.cwd.clone(), transcript, "PRE_DENIAL_MARKER".to_string(), From 1ae811ddb263cda3d7b6b074e00691f5324c14c6 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 13 May 2026 11:26:37 -0700 Subject: [PATCH 03/47] Refactor chatwidget settings surfaces into modules (phase 4) (#22518) ## Why `chatwidget.rs` is still carrying too many unrelated responsibilities in one file. #22269 started a five-phase cleanup to move coherent behavior domains into focused modules while keeping `chatwidget.rs` as the composition layer. #22407 completed phase 2 by extracting input and submission flow, and #22433 completed phase 3 by extracting protocol, replay, streaming, and tool lifecycle handling. This PR is phase 4. It keeps moving high-churn UI coordination out of the central widget by extracting settings, popups, and status surfaces without changing the visible behavior those flows already provide. This is once again a mechanical movement of existing functions. No functional changes. ## What Changed - Added focused modules for runtime settings/model coordination, model/reasoning/collaboration popups, settings/personality/theme/audio/experimental popups, permission prompts, status setup/output controls, and Windows sandbox prompt flows. - Moved the remaining rate-limit nudge/status helpers and connectors popup/loading/update helpers into their existing focused modules. - Preserved the existing picker flows, approval behavior, status/title setup previews, rate-limit notices, and connectors/app list behavior while shrinking `chatwidget.rs` back toward orchestration. - Left `codex-rs/tui/src/chatwidget.rs` as the registration and composition surface for these extracted behaviors. ## Cleanup Phases The five-phase cleanup plan from #22269 is: 1. Phase 1: mechanical helper and state moves. Completed in #22269. 2. Phase 2: extract input and submission flow, including queued user messages, shell prompt submission, pending steer restoration, and thread input snapshot/restore behavior. Completed in #22407. 3. Phase 3: extract protocol, replay, streaming, and tool lifecycle handling, while preserving active-cell grouping, transcript invalidation, interrupt deferral, and final-message separator behavior. Completed in #22433. 4. Phase 4: extract settings, popups, and status surfaces, including model/reasoning/collaboration/personality popups, permission prompts, rate-limit UI, and connectors helpers. This PR. 5. Phase 5: clean up the remaining constructor and orchestration code once the larger behavior domains have moved out, leaving `chatwidget.rs` as the composition layer. ## Verification - `cargo check -p codex-tui` - `cargo test -p codex-tui chatwidget::tests::permissions` - `cargo test -p codex-tui chatwidget::tests::status_surface_previews` - `cargo test -p codex-tui chatwidget::tests::popups_and_settings` - `cargo test -p codex-tui chatwidget::tests::status_and_layout` `cargo test -p codex-tui` also compiles and begins running, but aborts in the unchanged app-side test `app::tests::discard_side_thread_keeps_local_state_when_server_close_fails` with a reproducible stack overflow. --- codex-rs/tui/src/chatwidget.rs | 3454 +---------------- codex-rs/tui/src/chatwidget/connectors.rs | 415 +- codex-rs/tui/src/chatwidget/model_popups.rs | 583 +++ .../tui/src/chatwidget/permission_popups.rs | 463 +++ codex-rs/tui/src/chatwidget/rate_limits.rs | 327 +- codex-rs/tui/src/chatwidget/settings.rs | 611 +++ .../tui/src/chatwidget/settings_popups.rs | 285 ++ .../tui/src/chatwidget/status_controls.rs | 380 ++ .../chatwidget/tests/popups_and_settings.rs | 2 + .../src/chatwidget/tests/status_and_layout.rs | 1 + .../src/chatwidget/windows_sandbox_prompts.rs | 446 +++ 11 files changed, 3517 insertions(+), 3450 deletions(-) create mode 100644 codex-rs/tui/src/chatwidget/model_popups.rs create mode 100644 codex-rs/tui/src/chatwidget/permission_popups.rs create mode 100644 codex-rs/tui/src/chatwidget/settings.rs create mode 100644 codex-rs/tui/src/chatwidget/settings_popups.rs create mode 100644 codex-rs/tui/src/chatwidget/status_controls.rs create mode 100644 codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 6723577754..f8bb6cd778 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -252,7 +252,6 @@ fn queued_message_edit_hint_binding( } use crate::app_event::AppEvent; -use crate::app_event::ConnectorsSnapshot; use crate::app_event::ExitMode; use crate::app_event::RateLimitRefreshOrigin; #[cfg(target_os = "windows")] @@ -318,7 +317,6 @@ use crate::text_formatting::truncate_text; use crate::tui::FrameRequester; mod command_lifecycle; mod connectors; -use self::connectors::ConnectorsCacheState; use self::connectors::ConnectorsState; mod exec_state; use self::exec_state::RunningCommand; @@ -362,11 +360,11 @@ use self::plugins::PluginListFetchState; use self::plugins::PluginsCacheState; mod plan_implementation; use self::plan_implementation::PLAN_IMPLEMENTATION_TITLE; +mod model_popups; +mod permission_popups; mod protocol; mod protocol_requests; mod rate_limits; -use self::rate_limits::NUDGE_MODEL_SLUG; -use self::rate_limits::RATE_LIMIT_SWITCH_PROMPT_THRESHOLD; use self::rate_limits::RateLimitErrorKind; use self::rate_limits::RateLimitSwitchPromptState; use self::rate_limits::RateLimitWarningState; @@ -380,11 +378,15 @@ mod reasoning_shortcuts; mod review; use self::review::ReviewState; mod service_tiers; +mod settings; +mod settings_popups; mod side; mod status_state; +mod windows_sandbox_prompts; use self::status_state::StatusIndicatorState; use self::status_state::StatusState; use self::status_state::TerminalTitleStatusKind; +mod status_controls; mod status_surfaces; mod streaming; use self::status_surfaces::CachedProjectRootName; @@ -896,187 +898,6 @@ impl ChatWidget { self.realtime_conversation_enabled() } - /// Update the status indicator header and details. - /// - /// Passing `None` clears any existing details. - fn set_status( - &mut self, - header: String, - details: Option, - details_capitalization: StatusDetailsCapitalization, - details_max_lines: usize, - ) { - let details = details - .filter(|details| !details.is_empty()) - .map(|details| { - let trimmed = details.trim_start(); - match details_capitalization { - StatusDetailsCapitalization::CapitalizeFirst => { - crate::text_formatting::capitalize_first(trimmed) - } - StatusDetailsCapitalization::Preserve => trimmed.to_string(), - } - }); - self.status_state.set_status(StatusIndicatorState { - header: header.clone(), - details: details.clone(), - details_max_lines, - }); - self.bottom_pane.update_status( - header, - details, - StatusDetailsCapitalization::Preserve, - details_max_lines, - ); - let title_uses_status = self - .config - .tui_terminal_title - .as_ref() - .is_some_and(|items| { - items - .iter() - .any(|item| item == "run-state" || item == "status") - }); - if title_uses_status { - self.refresh_status_surfaces(); - } - } - - /// Convenience wrapper around [`Self::set_status`]; - /// updates the status indicator header and clears any existing details. - fn set_status_header(&mut self, header: String) { - self.set_status( - header, - /*details*/ None, - StatusDetailsCapitalization::CapitalizeFirst, - STATUS_DETAILS_DEFAULT_MAX_LINES, - ); - } - - /// Sets the currently rendered footer status-line value. - pub(crate) fn set_status_line(&mut self, status_line: Option>) { - self.bottom_pane.set_status_line(status_line); - } - - /// Sets the terminal hyperlink target for the currently rendered footer status line. - pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { - self.bottom_pane.set_status_line_hyperlink(url); - } - - /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. - /// - /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the - /// user actually looking at?" and the footer stack remains a pure renderer of that decision. - pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { - self.bottom_pane.set_active_agent_label(active_agent_label); - } - - /// Recomputes footer status-line content from config and current runtime state. - /// - /// This method is the status-line orchestrator: it parses configured item identifiers, - /// warns once per session about invalid items, updates whether status-line mode is enabled, - /// schedules async git-branch lookup when needed, and renders only values that are currently - /// available. - /// - /// The omission behavior is intentional. If selected items are unavailable (for example before - /// a session id exists or before branch lookup completes), those items are skipped without - /// placeholders so the line remains compact and stable. - pub(crate) fn refresh_status_line(&mut self) { - self.refresh_status_surfaces(); - } - - /// Records that status-line setup was canceled. - /// - /// Cancellation is intentionally side-effect free for config state; the existing configuration - /// remains active and no persistence is attempted. - pub(crate) fn cancel_status_line_setup(&self) { - tracing::info!("Status line setup canceled by user"); - } - - /// Applies status-line item selection from the setup view to in-memory config. - /// - /// An empty selection persists as an explicit empty list. - pub(crate) fn setup_status_line(&mut self, items: Vec, use_theme_colors: bool) { - tracing::info!( - "status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}" - ); - let ids = items.iter().map(ToString::to_string).collect::>(); - self.config.tui_status_line = Some(ids); - self.config.tui_status_line_use_colors = use_theme_colors; - self.refresh_status_line(); - } - - /// Applies a temporary terminal-title selection while the setup UI is open. - pub(crate) fn preview_terminal_title(&mut self, items: Vec) { - if self.terminal_title_setup_original_items.is_none() { - self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); - } - - let ids = items.iter().map(ToString::to_string).collect::>(); - self.config.tui_terminal_title = Some(ids); - self.refresh_terminal_title(); - } - - /// Restores the terminal-title config that was active before the setup UI - /// opened, undoing any preview changes. No-op if no setup session is active. - pub(crate) fn revert_terminal_title_setup_preview(&mut self) { - let Some(original_items) = self.terminal_title_setup_original_items.take() else { - return; - }; - - self.config.tui_terminal_title = original_items; - self.refresh_terminal_title(); - } - - /// Dismisses the terminal-title setup UI and reverts to the pre-setup config. - pub(crate) fn cancel_terminal_title_setup(&mut self) { - tracing::info!("Terminal title setup canceled by user"); - self.revert_terminal_title_setup_preview(); - } - - /// Commits a confirmed terminal-title selection, ending the setup session. - /// - /// After this call, `revert_terminal_title_setup_preview` becomes a no-op - /// because the original config snapshot is discarded. - pub(crate) fn setup_terminal_title(&mut self, items: Vec) { - tracing::info!("terminal title setup confirmed with items: {items:#?}"); - let ids = items.iter().map(ToString::to_string).collect::>(); - self.terminal_title_setup_original_items = None; - self.config.tui_terminal_title = Some(ids); - self.refresh_terminal_title(); - } - - /// Stores async git-branch lookup results for the current status-line cwd. - /// - /// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch - /// names after directory changes. - pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option) { - if self.status_line_branch_cwd.as_ref() != Some(&cwd) { - self.status_line_branch_pending = false; - return; - } - self.status_line_branch = branch; - self.status_line_branch_pending = false; - self.status_line_branch_lookup_complete = true; - self.refresh_status_surfaces(); - } - - /// Stores async Git summary lookup results for the current status-line cwd. - pub(crate) fn set_status_line_git_summary( - &mut self, - cwd: PathBuf, - summary: StatusLineGitSummary, - ) { - if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) { - self.status_line_git_summary_pending = false; - return; - } - self.status_line_git_summary = Some(summary); - self.status_line_git_summary_pending = false; - self.status_line_git_summary_lookup_complete = true; - self.refresh_status_surfaces(); - } - fn restore_retry_status_header_if_present(&mut self) { if let Some(header) = self.status_state.take_retry_status_header() { self.set_status_header(header); @@ -1487,107 +1308,6 @@ impl ChatWidget { } } - pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { - if let Some(mut snapshot) = snapshot { - let limit_id = snapshot - .limit_id - .clone() - .unwrap_or_else(|| "codex".to_string()); - let limit_label = snapshot - .limit_name - .clone() - .unwrap_or_else(|| limit_id.clone()); - if snapshot.credits.is_none() { - snapshot.credits = self - .rate_limit_snapshots_by_limit_id - .get(&limit_id) - .and_then(|display| display.credits.as_ref()) - .map(|credits| CreditsSnapshot { - has_credits: credits.has_credits, - unlimited: credits.unlimited, - balance: credits.balance.clone(), - }); - } - - self.plan_type = snapshot.plan_type.or(self.plan_type); - - let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); - if is_codex_limit - && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type - { - self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); - } - let warnings = if is_codex_limit { - self.rate_limit_warnings.take_warnings( - snapshot - .secondary - .as_ref() - .map(|window| f64::from(window.used_percent)), - snapshot - .secondary - .as_ref() - .and_then(|window| window.window_duration_mins), - snapshot - .primary - .as_ref() - .map(|window| f64::from(window.used_percent)), - snapshot - .primary - .as_ref() - .and_then(|window| window.window_duration_mins), - ) - } else { - vec![] - }; - - let high_usage = is_codex_limit - && (snapshot - .secondary - .as_ref() - .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false) - || snapshot - .primary - .as_ref() - .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) - .unwrap_or(false)); - - let has_workspace_credits = snapshot - .credits - .as_ref() - .map(|credits| credits.has_credits) - .unwrap_or(false); - - if high_usage - && !has_workspace_credits - && !self.rate_limit_switch_prompt_hidden() - && self.current_model() != NUDGE_MODEL_SLUG - && !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Shown - ) - { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; - } - - let display = - rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); - self.rate_limit_snapshots_by_limit_id - .insert(limit_id, display); - - if !warnings.is_empty() { - for warning in warnings { - self.add_to_history(history_cell::new_warning_event(warning)); - } - self.request_redraw(); - } - } else { - self.rate_limit_snapshots_by_limit_id.clear(); - self.codex_rate_limit_reached_type = None; - } - self.refresh_status_line(); - } - pub(crate) fn handle_history_entry_response(&mut self, event: HistoryLookupResponse) { let HistoryLookupResponse { offset, @@ -2399,91 +2119,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_status_output( - &mut self, - refreshing_rate_limits: bool, - request_id: Option, - ) { - let default_usage = TokenUsage::default(); - let token_info = self.token_info.as_ref(); - let total_usage = token_info - .map(|ti| &ti.total_token_usage) - .unwrap_or(&default_usage); - let collaboration_mode = self.collaboration_mode_label(); - let model = self.current_model().to_string(); - let model_default_reasoning_effort = - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.default_reasoning_effort) - }); - let reasoning_effort_override = Some( - self.effective_reasoning_effort() - .or(self.config.model_reasoning_effort) - .or(model_default_reasoning_effort), - ); - let rate_limit_snapshots: Vec = self - .rate_limit_snapshots_by_limit_id - .values() - .cloned() - .collect(); - let agents_summary = - crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); - let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( - &self.config, - self.runtime_model_provider_base_url.as_deref(), - self.status_account_display.as_ref(), - token_info, - total_usage, - &self.thread_id, - self.thread_name.clone(), - self.forked_from, - rate_limit_snapshots.as_slice(), - self.plan_type, - Local::now(), - self.model_display_name(), - collaboration_mode, - reasoning_effort_override, - agents_summary, - refreshing_rate_limits, - ); - if let Some(request_id) = request_id { - self.refreshing_status_outputs.push((request_id, handle)); - } - self.add_to_history(cell); - } - - pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { - if self.refreshing_status_outputs.is_empty() { - return; - } - - let rate_limit_snapshots: Vec = self - .rate_limit_snapshots_by_limit_id - .values() - .cloned() - .collect(); - let now = Local::now(); - let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len()); - let mut updated_any = false; - for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) { - if pending_request_id == request_id { - updated_any = true; - handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now); - } else { - remaining.push((pending_request_id, handle)); - } - } - self.refreshing_status_outputs = remaining; - if updated_any { - self.request_redraw(); - } - } - pub(crate) fn add_debug_config_output(&mut self) { self.add_to_history(crate::debug_config::new_debug_config_output( &self.config, @@ -2491,122 +2126,6 @@ impl ChatWidget { )); } - fn open_status_line_setup(&mut self) { - let configured_status_line_items = self.configured_status_line_items(); - let view = StatusLineSetupView::new( - Some(configured_status_line_items.as_slice()), - self.config.tui_status_line_use_colors, - self.status_surface_preview_data(), - self.app_event_tx.clone(), - self.bottom_pane.list_keymap(), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - fn open_terminal_title_setup(&mut self) { - let configured_terminal_title_items = self.configured_terminal_title_items(); - self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); - let view = TerminalTitleSetupView::new( - Some(configured_terminal_title_items.as_slice()), - self.terminal_title_preview_data(), - self.app_event_tx.clone(), - self.bottom_pane.list_keymap(), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { - StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { - self.status_surface_preview_value_for_item(item) - .map(|value| (item, value)) - })) - } - - fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { - let mut preview_data = self.status_surface_preview_data(); - let now = Instant::now(); - for item in TerminalTitleItem::iter() { - let Some(preview_item) = item.preview_item() else { - continue; - }; - let Some(value) = self.terminal_title_value_for_item(item, now) else { - continue; - }; - preview_data.set_live(preview_item, value); - } - preview_data - } - fn open_theme_picker(&mut self) { - let codex_home = crate::legacy_core::config::find_codex_home().ok(); - let terminal_width = self - .last_rendered_width - .get() - .and_then(|width| u16::try_from(width).ok()); - let params = crate::theme_picker::build_theme_picker_params( - self.config.tui_theme.as_deref(), - codex_home.as_deref(), - terminal_width, - ); - self.bottom_pane.show_selection_view(params); - } - - fn status_line_context_window_size(&self) -> Option { - self.token_info - .as_ref() - .and_then(|info| info.model_context_window) - .or(self.config.model_context_window) - } - - fn status_line_context_remaining_percent(&self) -> Option { - let Some(context_window) = self.status_line_context_window_size() else { - return Some(100); - }; - let default_usage = TokenUsage::default(); - let usage = self - .token_info - .as_ref() - .map(|info| &info.last_token_usage) - .unwrap_or(&default_usage); - Some( - usage - .percent_of_context_window_remaining(context_window) - .clamp(0, 100), - ) - } - - fn status_line_context_used_percent(&self) -> Option { - let remaining = self.status_line_context_remaining_percent().unwrap_or(100); - Some((100 - remaining).clamp(0, 100)) - } - - fn status_line_total_usage(&self) -> TokenUsage { - self.token_info - .as_ref() - .map(|info| info.total_token_usage.clone()) - .unwrap_or_default() - } - - fn status_line_limit_display( - &self, - window: Option<&RateLimitWindowDisplay>, - label: &str, - ) -> Option { - let window = window?; - let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64); - Some(format!("{label} {remaining:.0}%")) - } - - fn status_line_reasoning_effort_label(effort: Option) -> &'static str { - match effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", - } - } - pub(crate) fn add_ps_output(&mut self) { let processes = self .unified_exec_processes @@ -2629,2659 +2148,6 @@ impl ChatWidget { ); } - fn stop_rate_limit_poller(&mut self) {} - - pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { - self.prefetch_connectors_with_options(force_refetch); - } - - fn prefetch_connectors(&mut self) { - self.prefetch_connectors_with_options(/*force_refetch*/ false); - } - - fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { - if !self.connectors_enabled() { - return; - } - if self.connectors.prefetch_in_flight { - if force_refetch { - self.connectors.force_refetch_pending = true; - } - return; - } - - self.connectors.prefetch_in_flight = true; - if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) { - self.connectors.cache = ConnectorsCacheState::Loading; - } - - let config = self.config.clone(); - let environment_manager = Arc::clone(&self.environment_manager); - let app_event_tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let accessible_result = - match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( - &config, - force_refetch, - &environment_manager, - ) - .await - { - Ok(connectors) => connectors, - Err(err) => { - app_event_tx.send(AppEvent::ConnectorsLoaded { - result: Err(format!("Failed to load apps: {err}")), - is_final: true, - }); - return; - } - }; - let should_schedule_force_refetch = - !force_refetch && !accessible_result.codex_apps_ready; - let accessible_connectors = accessible_result.connectors; - - app_event_tx.send(AppEvent::ConnectorsLoaded { - result: Ok(ConnectorsSnapshot { - connectors: accessible_connectors.clone(), - }), - is_final: false, - }); - - let result: Result = async { - let all_connectors = - chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) - .await?; - let connectors = chatgpt_connectors::merge_connectors_with_accessible( - all_connectors, - accessible_connectors, - /*all_connectors_loaded*/ true, - ); - Ok(ConnectorsSnapshot { connectors }) - } - .await - .map_err(|err: anyhow::Error| format!("Failed to load apps: {err}")); - - app_event_tx.send(AppEvent::ConnectorsLoaded { - result, - is_final: true, - }); - - if should_schedule_force_refetch { - app_event_tx.send(AppEvent::RefreshConnectors { - force_refetch: true, - }); - } - }); - } - - #[cfg_attr(not(test), allow(dead_code))] - fn prefetch_rate_limits(&mut self) { - self.stop_rate_limit_poller(); - } - - #[cfg_attr(not(test), allow(dead_code))] - fn should_prefetch_rate_limits(&self) -> bool { - self.config.model_provider.requires_openai_auth && self.has_chatgpt_account - } - - fn lower_cost_preset(&self) -> Option { - let models = self.model_catalog.try_list_models().ok()?; - models - .iter() - .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) - .cloned() - } - - fn rate_limit_switch_prompt_hidden(&self) -> bool { - self.config - .notices - .hide_rate_limit_model_nudge - .unwrap_or(false) - } - - fn maybe_show_pending_rate_limit_prompt(&mut self) { - if self.rate_limit_switch_prompt_hidden() { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - return; - } - if !matches!( - self.rate_limit_switch_prompt, - RateLimitSwitchPromptState::Pending - ) { - return; - } - if let Some(preset) = self.lower_cost_preset() { - self.open_rate_limit_switch_prompt(preset); - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; - } else { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { - let switch_model = preset.model; - let switch_model_for_events = switch_model.clone(); - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - - let switch_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*windows_sandbox_level*/ None, - Some(switch_model_for_events.clone()), - Some(Some(default_effort)), - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ))); - tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); - tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); - })]; - - let keep_actions: Vec = Vec::new(); - let never_actions: Vec = vec![Box::new(|tx| { - tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); - tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); - })]; - let description = if preset.description.is_empty() { - Some("Uses fewer credits for upcoming turns.".to_string()) - } else { - Some(preset.description) - }; - - let items = vec![ - SelectionItem { - name: format!("Switch to {switch_model}"), - description, - selected_description: None, - is_current: false, - actions: switch_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model".to_string(), - description: None, - selected_description: None, - is_current: false, - actions: keep_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Keep current model (never show again)".to_string(), - description: Some( - "Hide future rate limit reminders about switching models.".to_string(), - ), - selected_description: None, - is_current: false, - actions: never_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Approaching rate limits".to_string()), - subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn open_workspace_owner_nudge_prompt(&mut self, credit_type: AddCreditsNudgeCreditType) { - if self.add_credits_nudge_email_in_flight.is_some() { - return; - } - - let (title, prompt) = match credit_type { - AddCreditsNudgeCreditType::Credits => ( - "You've reached your workspace credit limit", - "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?", - ), - AddCreditsNudgeCreditType::UsageLimit => ( - "Usage limit reached", - "Request a limit increase from your owner to continue using codex. Request increase?", - ), - }; - let send_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); - })]; - let items = vec![ - SelectionItem { - name: "Yes".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), - actions: send_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "No".to_string(), - display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), - is_default: true, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some(title.to_string()), - subtitle: Some(prompt.to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - initial_selected_idx: Some(1), - ..Default::default() - }); - } - - pub(crate) fn start_add_credits_nudge_email_request( - &mut self, - credit_type: AddCreditsNudgeCreditType, - ) -> bool { - self.add_credits_nudge_email_in_flight = Some(credit_type); - true - } - - pub(crate) fn finish_add_credits_nudge_email_request( - &mut self, - result: Result, - ) { - let credit_type = self - .add_credits_nudge_email_in_flight - .take() - .unwrap_or(AddCreditsNudgeCreditType::Credits); - let message = match (credit_type, result) { - (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { - "Workspace owner notified." - } - ( - AddCreditsNudgeCreditType::Credits, - Ok(AddCreditsNudgeEmailStatus::CooldownActive), - ) => "Workspace owner was already notified recently.", - (AddCreditsNudgeCreditType::Credits, Err(_)) => { - "Could not notify your workspace owner. Please try again." - } - (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => { - "Limit increase requested." - } - ( - AddCreditsNudgeCreditType::UsageLimit, - Ok(AddCreditsNudgeEmailStatus::CooldownActive), - ) => "A limit increase was already requested recently.", - (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => { - "Could not request a limit increase. Please try again." - } - }; - self.add_to_history(history_cell::new_info_event( - message.to_string(), - /*hint*/ None, - )); - self.request_redraw(); - } - - /// Open a popup to choose a quick auto model. Selecting "All models" - /// opens the full picker with every available preset. - pub(crate) fn open_model_popup(&mut self) { - if !self.is_session_configured() { - self.add_info_message( - "Model selection is disabled until startup completes.".to_string(), - /*hint*/ None, - ); - return; - } - - let presets: Vec = match self.model_catalog.try_list_models() { - Ok(models) => models, - Err(_) => { - self.add_info_message( - "Models are being updated; please try /model again in a moment.".to_string(), - /*hint*/ None, - ); - return; - } - }; - self.open_model_popup_with_presets(presets); - } - - pub(crate) fn open_personality_popup(&mut self) { - if !self.is_session_configured() { - self.add_info_message( - "Personality selection is disabled until startup completes.".to_string(), - /*hint*/ None, - ); - return; - } - if !self.current_model_supports_personality() { - let current_model = self.current_model(); - self.add_error_message(format!( - "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." - )); - return; - } - self.open_personality_popup_for_current_model(); - } - - fn open_personality_popup_for_current_model(&mut self) { - let current_personality = self.config.personality.unwrap_or(Personality::Friendly); - let personalities = [Personality::Friendly, Personality::Pragmatic]; - let supports_personality = self.current_model_supports_personality(); - - let items: Vec = personalities - .into_iter() - .map(|personality| { - let name = Self::personality_label(personality).to_string(); - let description = Some(Self::personality_description(personality).to_string()); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - /*approval_policy*/ None, - /*approvals_reviewer*/ None, - /*permission_profile*/ None, - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - Some(personality), - ))); - tx.send(AppEvent::UpdatePersonality(personality)); - tx.send(AppEvent::PersistPersonalitySelection { personality }); - })]; - SelectionItem { - name, - description, - is_current: current_personality == personality, - is_disabled: !supports_personality, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - let mut header = ColumnRenderable::new(); - header.push(Line::from("Select Personality".bold())); - header.push(Line::from("Choose a communication style for Codex.".dim())); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - pub(crate) fn open_realtime_audio_popup(&mut self) { - let items = [ - RealtimeAudioDeviceKind::Microphone, - RealtimeAudioDeviceKind::Speaker, - ] - .into_iter() - .map(|kind| { - let description = Some(format!( - "Current: {}", - self.current_realtime_audio_selection_label(kind) - )); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); - })]; - SelectionItem { - name: kind.title().to_string(), - description, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Settings".to_string()), - subtitle: Some("Configure settings for Codex.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - #[cfg(not(target_os = "linux"))] - pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { - match list_realtime_audio_device_names(kind) { - Ok(device_names) => { - self.open_realtime_audio_device_selection_with_names(kind, device_names); - } - Err(err) => { - self.add_error_message(format!( - "Failed to load realtime {} devices: {err}", - kind.noun() - )); - } - } - } - - #[cfg(target_os = "linux")] - pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { - let _ = kind; - } - - #[cfg(not(target_os = "linux"))] - fn open_realtime_audio_device_selection_with_names( - &mut self, - kind: RealtimeAudioDeviceKind, - device_names: Vec, - ) { - let current_selection = self.current_realtime_audio_device_name(kind); - let current_available = current_selection - .as_deref() - .is_some_and(|name| device_names.iter().any(|device_name| device_name == name)); - let mut items = vec![SelectionItem { - name: "System default".to_string(), - description: Some("Use your operating system default device.".to_string()), - is_current: current_selection.is_none(), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None }); - })], - dismiss_on_select: true, - ..Default::default() - }]; - - if let Some(selection) = current_selection.as_deref() - && !current_available - { - items.push(SelectionItem { - name: format!("Unavailable: {selection}"), - description: Some("Configured device is not currently available.".to_string()), - is_current: true, - is_disabled: true, - disabled_reason: Some("Reconnect the device or choose another one.".to_string()), - ..Default::default() - }); - } - - items.extend(device_names.into_iter().map(|device_name| { - let persisted_name = device_name.clone(); - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { - kind, - name: Some(persisted_name.clone()), - }); - })]; - SelectionItem { - is_current: current_selection.as_deref() == Some(device_name.as_str()), - name: device_name, - actions, - dismiss_on_select: true, - ..Default::default() - } - })); - - let mut header = ColumnRenderable::new(); - header.push(Line::from(format!("Select {}", kind.title()).bold())); - header.push(Line::from( - "Saved devices apply to realtime voice only.".dim(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) { - let restart_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::RestartRealtimeAudioDevice { kind }); - })]; - let items = vec![ - SelectionItem { - name: "Restart now".to_string(), - description: Some(format!("Restart local {} audio now.", kind.noun())), - actions: restart_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Apply later".to_string(), - description: Some(format!( - "Keep the current {} until local audio starts again.", - kind.noun() - )), - dismiss_on_select: true, - ..Default::default() - }, - ]; - - let mut header = ColumnRenderable::new(); - header.push(Line::from(format!("Restart {} now?", kind.title()).bold())); - header.push(Line::from( - "Configuration is saved. Restart local audio to use it immediately.".dim(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { - let title = title.to_string(); - let subtitle = subtitle.to_string(); - let mut header = ColumnRenderable::new(); - header.push(Line::from(title.bold())); - header.push(Line::from(subtitle.dim())); - if let Some(warning) = self.model_menu_warning_line() { - header.push(warning); - } - Box::new(header) - } - - fn model_menu_warning_line(&self) -> Option> { - let base_url = self.custom_openai_base_url()?; - let warning = format!( - "Warning: OpenAI base URL is overridden to {base_url}. Selecting models may not be supported or work properly." - ); - Some(Line::from(warning.red())) - } - - fn custom_openai_base_url(&self) -> Option { - if !self.config.model_provider.is_openai() { - return None; - } - - let base_url = self.config.model_provider.base_url.as_ref()?; - let trimmed = base_url.trim(); - if trimmed.is_empty() { - return None; - } - - let normalized = trimmed.trim_end_matches('/'); - if normalized == DEFAULT_OPENAI_BASE_URL { - return None; - } - - Some(trimmed.to_string()) - } - - pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { - let presets: Vec = presets - .into_iter() - .filter(|preset| preset.show_in_picker) - .collect(); - - let current_model = self.current_model(); - let current_label = presets - .iter() - .find(|preset| preset.model.as_str() == current_model) - .map(|preset| preset.model.to_string()) - .unwrap_or_else(|| self.model_display_name().to_string()); - - let (mut auto_presets, other_presets): (Vec, Vec) = presets - .into_iter() - .partition(|preset| Self::is_auto_model(&preset.model)); - - if auto_presets.is_empty() { - self.open_all_models_popup(other_presets); - return; - } - - auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); - let mut items: Vec = auto_presets - .into_iter() - .map(|preset| { - let description = - (!preset.description.is_empty()).then_some(preset.description.clone()); - let model = preset.model.clone(); - let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( - model.as_str(), - Some(preset.default_reasoning_effort), - ); - let actions = Self::model_selection_actions( - model.clone(), - Some(preset.default_reasoning_effort), - should_prompt_plan_mode_scope, - ); - SelectionItem { - name: model.clone(), - description, - is_current: model.as_str() == current_model, - is_default: preset.is_default, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - if !other_presets.is_empty() { - let all_models = other_presets; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenAllModelsPopup { - models: all_models.clone(), - }); - })]; - - let is_current = !items.iter().any(|item| item.is_current); - let description = Some(format!( - "Choose a specific model and reasoning level (current: {current_label})" - )); - - items.push(SelectionItem { - name: "All models".to_string(), - description, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let header = self.model_menu_header( - "Select Model", - "Pick a quick auto mode or browse all models.", - ); - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header, - ..Default::default() - }); - } - - fn is_auto_model(model: &str) -> bool { - model.starts_with("codex-auto-") - } - - fn auto_model_order(model: &str) -> usize { - match model { - "codex-auto-fast" => 0, - "codex-auto-balanced" => 1, - "codex-auto-thorough" => 2, - _ => 3, - } - } - - pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { - if presets.is_empty() { - self.add_info_message( - "No additional models are available right now.".to_string(), - /*hint*/ None, - ); - return; - } - - let mut items: Vec = Vec::new(); - for preset in presets.into_iter() { - let description = - (!preset.description.is_empty()).then_some(preset.description.to_string()); - let is_current = preset.model.as_str() == self.current_model(); - let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; - let preset_for_action = preset.clone(); - let actions: Vec = vec![Box::new(move |tx| { - let preset_for_event = preset_for_action.clone(); - tx.send(AppEvent::OpenReasoningPopup { - model: preset_for_event, - }); - })]; - items.push(SelectionItem { - name: preset.model.clone(), - description, - is_current, - is_default: preset.is_default, - actions, - dismiss_on_select: single_supported_effort, - dismiss_parent_on_child_accept: !single_supported_effort, - ..Default::default() - }); - } - - let header = self.model_menu_header( - "Select Model and Effort", - "Access legacy models by running codex -m or in your config.toml", - ); - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), - items, - header, - ..Default::default() - }); - } - - pub(crate) fn open_collaboration_modes_popup(&mut self) { - let presets = collaboration_modes::presets_for_tui(self.model_catalog.as_ref()); - if presets.is_empty() { - self.add_info_message( - "No collaboration modes are available right now.".to_string(), - /*hint*/ None, - ); - return; - } - - let current_kind = self - .active_collaboration_mask - .as_ref() - .and_then(|mask| mask.mode) - .or_else(|| { - collaboration_modes::default_mask(self.model_catalog.as_ref()) - .and_then(|mask| mask.mode) - }); - let items: Vec = presets - .into_iter() - .map(|mask| { - let name = mask.name.clone(); - let is_current = current_kind == mask.mode; - let actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); - })]; - SelectionItem { - name, - is_current, - actions, - dismiss_on_select: true, - ..Default::default() - } - }) - .collect(); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Select Collaboration Mode".to_string()), - subtitle: Some("Pick a collaboration preset.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }); - } - - fn model_selection_actions( - model_for_action: String, - effort_for_action: Option, - should_prompt_plan_mode_scope: bool, - ) -> Vec { - vec![Box::new(move |tx| { - if should_prompt_plan_mode_scope { - tx.send(AppEvent::OpenPlanReasoningScopePrompt { - model: model_for_action.clone(), - effort: effort_for_action, - }); - return; - } - - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: effort_for_action, - }); - })] - } - - fn should_prompt_plan_mode_reasoning_scope( - &self, - selected_model: &str, - selected_effort: Option, - ) -> bool { - if !self.collaboration_modes_enabled() - || self.active_mode_kind() != ModeKind::Plan - || selected_model != self.current_model() - { - return false; - } - - // Prompt whenever the selection is not a true no-op for both: - // 1) the active Plan-mode effective reasoning, and - // 2) the stored global defaults that would be updated by the fallback path. - selected_effort != self.effective_reasoning_effort() - || selected_model != self.current_collaboration_mode.model() - || selected_effort != self.current_collaboration_mode.reasoning_effort() - } - - pub(crate) fn open_plan_reasoning_scope_prompt( - &mut self, - model: String, - effort: Option, - ) { - let reasoning_phrase = match effort { - Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), - Some(selected_effort) => { - format!( - "{} reasoning", - Self::reasoning_effort_label(selected_effort).to_lowercase() - ) - } - None => "the selected reasoning".to_string(), - }; - let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); - let plan_reasoning_source = if let Some(plan_override) = - self.config.plan_mode_reasoning_effort - { - format!( - "user-chosen Plan override ({})", - Self::reasoning_effort_label(plan_override).to_lowercase() - ) - } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) - { - match plan_mask.reasoning_effort.flatten() { - Some(plan_effort) => format!( - "built-in Plan default ({})", - Self::reasoning_effort_label(plan_effort).to_lowercase() - ), - None => "built-in Plan default (no reasoning)".to_string(), - } - } else { - "built-in Plan default".to_string() - }; - let all_modes_description = format!( - "Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}." - ); - let subtitle = format!("Choose where to apply {reasoning_phrase}."); - - let plan_only_actions: Vec = vec![Box::new({ - let model = model.clone(); - move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); - } - })]; - let all_modes_actions: Vec = vec![Box::new(move |tx| { - tx.send(AppEvent::UpdateModel(model.clone())); - tx.send(AppEvent::UpdateReasoningEffort(effort)); - tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); - tx.send(AppEvent::PersistModelSelection { - model: model.clone(), - effort, - }); - })]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), - subtitle: Some(subtitle), - footer_hint: Some(standard_popup_hint_line()), - items: vec![ - SelectionItem { - name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(), - description: Some(plan_only_description), - actions: plan_only_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: PLAN_MODE_REASONING_SCOPE_ALL_MODES.to_string(), - description: Some(all_modes_description), - actions: all_modes_actions, - dismiss_on_select: true, - ..Default::default() - }, - ], - ..Default::default() - }); - self.notify(Notification::PlanModePrompt { - title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(), - }); - } - - /// Open a popup to choose the reasoning effort (stage 2) for the given model. - pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { - let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; - let supported = preset.supported_reasoning_efforts; - let in_plan_mode = - self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; - - let warn_effort = if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::XHigh) - { - Some(ReasoningEffortConfig::XHigh) - } else if supported - .iter() - .any(|option| option.effort == ReasoningEffortConfig::High) - { - Some(ReasoningEffortConfig::High) - } else { - None - }; - let warning_text = warn_effort.map(|effort| { - let effort_label = Self::reasoning_effort_label(effort); - format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") - }); - let warn_for_model = preset.model.starts_with("gpt-5.1-codex") - || preset.model.starts_with("gpt-5.1-codex-max") - || preset.model.starts_with("gpt-5.2"); - - struct EffortChoice { - stored: Option, - display: ReasoningEffortConfig, - } - let mut choices: Vec = Vec::new(); - for effort in ReasoningEffortConfig::iter() { - if supported.iter().any(|option| option.effort == effort) { - choices.push(EffortChoice { - stored: Some(effort), - display: effort, - }); - } - } - if choices.is_empty() { - choices.push(EffortChoice { - stored: Some(default_effort), - display: default_effort, - }); - } - - if choices.len() == 1 { - let selected_effort = choices.first().and_then(|c| c.stored); - let selected_model = preset.model; - if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { - self.app_event_tx - .send(AppEvent::OpenPlanReasoningScopePrompt { - model: selected_model, - effort: selected_effort, - }); - } else { - self.apply_model_and_effort(selected_model, selected_effort); - } - return; - } - - let default_choice: Option = choices - .iter() - .any(|choice| choice.stored == Some(default_effort)) - .then_some(Some(default_effort)) - .flatten() - .or_else(|| choices.iter().find_map(|choice| choice.stored)) - .or(Some(default_effort)); - - let model_slug = preset.model.to_string(); - let is_current_model = self.current_model() == preset.model.as_str(); - let highlight_choice = if is_current_model { - if in_plan_mode { - self.config - .plan_mode_reasoning_effort - .or(self.effective_reasoning_effort()) - } else { - self.effective_reasoning_effort() - } - } else { - default_choice - }; - let selection_choice = highlight_choice.or(default_choice); - let initial_selected_idx = choices - .iter() - .position(|choice| choice.stored == selection_choice) - .or_else(|| { - selection_choice - .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) - }); - let mut items: Vec = Vec::new(); - for choice in choices.iter() { - let effort = choice.display; - let mut effort_label = Self::reasoning_effort_label(effort).to_string(); - if choice.stored == default_choice { - effort_label.push_str(" (default)"); - } - - let description = choice - .stored - .and_then(|effort| { - supported - .iter() - .find(|option| option.effort == effort) - .map(|option| option.description.to_string()) - }) - .filter(|text| !text.is_empty()); - - let show_warning = warn_for_model && warn_effort == Some(effort); - let selected_description = if show_warning { - warning_text.as_ref().map(|warning_message| { - description.as_ref().map_or_else( - || warning_message.clone(), - |d| format!("{d}\n{warning_message}"), - ) - }) - } else { - None - }; - - let model_for_action = model_slug.clone(); - let choice_effort = choice.stored; - let should_prompt_plan_mode_scope = - self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); - let actions: Vec = vec![Box::new(move |tx| { - if should_prompt_plan_mode_scope { - tx.send(AppEvent::OpenPlanReasoningScopePrompt { - model: model_for_action.clone(), - effort: choice_effort, - }); - } else { - tx.send(AppEvent::UpdateModel(model_for_action.clone())); - tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); - tx.send(AppEvent::PersistModelSelection { - model: model_for_action.clone(), - effort: choice_effort, - }); - } - })]; - - items.push(SelectionItem { - name: effort_label, - description, - selected_description, - is_current: is_current_model && choice.stored == highlight_choice, - actions, - dismiss_on_select: true, - ..Default::default() - }); - } - - let mut header = ColumnRenderable::new(); - header.push(Line::from( - format!("Select Reasoning Level for {model_slug}").bold(), - )); - - self.bottom_pane.show_selection_view(SelectionViewParams { - header: Box::new(header), - footer_hint: Some(standard_popup_hint_line()), - items, - initial_selected_idx, - ..Default::default() - }); - } - - fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { - match effort { - ReasoningEffortConfig::None => "None", - ReasoningEffortConfig::Minimal => "Minimal", - ReasoningEffortConfig::Low => "Low", - ReasoningEffortConfig::Medium => "Medium", - ReasoningEffortConfig::High => "High", - ReasoningEffortConfig::XHigh => "Extra high", - } - } - - fn apply_model_and_effort_without_persist( - &self, - model: String, - effort: Option, - ) { - self.app_event_tx.send(AppEvent::UpdateModel(model)); - self.app_event_tx - .send(AppEvent::UpdateReasoningEffort(effort)); - } - - fn apply_model_and_effort(&self, model: String, effort: Option) { - self.apply_model_and_effort_without_persist(model.clone(), effort); - self.app_event_tx - .send(AppEvent::PersistModelSelection { model, effort }); - } - - /// Open the permissions popup. - pub(crate) fn open_approvals_popup(&mut self) { - self.open_permissions_popup(); - } - - /// Open a popup to choose the permissions mode. - pub(crate) fn open_permissions_popup(&mut self) { - let include_read_only = cfg!(target_os = "windows"); - let current_approval = - AskForApproval::from(self.config.permissions.approval_policy.value()); - let current_permission_profile = self.config.permissions.permission_profile(); - let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); - let current_review_policy = self.config.approvals_reviewer; - let mut items: Vec = Vec::new(); - let presets: Vec = builtin_approval_presets(); - - #[cfg(target_os = "windows")] - let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); - #[cfg(target_os = "windows")] - let windows_degraded_sandbox_enabled = - matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); - #[cfg(not(target_os = "windows"))] - let windows_degraded_sandbox_enabled = false; - - let show_elevate_sandbox_hint = - crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && windows_degraded_sandbox_enabled - && presets.iter().any(|preset| preset.id == "auto"); - - let guardian_disabled_reason = |enabled: bool| { - let mut next_features = self.config.features.get().clone(); - next_features.set_enabled(Feature::GuardianApproval, enabled); - self.config - .features - .can_set(&next_features) - .err() - .map(|err| err.to_string()) - }; - - for preset in presets.into_iter() { - if !include_read_only && preset.id == "read-only" { - continue; - } - let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Default (non-admin sandbox)".to_string() - } else { - preset.label.to_string() - }; - let preset_approval = AskForApproval::from(preset.approval); - let base_description = - Some(preset.description.replace(" (Identical to Agent mode)", "")); - let approval_disabled_reason = match self - .config - .permissions - .approval_policy - .can_set(&preset.approval) - { - Ok(()) => None, - Err(err) => Some(err.to_string()), - }; - let default_disabled_reason = approval_disabled_reason - .clone() - .or_else(|| guardian_disabled_reason(false)); - let requires_confirmation = preset.id == "full-access" - && !self - .config - .notices - .hide_full_access_warning - .unwrap_or(false); - let default_actions: Vec = if requires_confirmation { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenFullAccessConfirmation { - preset: preset_clone.clone(), - return_to_permissions: !include_read_only, - }); - })] - } else if preset.id == "auto" { - #[cfg(target_os = "windows")] - { - if WindowsSandboxLevel::from_config(&self.config) - == WindowsSandboxLevel::Disabled - { - let preset_clone = preset.clone(); - if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) - { - vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }); - })] - } else { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { - preset: preset_clone.clone(), - }); - })] - } - } else if let Some((sample_paths, extra_count, failed_scan)) = - self.world_writable_warning_details() - { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset_clone.clone()), - sample_paths: sample_paths.clone(), - extra_count, - failed_scan, - }); - })] - } else { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - } - } - #[cfg(not(target_os = "windows"))] - { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - } - } else { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - }; - if preset.id == "auto" { - items.push(SelectionItem { - name: base_name.clone(), - description: base_description.clone(), - is_current: current_review_policy == ApprovalsReviewer::User - && Self::preset_matches_current( - current_approval, - ¤t_permission_profile, - self.config.cwd.as_path(), - &preset, - ), - actions: default_actions, - dismiss_on_select: true, - disabled_reason: default_disabled_reason, - ..Default::default() - }); - - if guardian_approval_enabled { - items.push(SelectionItem { - name: "Auto-review".to_string(), - description: Some( - "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent." - .to_string(), - ), - is_current: current_review_policy == ApprovalsReviewer::AutoReview - && Self::preset_matches_current( - current_approval, - ¤t_permission_profile, - self.config.cwd.as_path(), - &preset, - ), - actions: Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - "Auto-review".to_string(), - ApprovalsReviewer::AutoReview, - ), - dismiss_on_select: true, - disabled_reason: approval_disabled_reason - .or_else(|| guardian_disabled_reason(true)), - ..Default::default() - }); - } - } else { - items.push(SelectionItem { - name: base_name, - description: base_description, - is_current: Self::preset_matches_current( - current_approval, - ¤t_permission_profile, - self.config.cwd.as_path(), - &preset, - ), - actions: default_actions, - dismiss_on_select: true, - disabled_reason: default_disabled_reason, - ..Default::default() - }); - } - } - - let footer_note = show_elevate_sandbox_hint.then(|| { - vec![ - "The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(), - "/setup-default-sandbox".cyan(), - ".".dim(), - ] - .into() - }); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Update Model Permissions".to_string()), - footer_note, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(()), - ..Default::default() - }); - } - - pub(crate) fn open_auto_review_denials_popup(&mut self) { - if self.review.recent_auto_review_denials.is_empty() { - self.add_info_message( - "No recent auto-review denials in this thread.".to_string(), - Some("Denials are recorded after auto-review rejects an action.".to_string()), - ); - return; - } - let Some(thread_id) = self.thread_id() else { - self.add_error_message("That thread is no longer available.".to_string()); - return; - }; - - let mut items = vec![SelectionItem { - name: "Command".to_string(), - description: Some("Rationale".to_string()), - is_disabled: true, - search_value: Some(String::new()), - ..Default::default() - }]; - items.extend( - self.review - .recent_auto_review_denials - .entries() - .map(|event| { - let id = event.id.clone(); - let summary = auto_review_denials::action_summary(&event.action); - let rationale = event - .rationale - .as_deref() - .unwrap_or("Auto-review did not include a rationale."); - SelectionItem { - name: summary.clone(), - description: Some(rationale.to_string()), - selected_description: Some(rationale.to_string()), - search_value: Some(format!("{summary} {rationale}")), - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::ApproveRecentAutoReviewDenial { - thread_id, - id: id.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - } - }), - ); - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: Some("Auto-review Denials".to_string()), - subtitle: Some("Select a denied action to approve.".to_string()), - footer_hint: Some(standard_popup_hint_line()), - items, - is_searchable: true, - col_width_mode: ColumnWidthMode::AutoAllRows, - ..Default::default() - }); - self.request_redraw(); - } - - pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) { - let Some(event) = self.review.recent_auto_review_denials.take(&id) else { - self.add_error_message("That auto-review denial is no longer available.".to_string()); - return; - }; - - self.app_event_tx.send(AppEvent::SubmitThreadOp { - thread_id, - op: AppCommand::approve_guardian_denied_action(event), - }); - self.add_info_message( - "Approval recorded for one retry of the selected auto-review denial.".to_string(), - Some( - "The model will see the approval context; the retry still goes through auto-review." - .to_string(), - ), - ); - } - - pub(crate) fn open_experimental_popup(&mut self) { - let features: Vec = FEATURES - .iter() - .filter_map(|spec| { - let name = spec.stage.experimental_menu_name()?; - let description = spec.stage.experimental_menu_description()?; - Some(ExperimentalFeatureItem { - feature: spec.id, - name: name.to_string(), - description: description.to_string(), - enabled: self.config.features.enabled(spec.id), - }) - }) - .collect(); - - let view = ExperimentalFeaturesView::new( - features, - self.app_event_tx.clone(), - self.bottom_pane.list_keymap(), - ); - self.bottom_pane.show_view(Box::new(view)); - } - - fn approval_preset_actions( - approval: AskForApproval, - permission_profile: PermissionProfile, - label: String, - approvals_reviewer: ApprovalsReviewer, - ) -> Vec { - vec![Box::new(move |tx| { - let permission_profile_clone = permission_profile.clone(); - tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( - /*cwd*/ None, - Some(approval), - Some(approvals_reviewer), - Some(permission_profile_clone.clone()), - /*windows_sandbox_level*/ None, - /*model*/ None, - /*effort*/ None, - /*summary*/ None, - /*service_tier*/ None, - /*collaboration_mode*/ None, - /*personality*/ None, - ))); - tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); - tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - format!("Permissions updated to {label}"), - /*hint*/ None, - ), - ))); - })] - } - - fn preset_matches_current( - current_approval: AskForApproval, - current_permission_profile: &PermissionProfile, - cwd: &std::path::Path, - preset: &ApprovalPreset, - ) -> bool { - let preset_approval = AskForApproval::from(preset.approval); - if current_approval != preset_approval { - return false; - } - - match preset.id { - "full-access" => matches!(current_permission_profile, PermissionProfile::Disabled), - "read-only" => { - let file_system_policy = current_permission_profile.file_system_sandbox_policy(); - matches!( - current_permission_profile, - PermissionProfile::Managed { .. } - ) && !file_system_policy.has_full_disk_write_access() - && file_system_policy - .get_writable_roots_with_cwd(cwd) - .is_empty() - && current_permission_profile.network_sandbox_policy() - == preset.permission_profile.network_sandbox_policy() - } - "auto" => { - let file_system_policy = current_permission_profile.file_system_sandbox_policy(); - matches!( - current_permission_profile, - PermissionProfile::Managed { .. } - ) && file_system_policy.can_write_path_with_cwd(cwd, cwd) - && !file_system_policy.has_full_disk_write_access() - && current_permission_profile.network_sandbox_policy() - == preset.permission_profile.network_sandbox_policy() - } - _ => current_permission_profile == &preset.permission_profile, - } - } - - #[cfg(target_os = "windows")] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - if self - .config - .notices - .hide_world_writable_warning - .unwrap_or(false) - { - return None; - } - let cwd = self.config.cwd.clone(); - let env_map: std::collections::HashMap = std::env::vars().collect(); - let Ok(policy) = self - .config - .permissions - .permission_profile() - .to_legacy_sandbox_policy(self.config.cwd.as_path()) - else { - return Some((Vec::new(), 0, true)); - }; - match codex_windows_sandbox::apply_world_writable_scan_and_denies( - self.config.codex_home.as_path(), - cwd.as_path(), - &env_map, - &policy, - Some(self.config.codex_home.as_path()), - ) { - Ok(_) => None, - Err(_) => Some((Vec::new(), 0, true)), - } - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { - None - } - - pub(crate) fn open_full_access_confirmation( - &mut self, - preset: ApprovalPreset, - return_to_permissions: bool, - ) { - let selected_name = preset.label.to_string(); - let approval = AskForApproval::from(preset.approval); - let permission_profile = preset.permission_profile; - let mut header_children: Vec> = Vec::new(); - let title_line = Line::from("Enable full access?").bold(); - let info_line = Line::from(vec![ - "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " - .into(), - "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." - .fg(Color::Red), - ]); - header_children.push(Box::new(title_line)); - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - let header = ColumnRenderable::with(header_children); - - let mut accept_actions = Self::approval_preset_actions( - approval, - permission_profile.clone(), - selected_name.clone(), - ApprovalsReviewer::User, - ); - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - })); - - let mut accept_and_remember_actions = Self::approval_preset_actions( - approval, - permission_profile, - selected_name, - ApprovalsReviewer::User, - ); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); - tx.send(AppEvent::PersistFullAccessWarningAcknowledged); - })); - - let deny_actions: Vec = vec![Box::new(move |tx| { - if return_to_permissions { - tx.send(AppEvent::OpenPermissionsPopup); - } else { - tx.send(AppEvent::OpenApprovalsPopup); - } - })]; - - let items = vec![ - SelectionItem { - name: "Yes, continue anyway".to_string(), - description: Some("Apply full access for this session".to_string()), - actions: accept_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Yes, and don't ask again".to_string(), - description: Some("Enable full access and remember this choice".to_string()), - actions: accept_and_remember_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Cancel".to_string(), - description: Some("Go back without enabling full access".to_string()), - actions: deny_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - preset: Option, - sample_paths: Vec, - extra_count: usize, - failed_scan: bool, - ) { - let (approval, permission_profile) = match &preset { - Some(p) => ( - Some(AskForApproval::from(p.approval)), - Some(p.permission_profile.clone()), - ), - None => (None, None), - }; - let mut header_children: Vec> = Vec::new(); - let describe_profile = |profile: &PermissionProfile| { - if matches!(profile, PermissionProfile::Disabled) { - "Full Access mode" - } else if profile - .file_system_sandbox_policy() - .can_write_path_with_cwd(self.config.cwd.as_path(), self.config.cwd.as_path()) - { - "Agent mode" - } else { - "Read-Only mode" - } - }; - let mode_label = preset - .as_ref() - .map(|p| describe_profile(&p.permission_profile)) - .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); - let info_line = if failed_scan { - Line::from(vec![ - "We couldn't complete the world-writable scan, so protections cannot be verified. " - .into(), - format!("The Windows sandbox cannot guarantee protection in {mode_label}.") - .fg(Color::Red), - ]) - } else { - Line::from(vec![ - "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), - " Consider removing write access for Everyone from the following folders:".into(), - ]) - }; - header_children.push(Box::new( - Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), - )); - - if !sample_paths.is_empty() { - // Show up to three examples and optionally an "and X more" line. - let mut lines: Vec = Vec::new(); - lines.push(Line::from("")); - for p in &sample_paths { - lines.push(Line::from(format!(" - {p}"))); - } - if extra_count > 0 { - lines.push(Line::from(format!("and {extra_count} more"))); - } - header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - } - let header = ColumnRenderable::with(header_children); - - // Build actions ensuring acknowledgement happens before applying the - // new permission profile, so downstream policy-change hooks don't - // re-trigger the warning. - let mut accept_actions: Vec = Vec::new(); - // Suppress the immediate re-scan only when a preset will be applied via - // /permissions, to avoid duplicate warnings from the ensuing policy change. - if preset.is_some() { - accept_actions.push(Box::new(|tx| { - tx.send(AppEvent::SkipNextWorldWritableScan); - })); - } - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile.clone()) { - accept_actions.extend(Self::approval_preset_actions( - approval, - permission_profile, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } - - let mut accept_and_remember_actions: Vec = Vec::new(); - accept_and_remember_actions.push(Box::new(|tx| { - tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); - tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); - })); - if let (Some(approval), Some(permission_profile)) = (approval, permission_profile) { - accept_and_remember_actions.extend(Self::approval_preset_actions( - approval, - permission_profile, - mode_label.to_string(), - ApprovalsReviewer::User, - )); - } - - let items = vec![ - SelectionItem { - name: "Continue".to_string(), - description: Some(format!("Apply {mode_label} for this session")), - actions: accept_actions, - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Continue and don't warn again".to_string(), - description: Some(format!("Enable {mode_label} and remember this choice")), - actions: accept_and_remember_actions, - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_world_writable_warning_confirmation( - &mut self, - _preset: Option, - _sample_paths: Vec, - _extra_count: usize, - _failed_scan: bool, - ) { - } - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { - use ratatui_macros::line; - - if !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { - // Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it - // directly (no elevation prompts). - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], - line!["Learn more: https://developers.openai.com/codex/windows"], - ]) - .wrap(Wrap { trim: false }), - )); - - let preset_clone = preset; - let items = vec![ - SelectionItem { - name: "Enable experimental sandbox".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Legacy, - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Go back".to_string(), - description: None, - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - return; - } - - self.session_telemetry.counter( - "codex.windows_sandbox.elevated_prompt_shown", - /*inc*/ 1, - &[], - ); - - let mut header = ColumnRenderable::new(); - header.push(*Box::new( - Paragraph::new(vec![ - line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], - ]) - .wrap(Wrap { trim: false }), - )); - - let accept_otel = self.session_telemetry.clone(); - let legacy_otel = self.session_telemetry.clone(); - let legacy_preset = preset.clone(); - let quit_otel = self.session_telemetry.clone(); - let items = vec![ - SelectionItem { - name: "Set up default sandbox (requires Administrator permissions)".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - accept_otel.counter( - "codex.windows_sandbox.elevated_prompt_accept", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - legacy_otel.counter( - "codex.windows_sandbox.elevated_prompt_use_legacy", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: legacy_preset.clone(), - }); - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Quit".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - quit_otel.counter( - "codex.windows_sandbox.elevated_prompt_quit", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} - - #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) { - use ratatui_macros::line; - - let mut lines = Vec::new(); - lines.push(line![ - "Couldn't set up your sandbox with Administrator permissions".bold() - ]); - lines.push(line![""]); - lines.push(line![ - "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." - ]); - lines.push(line![ - "Learn more " - ]); - - let mut header = ColumnRenderable::new(); - header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - - let elevated_preset = preset.clone(); - let legacy_preset = preset; - let quit_otel = self.session_telemetry.clone(); - let items = vec![ - SelectionItem { - name: "Try setting up admin sandbox again".to_string(), - description: None, - actions: vec![Box::new({ - let otel = self.session_telemetry.clone(); - let preset = elevated_preset; - move |tx| { - otel.counter( - "codex.windows_sandbox.fallback_retry_elevated", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset.clone(), - }); - } - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Use Codex with non-admin sandbox".to_string(), - description: None, - actions: vec![Box::new({ - let otel = self.session_telemetry.clone(); - let preset = legacy_preset; - move |tx| { - otel.counter( - "codex.windows_sandbox.fallback_use_legacy", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::BeginWindowsSandboxLegacySetup { - preset: preset.clone(), - }); - } - })], - dismiss_on_select: true, - ..Default::default() - }, - SelectionItem { - name: "Quit".to_string(), - description: None, - actions: vec![Box::new(move |tx| { - quit_otel.counter( - "codex.windows_sandbox.fallback_prompt_quit", - /*inc*/ 1, - &[], - ); - tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); - })], - dismiss_on_select: true, - ..Default::default() - }, - ]; - - self.bottom_pane.show_selection_view(SelectionViewParams { - title: None, - footer_hint: Some(standard_popup_hint_line()), - items, - header: Box::new(header), - ..Default::default() - }); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {} - - #[cfg(target_os = "windows")] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { - if show_now - && WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled - && let Some(preset) = builtin_approval_presets() - .into_iter() - .find(|preset| preset.id == "auto") - { - self.open_windows_sandbox_enable_prompt(preset); - } - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} - - #[cfg(target_os = "windows")] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) { - // While elevated sandbox setup runs, prevent typing so the user doesn't - // accidentally queue messages that will run under an unexpected mode. - self.bottom_pane.set_composer_input_enabled( - /*enabled*/ false, - Some("Input disabled until setup completes.".to_string()), - ); - self.bottom_pane.ensure_status_indicator(); - self.bottom_pane - .set_interrupt_hint_visible(/*visible*/ false); - self.set_status( - "Setting up sandbox...".to_string(), - Some("Hang tight, this may take a few minutes".to_string()), - StatusDetailsCapitalization::CapitalizeFirst, - STATUS_DETAILS_DEFAULT_MAX_LINES, - ); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - #[allow(dead_code)] - pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} - - #[cfg(target_os = "windows")] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { - self.bottom_pane - .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); - self.bottom_pane.hide_status_indicator(); - self.request_redraw(); - } - - #[cfg(not(target_os = "windows"))] - pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} - - /// Set the approval policy in the widget's config copy. - pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { - if let Err(err) = self - .config - .permissions - .approval_policy - .set(policy.to_core()) - { - tracing::warn!(%err, "failed to set approval_policy on chat config"); - } else { - self.refresh_status_surfaces(); - } - } - - /// Set the permission profile in the widget's config copy. - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_permission_profile( - &mut self, - profile: PermissionProfile, - ) -> ConstraintResult<()> { - self.config.permissions.set_permission_profile(profile)?; - self.refresh_status_surfaces(); - Ok(()) - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { - self.config.permissions.windows_sandbox_mode = mode; - #[cfg(target_os = "windows")] - self.bottom_pane.set_windows_degraded_sandbox_active( - crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && matches!( - WindowsSandboxLevel::from_config(&self.config), - WindowsSandboxLevel::RestrictedToken - ), - ); - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) -> bool { - if let Err(err) = self.config.features.set_enabled(feature, enabled) { - tracing::warn!( - error = %err, - feature = feature.key(), - "failed to update constrained chat widget feature state" - ); - } - let enabled = self.config.features.enabled(feature); - if feature == Feature::RealtimeConversation { - let realtime_conversation_enabled = self.realtime_conversation_enabled(); - self.bottom_pane - .set_realtime_conversation_enabled(realtime_conversation_enabled); - self.bottom_pane - .set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled()); - if !realtime_conversation_enabled && self.realtime_conversation.is_live() { - self.request_realtime_conversation_close(Some( - "Realtime voice mode was closed because the feature was disabled.".to_string(), - )); - } - } - if feature == Feature::FastMode { - self.sync_service_tier_commands(); - } - if feature == Feature::Personality { - self.sync_personality_command_enabled(); - } - if feature == Feature::Plugins { - self.sync_plugins_command_enabled(); - self.refresh_plugin_mentions(); - } - if feature == Feature::Goals { - self.sync_goal_command_enabled(); - if !enabled { - self.current_goal_status_indicator = None; - self.current_goal_status = None; - self.turn_lifecycle.goal_status_active_turn_started_at = None; - self.turn_lifecycle.budget_limited_turn_ids.clear(); - self.update_collaboration_mode_indicator(); - } - } - if feature == Feature::MentionsV2 { - self.sync_mentions_v2_enabled(); - } - if feature == Feature::PreventIdleSleep { - self.turn_lifecycle.set_prevent_idle_sleep(enabled); - } - #[cfg(target_os = "windows")] - if matches!( - feature, - Feature::WindowsSandbox | Feature::WindowsSandboxElevated - ) { - self.bottom_pane.set_windows_degraded_sandbox_active( - crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && matches!( - WindowsSandboxLevel::from_config(&self.config), - WindowsSandboxLevel::RestrictedToken - ), - ); - } - enabled - } - - pub(crate) fn set_approvals_reviewer(&mut self, policy: ApprovalsReviewer) { - self.config.approvals_reviewer = policy; - self.refresh_status_surfaces(); - } - - pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_full_access_warning = Some(acknowledged); - } - - pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { - self.config.notices.hide_world_writable_warning = Some(acknowledged); - } - - pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { - self.config.notices.hide_rate_limit_model_nudge = Some(hidden); - if hidden { - self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; - } - } - - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn world_writable_warning_hidden(&self) -> bool { - self.config - .notices - .hide_world_writable_warning - .unwrap_or(false) - } - - /// Override the reasoning effort used when Plan mode is active. - /// - /// When the active mask is already Plan, the override is applied immediately - /// so the footer reflects it without waiting for the next mode switch. - /// Passing `None` resets to the Plan-mode preset default. - pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { - self.config.plan_mode_reasoning_effort = effort; - if self.collaboration_modes_enabled() - && let Some(mask) = self.active_collaboration_mask.as_mut() - && mask.mode == Some(ModeKind::Plan) - { - if let Some(effort) = effort { - mask.reasoning_effort = Some(Some(effort)); - } else if let Some(plan_mask) = - collaboration_modes::plan_mask(self.model_catalog.as_ref()) - { - mask.reasoning_effort = plan_mask.reasoning_effort; - } - } - self.refresh_model_dependent_surfaces(); - } - - /// Set the reasoning effort for the non-Plan collaboration mode. - /// - /// Does not touch the active Plan mask — Plan reasoning is controlled - /// exclusively by the Plan preset and `set_plan_mode_reasoning_effort`. - pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { - self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - /*model*/ None, - Some(effort), - /*developer_instructions*/ None, - ); - if self.collaboration_modes_enabled() - && let Some(mask) = self.active_collaboration_mask.as_mut() - && mask.mode != Some(ModeKind::Plan) - { - // Generic "global default" updates should not mutate the active Plan mask. - // Plan reasoning is controlled by the Plan preset and Plan-only override updates. - mask.reasoning_effort = Some(effort); - } - self.refresh_model_dependent_surfaces(); - } - - /// Set the personality in the widget's config copy. - pub(crate) fn set_personality(&mut self, personality: Personality) { - self.config.personality = Some(personality); - } - - pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> { - self.status_account_display.as_ref() - } - - pub(crate) fn runtime_model_provider_base_url(&self) -> Option<&str> { - self.runtime_model_provider_base_url.as_deref() - } - - #[cfg_attr(not(test), allow(dead_code))] - pub(crate) fn model_catalog(&self) -> Arc { - self.model_catalog.clone() - } - - pub(crate) fn current_plan_type(&self) -> Option { - self.plan_type - } - - pub(crate) fn has_chatgpt_account(&self) -> bool { - self.has_chatgpt_account - } - - pub(crate) fn update_account_state( - &mut self, - status_account_display: Option, - plan_type: Option, - has_chatgpt_account: bool, - ) { - self.status_account_display = status_account_display; - self.plan_type = plan_type; - self.has_chatgpt_account = has_chatgpt_account; - self.bottom_pane - .set_connectors_enabled(self.connectors_enabled()); - } - - pub(crate) fn set_realtime_audio_device( - &mut self, - kind: RealtimeAudioDeviceKind, - name: Option, - ) { - match kind { - RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name, - RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name, - } - } - - /// Set the syntax theme override in the widget's config copy. - pub(crate) fn set_tui_theme(&mut self, theme: Option) { - self.config.tui_theme = theme; - } - - /// Set the model in the widget's config copy and stored collaboration mode. - pub(crate) fn set_model(&mut self, model: &str) { - self.current_collaboration_mode = self.current_collaboration_mode.with_updates( - Some(model.to_string()), - /*effort*/ None, - /*developer_instructions*/ None, - ); - if self.collaboration_modes_enabled() - && let Some(mask) = self.active_collaboration_mask.as_mut() - { - mask.model = Some(model.to_string()); - } - self.refresh_model_dependent_surfaces(); - } - - pub(crate) fn current_model(&self) -> &str { - if !self.collaboration_modes_enabled() { - return self.current_collaboration_mode.model(); - } - self.active_collaboration_mask - .as_ref() - .and_then(|mask| mask.model.as_deref()) - .unwrap_or_else(|| self.current_collaboration_mode.model()) - } - - pub(crate) fn realtime_conversation_is_live(&self) -> bool { - self.realtime_conversation.is_live() - } - - fn current_realtime_audio_device_name(&self, kind: RealtimeAudioDeviceKind) -> Option { - match kind { - RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(), - RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(), - } - } - - fn current_realtime_audio_selection_label(&self, kind: RealtimeAudioDeviceKind) -> String { - self.current_realtime_audio_device_name(kind) - .unwrap_or_else(|| "System default".to_string()) - } - - fn sync_personality_command_enabled(&mut self) { - self.bottom_pane - .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); - } - - fn sync_plugins_command_enabled(&mut self) { - self.bottom_pane - .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); - } - - fn sync_goal_command_enabled(&mut self) { - self.bottom_pane - .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); - } - - fn sync_mentions_v2_enabled(&mut self) { - self.bottom_pane - .set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2)); - } - - fn current_model_supports_personality(&self) -> bool { - let model = self.current_model(); - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.supports_personality) - }) - .unwrap_or(false) - } - - /// Return whether the effective model currently advertises image-input support. - /// - /// We intentionally default to `true` when model metadata cannot be read so transient catalog - /// failures do not hard-block user input in the UI. - fn current_model_supports_images(&self) -> bool { - let model = self.current_model(); - self.model_catalog - .try_list_models() - .ok() - .and_then(|models| { - models - .into_iter() - .find(|preset| preset.model == model) - .map(|preset| preset.input_modalities.contains(&InputModality::Image)) - }) - .unwrap_or(true) - } - - fn sync_image_paste_enabled(&mut self) { - let enabled = self.current_model_supports_images(); - self.bottom_pane.set_image_paste_enabled(enabled); - } - - fn image_inputs_not_supported_message(&self) -> String { - format!( - "Model {} does not support image inputs. Remove images or switch models.", - self.current_model() - ) - } - - #[allow(dead_code)] // Used in tests - pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { - &self.current_collaboration_mode - } - - pub(crate) fn current_reasoning_effort(&self) -> Option { - self.effective_reasoning_effort() - } - - #[cfg(test)] - pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { - self.active_mode_kind() - } - - fn is_session_configured(&self) -> bool { - self.thread_id.is_some() - } - - fn collaboration_modes_enabled(&self) -> bool { - true - } - - /// Returns the dismissal scope that applies to the currently visible draft. - fn plan_mode_nudge_scope(&self) -> PlanModeNudgeScope { - self.thread_id - .map_or(PlanModeNudgeScope::NewThread, PlanModeNudgeScope::Thread) - } - - /// Returns whether the current draft should replace the normal footer with the Plan-mode nudge. - /// - /// `ChatWidget` owns this policy because it can combine lexical draft matching with mode - /// availability, interaction state, and thread-scoped dismissal. `ChatComposer` only renders - /// the resulting visibility bit. Keeping slash and shell drafts out here avoids advertising a - /// mode switch while the user is intentionally composing another local command. - fn should_show_plan_mode_nudge(&self) -> bool { - let text = self.bottom_pane.composer_text(); - let trimmed = text.trim_start(); - self.collaboration_modes_enabled() - && collaboration_modes::plan_mask(self.model_catalog.as_ref()).is_some() - && self.active_mode_kind() != ModeKind::Plan - && self.bottom_pane.composer_input_enabled() - && !self.bottom_pane.is_task_running() - && self.bottom_pane.no_modal_or_popup_active() - && !trimmed.starts_with('/') - && !trimmed.starts_with('!') - && contains_plan_keyword(&text) - && !self - .dismissed_plan_mode_nudge_scopes - .contains(&self.plan_mode_nudge_scope()) - } - - /// Synchronizes the footer presentation with the current Plan-mode nudge policy. - fn refresh_plan_mode_nudge(&mut self) { - self.bottom_pane - .set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge()); - } - - /// Hides the nudge for the current thread scope until the user changes conversation context. - fn dismiss_plan_mode_nudge(&mut self) { - self.dismissed_plan_mode_nudge_scopes - .insert(self.plan_mode_nudge_scope()); - self.refresh_plan_mode_nudge(); - } - - fn initial_collaboration_mask( - _config: &Config, - model_catalog: &ModelCatalog, - model_override: Option<&str>, - ) -> Option { - let mut mask = collaboration_modes::default_mask(model_catalog)?; - if let Some(model_override) = model_override { - mask.model = Some(model_override.to_string()); - } - Some(mask) - } - - fn active_mode_kind(&self) -> ModeKind { - self.active_collaboration_mask - .as_ref() - .and_then(|mask| mask.mode) - .unwrap_or(ModeKind::Default) - } - - fn effective_reasoning_effort(&self) -> Option { - if !self.collaboration_modes_enabled() { - return self.current_collaboration_mode.reasoning_effort(); - } - let current_effort = self.current_collaboration_mode.reasoning_effort(); - self.active_collaboration_mask - .as_ref() - .and_then(|mask| mask.reasoning_effort) - .unwrap_or(current_effort) - } - - fn effective_collaboration_mode(&self) -> CollaborationMode { - if !self.collaboration_modes_enabled() { - return self.current_collaboration_mode.clone(); - } - self.active_collaboration_mask.as_ref().map_or_else( - || self.current_collaboration_mode.clone(), - |mask| self.current_collaboration_mode.apply_mask(mask), - ) - } - - fn refresh_model_display(&mut self) { - let effective = self.effective_collaboration_mode(); - self.session_header.set_model(effective.model()); - // Keep composer paste affordances aligned with the currently effective model. - self.sync_image_paste_enabled(); - self.sync_service_tier_commands(); - self.refresh_terminal_title(); - } - - /// Refresh every UI surface that depends on the effective model, reasoning - /// effort, or collaboration mode. - /// - /// Call this at the end of any setter that mutates `current_collaboration_mode`, - /// `active_collaboration_mask`, or per-mode reasoning-effort overrides. - /// Consolidating both refreshes here prevents the bug where callers update the - /// header/title (`refresh_model_display`) but forget the footer status line - /// (`refresh_status_line`). - fn refresh_model_dependent_surfaces(&mut self) { - self.refresh_model_display(); - self.refresh_status_line(); - } - - fn model_display_name(&self) -> &str { - let model = self.current_model(); - if model.is_empty() { - DEFAULT_MODEL_DISPLAY_NAME - } else { - model - } - } - - /// Get the label for the current collaboration mode. - fn collaboration_mode_label(&self) -> Option<&'static str> { - if !self.collaboration_modes_enabled() { - return None; - } - let active_mode = self.active_mode_kind(); - active_mode - .is_tui_visible() - .then_some(active_mode.display_name()) - } - - fn collaboration_mode_indicator(&self) -> Option { - if !self.collaboration_modes_enabled() { - return None; - } - match self.active_mode_kind() { - ModeKind::Plan => Some(CollaborationModeIndicator::Plan), - ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => None, - } - } - - fn update_collaboration_mode_indicator(&mut self) { - let indicator = self.collaboration_mode_indicator(); - let goal_indicator = if indicator.is_none() { - self.goal_status_indicator(Instant::now()) - } else { - None - }; - self.current_goal_status_indicator = goal_indicator.clone(); - self.bottom_pane.set_collaboration_mode_indicator(indicator); - self.bottom_pane.set_goal_status_indicator(goal_indicator); - } - - fn refresh_goal_status_indicator_for_time_tick(&mut self) { - if self.collaboration_mode_indicator().is_some() { - return; - } - let goal_indicator = self.goal_status_indicator(Instant::now()); - if goal_indicator != self.current_goal_status_indicator { - self.current_goal_status_indicator = goal_indicator.clone(); - self.bottom_pane.set_goal_status_indicator(goal_indicator); - } - } - - fn goal_status_indicator(&self, now: Instant) -> Option { - if !self.config.features.enabled(Feature::Goals) { - return None; - } - self.current_goal_status.as_ref().and_then(|state| { - state.indicator(now, self.turn_lifecycle.goal_status_active_turn_started_at) - }) - } - - fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option) { - if let Some(active_thread_id) = self.thread_id - && active_thread_id.to_string() != goal.thread_id - { - return; - } - if !self.config.features.enabled(Feature::Goals) { - self.current_goal_status_indicator = None; - self.current_goal_status = None; - self.update_collaboration_mode_indicator(); - return; - } - if goal.status == AppThreadGoalStatus::BudgetLimited - && let Some(turn_id) = turn_id - { - self.turn_lifecycle.mark_budget_limited(turn_id); - } - self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); - self.update_collaboration_mode_indicator(); - } - - fn personality_label(personality: Personality) -> &'static str { - match personality { - Personality::None => "None", - Personality::Friendly => "Friendly", - Personality::Pragmatic => "Pragmatic", - } - } - - fn personality_description(personality: Personality) -> &'static str { - match personality { - Personality::None => "No personality instructions.", - Personality::Friendly => "Warm, collaborative, and helpful.", - Personality::Pragmatic => "Concise, task-focused, and direct.", - } - } - - /// Cycle to the next collaboration mode variant (Plan -> Default -> Plan). - fn cycle_collaboration_mode(&mut self) { - if !self.collaboration_modes_enabled() { - return; - } - - if let Some(next_mask) = collaboration_modes::next_mask( - self.model_catalog.as_ref(), - self.active_collaboration_mask.as_ref(), - ) { - self.set_collaboration_mask(next_mask); - } - } - - /// Update the active collaboration mask. - /// - /// When collaboration modes are enabled and a preset is selected, - /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. - pub(crate) fn set_collaboration_mask(&mut self, mut mask: CollaborationModeMask) { - if !self.collaboration_modes_enabled() { - return; - } - let previous_mode = self.active_mode_kind(); - let previous_model = self.current_model().to_string(); - let previous_effort = self.effective_reasoning_effort(); - if mask.mode == Some(ModeKind::Plan) - && let Some(effort) = self.config.plan_mode_reasoning_effort - { - mask.reasoning_effort = Some(Some(effort)); - } - if mask.mode == Some(ModeKind::Plan) { - self.dismissed_plan_mode_nudge_scopes - .insert(self.plan_mode_nudge_scope()); - } - self.active_collaboration_mask = Some(mask); - self.update_collaboration_mode_indicator(); - self.refresh_plan_mode_nudge(); - self.refresh_model_dependent_surfaces(); - let next_mode = self.active_mode_kind(); - let next_model = self.current_model(); - let next_effort = self.effective_reasoning_effort(); - if previous_mode != next_mode - && (previous_model != next_model || previous_effort != next_effort) - { - let mut message = format!("Model changed to {next_model}"); - if !next_model.starts_with("codex-auto-") { - let reasoning_label = match next_effort { - Some(ReasoningEffortConfig::Minimal) => "minimal", - Some(ReasoningEffortConfig::Low) => "low", - Some(ReasoningEffortConfig::Medium) => "medium", - Some(ReasoningEffortConfig::High) => "high", - Some(ReasoningEffortConfig::XHigh) => "xhigh", - None | Some(ReasoningEffortConfig::None) => "default", - }; - message.push(' '); - message.push_str(reasoning_label); - } - message.push_str(" for "); - message.push_str(next_mode.display_name()); - message.push_str(" mode."); - self.add_info_message(message, /*hint*/ None); - } - self.request_redraw(); - } - - fn connectors_enabled(&self) -> bool { - self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account - } - - fn connectors_for_mentions(&self) -> Option<&[AppInfo]> { - if !self.connectors_enabled() { - return None; - } - - if let Some(snapshot) = &self.connectors.partial_snapshot { - return Some(snapshot.connectors.as_slice()); - } - - match &self.connectors.cache { - ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), - _ => None, - } - } - fn plugins_for_mentions(&self) -> Option<&[PluginCapabilitySummary]> { if !self.config.features.enabled(Feature::Plugins) { return None; @@ -5416,220 +2282,6 @@ impl ChatWidget { self.request_redraw(); } - pub(crate) fn add_connectors_output(&mut self) { - if !self.connectors_enabled() { - self.add_info_message( - "Apps are disabled.".to_string(), - Some("Enable the apps feature to use $ or /apps.".to_string()), - ); - return; - } - - let connectors_cache = self.connectors.cache.clone(); - let should_force_refetch = !self.connectors.prefetch_in_flight - || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); - self.prefetch_connectors_with_options(should_force_refetch); - - match connectors_cache { - ConnectorsCacheState::Ready(snapshot) => { - if snapshot.connectors.is_empty() { - self.add_info_message("No apps available.".to_string(), /*hint*/ None); - } else { - self.open_connectors_popup(&snapshot.connectors); - } - } - ConnectorsCacheState::Failed(err) => { - self.add_to_history(history_cell::new_error_event(err)); - } - ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { - self.open_connectors_loading_popup(); - } - } - self.request_redraw(); - } - - fn open_connectors_loading_popup(&mut self) { - if !self.bottom_pane.replace_selection_view_if_active( - CONNECTORS_SELECTION_VIEW_ID, - self.connectors_loading_popup_params(), - ) { - self.bottom_pane - .show_selection_view(self.connectors_loading_popup_params()); - } - } - - fn open_connectors_popup(&mut self, connectors: &[AppInfo]) { - self.bottom_pane.show_selection_view( - self.connectors_popup_params(connectors, /*selected_connector_id*/ None), - ); - } - - fn connectors_loading_popup_params(&self) -> SelectionViewParams { - let mut header = ColumnRenderable::new(); - header.push(Line::from("Apps".bold())); - header.push(Line::from("Loading installed and available apps...".dim())); - - SelectionViewParams { - view_id: Some(CONNECTORS_SELECTION_VIEW_ID), - header: Box::new(header), - items: vec![SelectionItem { - name: "Loading apps...".to_string(), - description: Some("This updates when the full list is ready.".to_string()), - is_disabled: true, - ..Default::default() - }], - ..Default::default() - } - } - - fn connectors_popup_params( - &self, - connectors: &[AppInfo], - selected_connector_id: Option<&str>, - ) -> SelectionViewParams { - let total = connectors.len(); - let installed = connectors - .iter() - .filter(|connector| connector.is_accessible) - .count(); - let mut header = ColumnRenderable::new(); - header.push(Line::from("Apps".bold())); - header.push(Line::from( - "Use $ to insert an installed app into your prompt.".dim(), - )); - header.push(Line::from( - format!("Installed {installed} of {total} available apps.").dim(), - )); - let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| { - connectors - .iter() - .position(|connector| connector.id == selected_connector_id) - }); - let mut items: Vec = Vec::with_capacity(connectors.len()); - for connector in connectors { - let connector_label = codex_connectors::metadata::connector_display_label(connector); - let connector_title = connector_label.clone(); - let link_description = Self::connector_description(connector); - let description = Self::connector_brief_description(connector); - let status_label = Self::connector_status_label(connector); - let search_value = format!("{connector_label} {}", connector.id); - let mut item = SelectionItem { - name: connector_label, - description: Some(description), - search_value: Some(search_value), - ..Default::default() - }; - let is_installed = connector.is_accessible; - let selected_label = if is_installed { - format!( - "{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app." - ) - } else { - format!("{status_label}. Press Enter to open the app page to install this app.") - }; - let missing_label = format!("{status_label}. App link unavailable."); - let instructions = if connector.is_accessible { - "Manage this app in your browser." - } else { - "Install this app in your browser, then reload Codex." - }; - if let Some(install_url) = connector.install_url.clone() { - let app_id = connector.id.clone(); - let is_enabled = connector.is_enabled; - let title = connector_title.clone(); - let instructions = instructions.to_string(); - let description = link_description.clone(); - item.actions = vec![Box::new(move |tx| { - tx.send(AppEvent::OpenAppLink { - app_id: app_id.clone(), - title: title.clone(), - description: description.clone(), - instructions: instructions.clone(), - url: install_url.clone(), - is_installed, - is_enabled, - }); - })]; - item.dismiss_on_select = true; - item.selected_description = Some(selected_label); - } else { - let missing_label_for_action = missing_label.clone(); - item.actions = vec![Box::new(move |tx| { - tx.send(AppEvent::InsertHistoryCell(Box::new( - history_cell::new_info_event( - missing_label_for_action.clone(), - /*hint*/ None, - ), - ))); - })]; - item.dismiss_on_select = true; - item.selected_description = Some(missing_label); - } - items.push(item); - } - - SelectionViewParams { - view_id: Some(CONNECTORS_SELECTION_VIEW_ID), - header: Box::new(header), - footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), - items, - is_searchable: true, - search_placeholder: Some("Type to search apps".to_string()), - col_width_mode: ColumnWidthMode::AutoAllRows, - initial_selected_idx, - ..Default::default() - } - } - - fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) { - let selected_connector_id = - if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = ( - self.bottom_pane - .selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID), - &self.connectors.cache, - ) { - snapshot - .connectors - .get(selected_index) - .map(|connector| connector.id.as_str()) - } else { - None - }; - let _ = self.bottom_pane.replace_selection_view_if_active( - CONNECTORS_SELECTION_VIEW_ID, - self.connectors_popup_params(connectors, selected_connector_id), - ); - } - - fn connector_brief_description(connector: &AppInfo) -> String { - let status_label = Self::connector_status_label(connector); - match Self::connector_description(connector) { - Some(description) => format!("{status_label} · {description}"), - None => status_label.to_string(), - } - } - - fn connector_status_label(connector: &AppInfo) -> &'static str { - if connector.is_accessible { - if connector.is_enabled { - "Installed" - } else { - "Installed · Disabled" - } - } else { - "Can be installed" - } - } - - fn connector_description(connector: &AppInfo) -> Option { - connector - .description - .as_deref() - .map(str::trim) - .filter(|description| !description.is_empty()) - .map(str::to_string) - } - /// Forward file-search results to the bottom pane. pub(crate) fn apply_file_search_result(&mut self, query: String, matches: Vec) { self.bottom_pane.on_file_search_result(query, matches); @@ -5989,100 +2641,6 @@ impl ChatWidget { self.refresh_plugin_mentions(); } - pub(crate) fn on_connectors_loaded( - &mut self, - result: Result, - is_final: bool, - ) { - let mut trigger_pending_force_refetch = false; - if is_final { - self.connectors.prefetch_in_flight = false; - if self.connectors.force_refetch_pending { - self.connectors.force_refetch_pending = false; - trigger_pending_force_refetch = true; - } - } - - match result { - Ok(mut snapshot) => { - if !is_final { - snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible( - Vec::new(), - snapshot.connectors, - /*all_connectors_loaded*/ false, - ); - } - snapshot.connectors = - chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config); - if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { - let enabled_by_id: HashMap<&str, bool> = existing_snapshot - .connectors - .iter() - .map(|connector| (connector.id.as_str(), connector.is_enabled)) - .collect(); - for connector in &mut snapshot.connectors { - if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) { - connector.is_enabled = *is_enabled; - } - } - } - if is_final { - self.connectors.partial_snapshot = None; - self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); - } else { - self.connectors.partial_snapshot = Some(snapshot.clone()); - } - self.bottom_pane.set_connectors_snapshot(Some(snapshot)); - } - Err(err) => { - let partial_snapshot = self.connectors.partial_snapshot.take(); - if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache { - warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); - self.bottom_pane - .set_connectors_snapshot(Some(snapshot.clone())); - } else if let Some(snapshot) = partial_snapshot { - warn!( - "failed to load full apps list; falling back to installed apps snapshot: {err}" - ); - self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); - self.bottom_pane.set_connectors_snapshot(Some(snapshot)); - } else { - self.connectors.cache = ConnectorsCacheState::Failed(err); - self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); - } - } - } - - if trigger_pending_force_refetch { - self.prefetch_connectors_with_options(/*force_refetch*/ true); - } - } - - pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { - let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else { - return; - }; - - let mut changed = false; - for connector in &mut snapshot.connectors { - if connector.id == connector_id { - changed = connector.is_enabled != enabled; - connector.is_enabled = enabled; - break; - } - } - - if !changed { - return; - } - - self.refresh_connectors_popup_if_open(&snapshot.connectors); - self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); - self.bottom_pane.set_connectors_snapshot(Some(snapshot)); - } - pub(crate) fn refresh_plugin_mentions(&mut self) { if !self.config.features.enabled(Feature::Plugins) { self.bottom_pane.set_plugin_mentions(/*plugins*/ None); diff --git a/codex-rs/tui/src/chatwidget/connectors.rs b/codex-rs/tui/src/chatwidget/connectors.rs index 0cd5429baf..942f2e9da8 100644 --- a/codex-rs/tui/src/chatwidget/connectors.rs +++ b/codex-rs/tui/src/chatwidget/connectors.rs @@ -1,5 +1,6 @@ -//! Connector list cache state for `ChatWidget`. +//! Connector cache, popup, and refresh handling for `ChatWidget`. +use super::*; use crate::app_event::ConnectorsSnapshot; #[derive(Debug, Clone, Default)] @@ -18,3 +19,415 @@ pub(super) struct ConnectorsState { pub(super) prefetch_in_flight: bool, pub(super) force_refetch_pending: bool, } + +impl ChatWidget { + pub(crate) fn refresh_connectors(&mut self, force_refetch: bool) { + self.prefetch_connectors_with_options(force_refetch); + } + + pub(super) fn prefetch_connectors(&mut self) { + self.prefetch_connectors_with_options(/*force_refetch*/ false); + } + + fn prefetch_connectors_with_options(&mut self, force_refetch: bool) { + if !self.connectors_enabled() { + return; + } + if self.connectors.prefetch_in_flight { + if force_refetch { + self.connectors.force_refetch_pending = true; + } + return; + } + + self.connectors.prefetch_in_flight = true; + if !matches!(self.connectors.cache, ConnectorsCacheState::Ready(_)) { + self.connectors.cache = ConnectorsCacheState::Loading; + } + + let config = self.config.clone(); + let environment_manager = Arc::clone(&self.environment_manager); + let app_event_tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let accessible_result = + match chatgpt_connectors::list_accessible_connectors_from_mcp_tools_with_environment_manager( + &config, + force_refetch, + &environment_manager, + ) + .await + { + Ok(connectors) => connectors, + Err(err) => { + app_event_tx.send(AppEvent::ConnectorsLoaded { + result: Err(format!("Failed to load apps: {err}")), + is_final: true, + }); + return; + } + }; + let should_schedule_force_refetch = + !force_refetch && !accessible_result.codex_apps_ready; + let accessible_connectors = accessible_result.connectors; + + app_event_tx.send(AppEvent::ConnectorsLoaded { + result: Ok(ConnectorsSnapshot { + connectors: accessible_connectors.clone(), + }), + is_final: false, + }); + + let result: Result = async { + let all_connectors = + chatgpt_connectors::list_all_connectors_with_options(&config, force_refetch) + .await?; + let connectors = chatgpt_connectors::merge_connectors_with_accessible( + all_connectors, + accessible_connectors, + /*all_connectors_loaded*/ true, + ); + Ok(ConnectorsSnapshot { connectors }) + } + .await + .map_err(|err: anyhow::Error| format!("Failed to load apps: {err}")); + + app_event_tx.send(AppEvent::ConnectorsLoaded { + result, + is_final: true, + }); + + if should_schedule_force_refetch { + app_event_tx.send(AppEvent::RefreshConnectors { + force_refetch: true, + }); + } + }); + } + + pub(super) fn connectors_enabled(&self) -> bool { + self.config.features.enabled(Feature::Apps) && self.has_chatgpt_account + } + + pub(super) fn connectors_for_mentions(&self) -> Option<&[AppInfo]> { + if !self.connectors_enabled() { + return None; + } + + if let Some(snapshot) = &self.connectors.partial_snapshot { + return Some(snapshot.connectors.as_slice()); + } + + match &self.connectors.cache { + ConnectorsCacheState::Ready(snapshot) => Some(snapshot.connectors.as_slice()), + _ => None, + } + } + + pub(crate) fn add_connectors_output(&mut self) { + if !self.connectors_enabled() { + self.add_info_message( + "Apps are disabled.".to_string(), + Some("Enable the apps feature to use $ or /apps.".to_string()), + ); + return; + } + + let connectors_cache = self.connectors.cache.clone(); + let should_force_refetch = !self.connectors.prefetch_in_flight + || matches!(connectors_cache, ConnectorsCacheState::Ready(_)); + self.prefetch_connectors_with_options(should_force_refetch); + + match connectors_cache { + ConnectorsCacheState::Ready(snapshot) => { + if snapshot.connectors.is_empty() { + self.add_info_message("No apps available.".to_string(), /*hint*/ None); + } else { + self.open_connectors_popup(&snapshot.connectors); + } + } + ConnectorsCacheState::Failed(err) => { + self.add_to_history(history_cell::new_error_event(err)); + } + ConnectorsCacheState::Loading | ConnectorsCacheState::Uninitialized => { + self.open_connectors_loading_popup(); + } + } + self.request_redraw(); + } + + fn open_connectors_loading_popup(&mut self) { + if !self.bottom_pane.replace_selection_view_if_active( + CONNECTORS_SELECTION_VIEW_ID, + self.connectors_loading_popup_params(), + ) { + self.bottom_pane + .show_selection_view(self.connectors_loading_popup_params()); + } + } + + fn open_connectors_popup(&mut self, connectors: &[AppInfo]) { + self.bottom_pane.show_selection_view( + self.connectors_popup_params(connectors, /*selected_connector_id*/ None), + ); + } + + fn connectors_loading_popup_params(&self) -> SelectionViewParams { + let mut header = ColumnRenderable::new(); + header.push(Line::from("Apps".bold())); + header.push(Line::from("Loading installed and available apps...".dim())); + + SelectionViewParams { + view_id: Some(CONNECTORS_SELECTION_VIEW_ID), + header: Box::new(header), + items: vec![SelectionItem { + name: "Loading apps...".to_string(), + description: Some("This updates when the full list is ready.".to_string()), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + } + } + + fn connectors_popup_params( + &self, + connectors: &[AppInfo], + selected_connector_id: Option<&str>, + ) -> SelectionViewParams { + let total = connectors.len(); + let installed = connectors + .iter() + .filter(|connector| connector.is_accessible) + .count(); + let mut header = ColumnRenderable::new(); + header.push(Line::from("Apps".bold())); + header.push(Line::from( + "Use $ to insert an installed app into your prompt.".dim(), + )); + header.push(Line::from( + format!("Installed {installed} of {total} available apps.").dim(), + )); + let initial_selected_idx = selected_connector_id.and_then(|selected_connector_id| { + connectors + .iter() + .position(|connector| connector.id == selected_connector_id) + }); + let mut items: Vec = Vec::with_capacity(connectors.len()); + for connector in connectors { + let connector_label = codex_connectors::metadata::connector_display_label(connector); + let connector_title = connector_label.clone(); + let link_description = Self::connector_description(connector); + let description = Self::connector_brief_description(connector); + let status_label = Self::connector_status_label(connector); + let search_value = format!("{connector_label} {}", connector.id); + let mut item = SelectionItem { + name: connector_label, + description: Some(description), + search_value: Some(search_value), + ..Default::default() + }; + let is_installed = connector.is_accessible; + let selected_label = if is_installed { + format!( + "{status_label}. Press Enter to open the app page to install, manage, or enable/disable this app." + ) + } else { + format!("{status_label}. Press Enter to open the app page to install this app.") + }; + let missing_label = format!("{status_label}. App link unavailable."); + let instructions = if connector.is_accessible { + "Manage this app in your browser." + } else { + "Install this app in your browser, then reload Codex." + }; + if let Some(install_url) = connector.install_url.clone() { + let app_id = connector.id.clone(); + let is_enabled = connector.is_enabled; + let title = connector_title.clone(); + let instructions = instructions.to_string(); + let description = link_description.clone(); + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAppLink { + app_id: app_id.clone(), + title: title.clone(), + description: description.clone(), + instructions: instructions.clone(), + url: install_url.clone(), + is_installed, + is_enabled, + }); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(selected_label); + } else { + let missing_label_for_action = missing_label.clone(); + item.actions = vec![Box::new(move |tx| { + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + missing_label_for_action.clone(), + /*hint*/ None, + ), + ))); + })]; + item.dismiss_on_select = true; + item.selected_description = Some(missing_label); + } + items.push(item); + } + + SelectionViewParams { + view_id: Some(CONNECTORS_SELECTION_VIEW_ID), + header: Box::new(header), + footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to search apps".to_string()), + col_width_mode: ColumnWidthMode::AutoAllRows, + initial_selected_idx, + ..Default::default() + } + } + + fn refresh_connectors_popup_if_open(&mut self, connectors: &[AppInfo]) { + let selected_connector_id = + if let (Some(selected_index), ConnectorsCacheState::Ready(snapshot)) = ( + self.bottom_pane + .selected_index_for_active_view(CONNECTORS_SELECTION_VIEW_ID), + &self.connectors.cache, + ) { + snapshot + .connectors + .get(selected_index) + .map(|connector| connector.id.as_str()) + } else { + None + }; + let _ = self.bottom_pane.replace_selection_view_if_active( + CONNECTORS_SELECTION_VIEW_ID, + self.connectors_popup_params(connectors, selected_connector_id), + ); + } + + fn connector_brief_description(connector: &AppInfo) -> String { + let status_label = Self::connector_status_label(connector); + match Self::connector_description(connector) { + Some(description) => format!("{status_label} · {description}"), + None => status_label.to_string(), + } + } + + fn connector_status_label(connector: &AppInfo) -> &'static str { + if connector.is_accessible { + if connector.is_enabled { + "Installed" + } else { + "Installed · Disabled" + } + } else { + "Can be installed" + } + } + + fn connector_description(connector: &AppInfo) -> Option { + connector + .description + .as_deref() + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) + } + + pub(crate) fn on_connectors_loaded( + &mut self, + result: Result, + is_final: bool, + ) { + let mut trigger_pending_force_refetch = false; + if is_final { + self.connectors.prefetch_in_flight = false; + if self.connectors.force_refetch_pending { + self.connectors.force_refetch_pending = false; + trigger_pending_force_refetch = true; + } + } + + match result { + Ok(mut snapshot) => { + if !is_final { + snapshot.connectors = chatgpt_connectors::merge_connectors_with_accessible( + Vec::new(), + snapshot.connectors, + /*all_connectors_loaded*/ false, + ); + } + snapshot.connectors = + chatgpt_connectors::with_app_enabled_state(snapshot.connectors, &self.config); + if let ConnectorsCacheState::Ready(existing_snapshot) = &self.connectors.cache { + let enabled_by_id: HashMap<&str, bool> = existing_snapshot + .connectors + .iter() + .map(|connector| (connector.id.as_str(), connector.is_enabled)) + .collect(); + for connector in &mut snapshot.connectors { + if let Some(is_enabled) = enabled_by_id.get(connector.id.as_str()) { + connector.is_enabled = *is_enabled; + } + } + } + if is_final { + self.connectors.partial_snapshot = None; + self.refresh_connectors_popup_if_open(&snapshot.connectors); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); + } else { + self.connectors.partial_snapshot = Some(snapshot.clone()); + } + self.bottom_pane.set_connectors_snapshot(Some(snapshot)); + } + Err(err) => { + let partial_snapshot = self.connectors.partial_snapshot.take(); + if let ConnectorsCacheState::Ready(snapshot) = &self.connectors.cache { + warn!("failed to refresh apps list; retaining current apps snapshot: {err}"); + self.bottom_pane + .set_connectors_snapshot(Some(snapshot.clone())); + } else if let Some(snapshot) = partial_snapshot { + warn!( + "failed to load full apps list; falling back to installed apps snapshot: {err}" + ); + self.refresh_connectors_popup_if_open(&snapshot.connectors); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.bottom_pane.set_connectors_snapshot(Some(snapshot)); + } else { + self.connectors.cache = ConnectorsCacheState::Failed(err); + self.bottom_pane.set_connectors_snapshot(/*snapshot*/ None); + } + } + } + + if trigger_pending_force_refetch { + self.prefetch_connectors_with_options(/*force_refetch*/ true); + } + } + + pub(crate) fn update_connector_enabled(&mut self, connector_id: &str, enabled: bool) { + let ConnectorsCacheState::Ready(mut snapshot) = self.connectors.cache.clone() else { + return; + }; + + let mut changed = false; + for connector in &mut snapshot.connectors { + if connector.id == connector_id { + changed = connector.is_enabled != enabled; + connector.is_enabled = enabled; + break; + } + } + + if !changed { + return; + } + + self.refresh_connectors_popup_if_open(&snapshot.connectors); + self.connectors.cache = ConnectorsCacheState::Ready(snapshot.clone()); + self.bottom_pane.set_connectors_snapshot(Some(snapshot)); + } +} diff --git a/codex-rs/tui/src/chatwidget/model_popups.rs b/codex-rs/tui/src/chatwidget/model_popups.rs new file mode 100644 index 0000000000..0ee91c11fa --- /dev/null +++ b/codex-rs/tui/src/chatwidget/model_popups.rs @@ -0,0 +1,583 @@ +//! Model, collaboration, and reasoning popups for `ChatWidget`. +//! +//! These surfaces are tightly related because changing one often redirects +//! into another, especially while Plan mode is active. + +use super::*; + +impl ChatWidget { + /// Open a popup to choose a quick auto model. Selecting "All models" + /// opens the full picker with every available preset. + pub(crate) fn open_model_popup(&mut self) { + if !self.is_session_configured() { + self.add_info_message( + "Model selection is disabled until startup completes.".to_string(), + /*hint*/ None, + ); + return; + } + + let presets: Vec = match self.model_catalog.try_list_models() { + Ok(models) => models, + Err(_) => { + self.add_info_message( + "Models are being updated; please try /model again in a moment.".to_string(), + /*hint*/ None, + ); + return; + } + }; + self.open_model_popup_with_presets(presets); + } + + fn model_menu_header(&self, title: &str, subtitle: &str) -> Box { + let title = title.to_string(); + let subtitle = subtitle.to_string(); + let mut header = ColumnRenderable::new(); + header.push(Line::from(title.bold())); + header.push(Line::from(subtitle.dim())); + if let Some(warning) = self.model_menu_warning_line() { + header.push(warning); + } + Box::new(header) + } + + fn model_menu_warning_line(&self) -> Option> { + let base_url = self.custom_openai_base_url()?; + let warning = format!( + "Warning: OpenAI base URL is overridden to {base_url}. Selecting models may not be supported or work properly." + ); + Some(Line::from(warning.red())) + } + + fn custom_openai_base_url(&self) -> Option { + if !self.config.model_provider.is_openai() { + return None; + } + + let base_url = self.config.model_provider.base_url.as_ref()?; + let trimmed = base_url.trim(); + if trimmed.is_empty() { + return None; + } + + let normalized = trimmed.trim_end_matches('/'); + if normalized == DEFAULT_OPENAI_BASE_URL { + return None; + } + + Some(trimmed.to_string()) + } + + pub(crate) fn open_model_popup_with_presets(&mut self, presets: Vec) { + let presets: Vec = presets + .into_iter() + .filter(|preset| preset.show_in_picker) + .collect(); + + let current_model = self.current_model(); + let current_label = presets + .iter() + .find(|preset| preset.model.as_str() == current_model) + .map(|preset| preset.model.to_string()) + .unwrap_or_else(|| self.model_display_name().to_string()); + + let (mut auto_presets, other_presets): (Vec, Vec) = presets + .into_iter() + .partition(|preset| Self::is_auto_model(&preset.model)); + + if auto_presets.is_empty() { + self.open_all_models_popup(other_presets); + return; + } + + auto_presets.sort_by_key(|preset| Self::auto_model_order(&preset.model)); + let mut items: Vec = auto_presets + .into_iter() + .map(|preset| { + let description = + (!preset.description.is_empty()).then_some(preset.description.clone()); + let model = preset.model.clone(); + let should_prompt_plan_mode_scope = self.should_prompt_plan_mode_reasoning_scope( + model.as_str(), + Some(preset.default_reasoning_effort), + ); + let actions = Self::model_selection_actions( + model.clone(), + Some(preset.default_reasoning_effort), + should_prompt_plan_mode_scope, + ); + SelectionItem { + name: model.clone(), + description, + is_current: model.as_str() == current_model, + is_default: preset.is_default, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + if !other_presets.is_empty() { + let all_models = other_presets; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenAllModelsPopup { + models: all_models.clone(), + }); + })]; + + let is_current = !items.iter().any(|item| item.is_current); + let description = Some(format!( + "Choose a specific model and reasoning level (current: {current_label})" + )); + + items.push(SelectionItem { + name: "All models".to_string(), + description, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + let header = self.model_menu_header( + "Select Model", + "Pick a quick auto mode or browse all models.", + ); + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header, + ..Default::default() + }); + } + + fn is_auto_model(model: &str) -> bool { + model.starts_with("codex-auto-") + } + + fn auto_model_order(model: &str) -> usize { + match model { + "codex-auto-fast" => 0, + "codex-auto-balanced" => 1, + "codex-auto-thorough" => 2, + _ => 3, + } + } + + pub(crate) fn open_all_models_popup(&mut self, presets: Vec) { + if presets.is_empty() { + self.add_info_message( + "No additional models are available right now.".to_string(), + /*hint*/ None, + ); + return; + } + + let mut items: Vec = Vec::new(); + for preset in presets.into_iter() { + let description = + (!preset.description.is_empty()).then_some(preset.description.to_string()); + let is_current = preset.model.as_str() == self.current_model(); + let single_supported_effort = preset.supported_reasoning_efforts.len() == 1; + let preset_for_action = preset.clone(); + let actions: Vec = vec![Box::new(move |tx| { + let preset_for_event = preset_for_action.clone(); + tx.send(AppEvent::OpenReasoningPopup { + model: preset_for_event, + }); + })]; + items.push(SelectionItem { + name: preset.model.clone(), + description, + is_current, + is_default: preset.is_default, + actions, + dismiss_on_select: single_supported_effort, + dismiss_parent_on_child_accept: !single_supported_effort, + ..Default::default() + }); + } + + let header = self.model_menu_header( + "Select Model and Effort", + "Access legacy models by running codex -m or in your config.toml", + ); + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(self.bottom_pane.standard_popup_hint_line()), + items, + header, + ..Default::default() + }); + } + + pub(crate) fn open_collaboration_modes_popup(&mut self) { + let presets = collaboration_modes::presets_for_tui(self.model_catalog.as_ref()); + if presets.is_empty() { + self.add_info_message( + "No collaboration modes are available right now.".to_string(), + /*hint*/ None, + ); + return; + } + + let current_kind = self + .active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .or_else(|| { + collaboration_modes::default_mask(self.model_catalog.as_ref()) + .and_then(|mask| mask.mode) + }); + let items: Vec = presets + .into_iter() + .map(|mask| { + let name = mask.name.clone(); + let is_current = current_kind == mask.mode; + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateCollaborationMode(mask.clone())); + })]; + SelectionItem { + name, + is_current, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Select Collaboration Mode".to_string()), + subtitle: Some("Pick a collaboration preset.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + fn model_selection_actions( + model_for_action: String, + effort_for_action: Option, + should_prompt_plan_mode_scope: bool, + ) -> Vec { + vec![Box::new(move |tx| { + if should_prompt_plan_mode_scope { + tx.send(AppEvent::OpenPlanReasoningScopePrompt { + model: model_for_action.clone(), + effort: effort_for_action, + }); + return; + } + + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort_for_action)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: effort_for_action, + }); + })] + } + + fn should_prompt_plan_mode_reasoning_scope( + &self, + selected_model: &str, + selected_effort: Option, + ) -> bool { + if !self.collaboration_modes_enabled() + || self.active_mode_kind() != ModeKind::Plan + || selected_model != self.current_model() + { + return false; + } + + // Prompt whenever the selection is not a true no-op for both: + // 1) the active Plan-mode effective reasoning, and + // 2) the stored global defaults that would be updated by the fallback path. + selected_effort != self.effective_reasoning_effort() + || selected_model != self.current_collaboration_mode.model() + || selected_effort != self.current_collaboration_mode.reasoning_effort() + } + + pub(crate) fn open_plan_reasoning_scope_prompt( + &mut self, + model: String, + effort: Option, + ) { + let reasoning_phrase = match effort { + Some(ReasoningEffortConfig::None) => "no reasoning".to_string(), + Some(selected_effort) => { + format!( + "{} reasoning", + Self::reasoning_effort_label(selected_effort).to_lowercase() + ) + } + None => "the selected reasoning".to_string(), + }; + let plan_only_description = format!("Always use {reasoning_phrase} in Plan mode."); + let plan_reasoning_source = if let Some(plan_override) = + self.config.plan_mode_reasoning_effort + { + format!( + "user-chosen Plan override ({})", + Self::reasoning_effort_label(plan_override).to_lowercase() + ) + } else if let Some(plan_mask) = collaboration_modes::plan_mask(self.model_catalog.as_ref()) + { + match plan_mask.reasoning_effort.flatten() { + Some(plan_effort) => format!( + "built-in Plan default ({})", + Self::reasoning_effort_label(plan_effort).to_lowercase() + ), + None => "built-in Plan default (no reasoning)".to_string(), + } + } else { + "built-in Plan default".to_string() + }; + let all_modes_description = format!( + "Set the global default reasoning level and the Plan mode override. This replaces the current {plan_reasoning_source}." + ); + let subtitle = format!("Choose where to apply {reasoning_phrase}."); + + let plan_only_actions: Vec = vec![Box::new({ + let model = model.clone(); + move |tx| { + tx.send(AppEvent::UpdateModel(model.clone())); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + } + })]; + let all_modes_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::UpdateModel(model.clone())); + tx.send(AppEvent::UpdateReasoningEffort(effort)); + tx.send(AppEvent::UpdatePlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistPlanModeReasoningEffort(effort)); + tx.send(AppEvent::PersistModelSelection { + model: model.clone(), + effort, + }); + })]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(PLAN_MODE_REASONING_SCOPE_TITLE.to_string()), + subtitle: Some(subtitle), + footer_hint: Some(standard_popup_hint_line()), + items: vec![ + SelectionItem { + name: PLAN_MODE_REASONING_SCOPE_PLAN_ONLY.to_string(), + description: Some(plan_only_description), + actions: plan_only_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: PLAN_MODE_REASONING_SCOPE_ALL_MODES.to_string(), + description: Some(all_modes_description), + actions: all_modes_actions, + dismiss_on_select: true, + ..Default::default() + }, + ], + ..Default::default() + }); + self.notify(Notification::PlanModePrompt { + title: PLAN_MODE_REASONING_SCOPE_TITLE.to_string(), + }); + } + + /// Open a popup to choose the reasoning effort (stage 2) for the given model. + pub(crate) fn open_reasoning_popup(&mut self, preset: ModelPreset) { + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + let supported = preset.supported_reasoning_efforts; + let in_plan_mode = + self.collaboration_modes_enabled() && self.active_mode_kind() == ModeKind::Plan; + + let warn_effort = if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::XHigh) + { + Some(ReasoningEffortConfig::XHigh) + } else if supported + .iter() + .any(|option| option.effort == ReasoningEffortConfig::High) + { + Some(ReasoningEffortConfig::High) + } else { + None + }; + let warning_text = warn_effort.map(|effort| { + let effort_label = Self::reasoning_effort_label(effort); + format!("⚠ {effort_label} reasoning effort can quickly consume Plus plan rate limits.") + }); + let warn_for_model = preset.model.starts_with("gpt-5.1-codex") + || preset.model.starts_with("gpt-5.1-codex-max") + || preset.model.starts_with("gpt-5.2"); + + struct EffortChoice { + stored: Option, + display: ReasoningEffortConfig, + } + let mut choices: Vec = Vec::new(); + for effort in ReasoningEffortConfig::iter() { + if supported.iter().any(|option| option.effort == effort) { + choices.push(EffortChoice { + stored: Some(effort), + display: effort, + }); + } + } + if choices.is_empty() { + choices.push(EffortChoice { + stored: Some(default_effort), + display: default_effort, + }); + } + + if choices.len() == 1 { + let selected_effort = choices.first().and_then(|c| c.stored); + let selected_model = preset.model; + if self.should_prompt_plan_mode_reasoning_scope(&selected_model, selected_effort) { + self.app_event_tx + .send(AppEvent::OpenPlanReasoningScopePrompt { + model: selected_model, + effort: selected_effort, + }); + } else { + self.apply_model_and_effort(selected_model, selected_effort); + } + return; + } + + let default_choice: Option = choices + .iter() + .any(|choice| choice.stored == Some(default_effort)) + .then_some(Some(default_effort)) + .flatten() + .or_else(|| choices.iter().find_map(|choice| choice.stored)) + .or(Some(default_effort)); + + let model_slug = preset.model.to_string(); + let is_current_model = self.current_model() == preset.model.as_str(); + let highlight_choice = if is_current_model { + if in_plan_mode { + self.config + .plan_mode_reasoning_effort + .or(self.effective_reasoning_effort()) + } else { + self.effective_reasoning_effort() + } + } else { + default_choice + }; + let selection_choice = highlight_choice.or(default_choice); + let initial_selected_idx = choices + .iter() + .position(|choice| choice.stored == selection_choice) + .or_else(|| { + selection_choice + .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) + }); + let mut items: Vec = Vec::new(); + for choice in choices.iter() { + let effort = choice.display; + let mut effort_label = Self::reasoning_effort_label(effort).to_string(); + if choice.stored == default_choice { + effort_label.push_str(" (default)"); + } + + let description = choice + .stored + .and_then(|effort| { + supported + .iter() + .find(|option| option.effort == effort) + .map(|option| option.description.to_string()) + }) + .filter(|text| !text.is_empty()); + + let show_warning = warn_for_model && warn_effort == Some(effort); + let selected_description = if show_warning { + warning_text.as_ref().map(|warning_message| { + description.as_ref().map_or_else( + || warning_message.clone(), + |d| format!("{d}\n{warning_message}"), + ) + }) + } else { + None + }; + + let model_for_action = model_slug.clone(); + let choice_effort = choice.stored; + let should_prompt_plan_mode_scope = + self.should_prompt_plan_mode_reasoning_scope(model_slug.as_str(), choice_effort); + let actions: Vec = vec![Box::new(move |tx| { + if should_prompt_plan_mode_scope { + tx.send(AppEvent::OpenPlanReasoningScopePrompt { + model: model_for_action.clone(), + effort: choice_effort, + }); + } else { + tx.send(AppEvent::UpdateModel(model_for_action.clone())); + tx.send(AppEvent::UpdateReasoningEffort(choice_effort)); + tx.send(AppEvent::PersistModelSelection { + model: model_for_action.clone(), + effort: choice_effort, + }); + } + })]; + + items.push(SelectionItem { + name: effort_label, + description, + selected_description, + is_current: is_current_model && choice.stored == highlight_choice, + actions, + dismiss_on_select: true, + ..Default::default() + }); + } + + let mut header = ColumnRenderable::new(); + header.push(Line::from( + format!("Select Reasoning Level for {model_slug}").bold(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx, + ..Default::default() + }); + } + + pub(super) fn reasoning_effort_label(effort: ReasoningEffortConfig) -> &'static str { + match effort { + ReasoningEffortConfig::None => "None", + ReasoningEffortConfig::Minimal => "Minimal", + ReasoningEffortConfig::Low => "Low", + ReasoningEffortConfig::Medium => "Medium", + ReasoningEffortConfig::High => "High", + ReasoningEffortConfig::XHigh => "Extra high", + } + } + + pub(super) fn apply_model_and_effort_without_persist( + &self, + model: String, + effort: Option, + ) { + self.app_event_tx.send(AppEvent::UpdateModel(model)); + self.app_event_tx + .send(AppEvent::UpdateReasoningEffort(effort)); + } + + fn apply_model_and_effort(&self, model: String, effort: Option) { + self.apply_model_and_effort_without_persist(model.clone(), effort); + self.app_event_tx + .send(AppEvent::PersistModelSelection { model, effort }); + } +} diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs new file mode 100644 index 0000000000..dc428b4092 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -0,0 +1,463 @@ +//! Permission and approval popup flows for `ChatWidget`. +//! +//! This module owns the generic permission pickers and confirmation surfaces; +//! Windows-specific sandbox prompting lives beside it in +//! `windows_sandbox_prompts`. + +use super::*; + +impl ChatWidget { + /// Open the permissions popup. + pub(crate) fn open_approvals_popup(&mut self) { + self.open_permissions_popup(); + } + + /// Open a popup to choose the permissions mode. + pub(crate) fn open_permissions_popup(&mut self) { + let include_read_only = cfg!(target_os = "windows"); + let current_approval = + AskForApproval::from(self.config.permissions.approval_policy.value()); + let current_permission_profile = self.config.permissions.permission_profile(); + let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); + let current_review_policy = self.config.approvals_reviewer; + let mut items: Vec = Vec::new(); + let presets: Vec = builtin_approval_presets(); + + #[cfg(target_os = "windows")] + let windows_sandbox_level = WindowsSandboxLevel::from_config(&self.config); + #[cfg(target_os = "windows")] + let windows_degraded_sandbox_enabled = + matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); + #[cfg(not(target_os = "windows"))] + let windows_degraded_sandbox_enabled = false; + + let show_elevate_sandbox_hint = + crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && windows_degraded_sandbox_enabled + && presets.iter().any(|preset| preset.id == "auto"); + + let guardian_disabled_reason = |enabled: bool| { + let mut next_features = self.config.features.get().clone(); + next_features.set_enabled(Feature::GuardianApproval, enabled); + self.config + .features + .can_set(&next_features) + .err() + .map(|err| err.to_string()) + }; + + for preset in presets.into_iter() { + if !include_read_only && preset.id == "read-only" { + continue; + } + let base_name = if preset.id == "auto" && windows_degraded_sandbox_enabled { + "Default (non-admin sandbox)".to_string() + } else { + preset.label.to_string() + }; + let preset_approval = AskForApproval::from(preset.approval); + let base_description = + Some(preset.description.replace(" (Identical to Agent mode)", "")); + let approval_disabled_reason = match self + .config + .permissions + .approval_policy + .can_set(&preset.approval) + { + Ok(()) => None, + Err(err) => Some(err.to_string()), + }; + let default_disabled_reason = approval_disabled_reason + .clone() + .or_else(|| guardian_disabled_reason(false)); + let requires_confirmation = preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + let default_actions: Vec = if requires_confirmation { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset_clone.clone(), + return_to_permissions: !include_read_only, + }); + })] + } else if preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if WindowsSandboxLevel::from_config(&self.config) + == WindowsSandboxLevel::Disabled + { + let preset_clone = preset.clone(); + if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) + { + vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + mode: WindowsSandboxEnableMode::Elevated, + }); + })] + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + }); + })] + } + } else if let Some((sample_paths, extra_count, failed_scan)) = + self.world_writable_warning_details() + { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset_clone.clone()), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, + }); + })] + } else { + Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + base_name.clone(), + ApprovalsReviewer::User, + ) + } + } + #[cfg(not(target_os = "windows"))] + { + Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + base_name.clone(), + ApprovalsReviewer::User, + ) + } + } else { + Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + base_name.clone(), + ApprovalsReviewer::User, + ) + }; + if preset.id == "auto" { + items.push(SelectionItem { + name: base_name.clone(), + description: base_description.clone(), + is_current: current_review_policy == ApprovalsReviewer::User + && Self::preset_matches_current( + current_approval, + ¤t_permission_profile, + self.config.cwd.as_path(), + &preset, + ), + actions: default_actions, + dismiss_on_select: true, + disabled_reason: default_disabled_reason, + ..Default::default() + }); + + if guardian_approval_enabled { + items.push(SelectionItem { + name: "Auto-review".to_string(), + description: Some( + "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent." + .to_string(), + ), + is_current: current_review_policy == ApprovalsReviewer::AutoReview + && Self::preset_matches_current( + current_approval, + ¤t_permission_profile, + self.config.cwd.as_path(), + &preset, + ), + actions: Self::approval_preset_actions( + preset_approval, + preset.permission_profile.clone(), + "Auto-review".to_string(), + ApprovalsReviewer::AutoReview, + ), + dismiss_on_select: true, + disabled_reason: approval_disabled_reason + .or_else(|| guardian_disabled_reason(true)), + ..Default::default() + }); + } + } else { + items.push(SelectionItem { + name: base_name, + description: base_description, + is_current: Self::preset_matches_current( + current_approval, + ¤t_permission_profile, + self.config.cwd.as_path(), + &preset, + ), + actions: default_actions, + dismiss_on_select: true, + disabled_reason: default_disabled_reason, + ..Default::default() + }); + } + } + + let footer_note = show_elevate_sandbox_hint.then(|| { + vec![ + "The non-admin sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the default sandbox, run ".dim(), + "/setup-default-sandbox".cyan(), + ".".dim(), + ] + .into() + }); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Update Model Permissions".to_string()), + footer_note, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(()), + ..Default::default() + }); + } + + pub(crate) fn open_auto_review_denials_popup(&mut self) { + if self.review.recent_auto_review_denials.is_empty() { + self.add_info_message( + "No recent auto-review denials in this thread.".to_string(), + Some("Denials are recorded after auto-review rejects an action.".to_string()), + ); + return; + } + let Some(thread_id) = self.thread_id() else { + self.add_error_message("That thread is no longer available.".to_string()); + return; + }; + + let mut items = vec![SelectionItem { + name: "Command".to_string(), + description: Some("Rationale".to_string()), + is_disabled: true, + search_value: Some(String::new()), + ..Default::default() + }]; + items.extend( + self.review + .recent_auto_review_denials + .entries() + .map(|event| { + let id = event.id.clone(); + let summary = auto_review_denials::action_summary(&event.action); + let rationale = event + .rationale + .as_deref() + .unwrap_or("Auto-review did not include a rationale."); + SelectionItem { + name: summary.clone(), + description: Some(rationale.to_string()), + selected_description: Some(rationale.to_string()), + search_value: Some(format!("{summary} {rationale}")), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::ApproveRecentAutoReviewDenial { + thread_id, + id: id.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + } + }), + ); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Auto-review Denials".to_string()), + subtitle: Some("Select a denied action to approve.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + col_width_mode: ColumnWidthMode::AutoAllRows, + ..Default::default() + }); + self.request_redraw(); + } + + pub(crate) fn approve_recent_auto_review_denial(&mut self, thread_id: ThreadId, id: String) { + let Some(event) = self.review.recent_auto_review_denials.take(&id) else { + self.add_error_message("That auto-review denial is no longer available.".to_string()); + return; + }; + + self.app_event_tx.send(AppEvent::SubmitThreadOp { + thread_id, + op: AppCommand::approve_guardian_denied_action(event), + }); + self.add_info_message( + "Approval recorded for one retry of the selected auto-review denial.".to_string(), + Some( + "The model will see the approval context; the retry still goes through auto-review." + .to_string(), + ), + ); + } + + pub(super) fn approval_preset_actions( + approval: AskForApproval, + permission_profile: PermissionProfile, + label: String, + approvals_reviewer: ApprovalsReviewer, + ) -> Vec { + vec![Box::new(move |tx| { + let permission_profile_clone = permission_profile.clone(); + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + Some(approval), + Some(approvals_reviewer), + Some(permission_profile_clone.clone()), + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); + tx.send(AppEvent::UpdatePermissionProfile(permission_profile_clone)); + tx.send(AppEvent::UpdateApprovalsReviewer(approvals_reviewer)); + tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + format!("Permissions updated to {label}"), + /*hint*/ None, + ), + ))); + })] + } + + pub(super) fn preset_matches_current( + current_approval: AskForApproval, + current_permission_profile: &PermissionProfile, + cwd: &std::path::Path, + preset: &ApprovalPreset, + ) -> bool { + let preset_approval = AskForApproval::from(preset.approval); + if current_approval != preset_approval { + return false; + } + + match preset.id { + "full-access" => matches!(current_permission_profile, PermissionProfile::Disabled), + "read-only" => { + let file_system_policy = current_permission_profile.file_system_sandbox_policy(); + matches!( + current_permission_profile, + PermissionProfile::Managed { .. } + ) && !file_system_policy.has_full_disk_write_access() + && file_system_policy + .get_writable_roots_with_cwd(cwd) + .is_empty() + && current_permission_profile.network_sandbox_policy() + == preset.permission_profile.network_sandbox_policy() + } + "auto" => { + let file_system_policy = current_permission_profile.file_system_sandbox_policy(); + matches!( + current_permission_profile, + PermissionProfile::Managed { .. } + ) && file_system_policy.can_write_path_with_cwd(cwd, cwd) + && !file_system_policy.has_full_disk_write_access() + && current_permission_profile.network_sandbox_policy() + == preset.permission_profile.network_sandbox_policy() + } + _ => current_permission_profile == &preset.permission_profile, + } + } + + pub(crate) fn open_full_access_confirmation( + &mut self, + preset: ApprovalPreset, + return_to_permissions: bool, + ) { + let selected_name = preset.label.to_string(); + let approval = AskForApproval::from(preset.approval); + let permission_profile = preset.permission_profile; + let mut header_children: Vec> = Vec::new(); + let title_line = Line::from("Enable full access?").bold(); + let info_line = Line::from(vec![ + "When Codex runs with full access, it can edit any file on your computer and run commands with network, without your approval. " + .into(), + "Exercise caution when enabling full access. This significantly increases the risk of data loss, leaks, or unexpected behavior." + .fg(Color::Red), + ]); + header_children.push(Box::new(title_line)); + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + let header = ColumnRenderable::with(header_children); + + let mut accept_actions = Self::approval_preset_actions( + approval, + permission_profile.clone(), + selected_name.clone(), + ApprovalsReviewer::User, + ); + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + })); + + let mut accept_and_remember_actions = Self::approval_preset_actions( + approval, + permission_profile, + selected_name, + ApprovalsReviewer::User, + ); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); + tx.send(AppEvent::PersistFullAccessWarningAcknowledged); + })); + + let deny_actions: Vec = vec![Box::new(move |tx| { + if return_to_permissions { + tx.send(AppEvent::OpenPermissionsPopup); + } else { + tx.send(AppEvent::OpenApprovalsPopup); + } + })]; + + let items = vec![ + SelectionItem { + name: "Yes, continue anyway".to_string(), + description: Some("Apply full access for this session".to_string()), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Yes, and don't ask again".to_string(), + description: Some("Enable full access and remember this choice".to_string()), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Cancel".to_string(), + description: Some("Go back without enabling full access".to_string()), + actions: deny_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } +} diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 8d5bd89aee..6b5414536d 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -1,5 +1,6 @@ -//! Rate-limit warning and prompt state for `ChatWidget`. +//! Rate-limit warning, prompt, and notice surfaces for `ChatWidget`. +use super::*; use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo; pub(super) const NUDGE_MODEL_SLUG: &str = "gpt-5.4-mini"; @@ -125,3 +126,327 @@ pub(super) fn app_server_rate_limit_error_kind( pub(super) fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { matches!(info, AppServerCodexErrorInfo::CyberPolicy) } + +impl ChatWidget { + pub(crate) fn on_rate_limit_snapshot(&mut self, snapshot: Option) { + if let Some(mut snapshot) = snapshot { + let limit_id = snapshot + .limit_id + .clone() + .unwrap_or_else(|| "codex".to_string()); + let limit_label = snapshot + .limit_name + .clone() + .unwrap_or_else(|| limit_id.clone()); + if snapshot.credits.is_none() { + snapshot.credits = self + .rate_limit_snapshots_by_limit_id + .get(&limit_id) + .and_then(|display| display.credits.as_ref()) + .map(|credits| CreditsSnapshot { + has_credits: credits.has_credits, + unlimited: credits.unlimited, + balance: credits.balance.clone(), + }); + } + + self.plan_type = snapshot.plan_type.or(self.plan_type); + + let is_codex_limit = limit_id.eq_ignore_ascii_case("codex"); + if is_codex_limit + && let Some(rate_limit_reached_type) = snapshot.rate_limit_reached_type + { + self.codex_rate_limit_reached_type = Some(rate_limit_reached_type); + } + let warnings = if is_codex_limit { + self.rate_limit_warnings.take_warnings( + snapshot + .secondary + .as_ref() + .map(|window| f64::from(window.used_percent)), + snapshot + .secondary + .as_ref() + .and_then(|window| window.window_duration_mins), + snapshot + .primary + .as_ref() + .map(|window| f64::from(window.used_percent)), + snapshot + .primary + .as_ref() + .and_then(|window| window.window_duration_mins), + ) + } else { + vec![] + }; + + let high_usage = is_codex_limit + && (snapshot + .secondary + .as_ref() + .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false) + || snapshot + .primary + .as_ref() + .map(|w| f64::from(w.used_percent) >= RATE_LIMIT_SWITCH_PROMPT_THRESHOLD) + .unwrap_or(false)); + + let has_workspace_credits = snapshot + .credits + .as_ref() + .map(|credits| credits.has_credits) + .unwrap_or(false); + + if high_usage + && !has_workspace_credits + && !self.rate_limit_switch_prompt_hidden() + && self.current_model() != NUDGE_MODEL_SLUG + && !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Shown + ) + { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Pending; + } + + let display = + rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now()); + self.rate_limit_snapshots_by_limit_id + .insert(limit_id, display); + + if !warnings.is_empty() { + for warning in warnings { + self.add_to_history(history_cell::new_warning_event(warning)); + } + self.request_redraw(); + } + } else { + self.rate_limit_snapshots_by_limit_id.clear(); + self.codex_rate_limit_reached_type = None; + } + self.refresh_status_line(); + } + + pub(super) fn stop_rate_limit_poller(&mut self) {} + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn prefetch_rate_limits(&mut self) { + self.stop_rate_limit_poller(); + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(super) fn should_prefetch_rate_limits(&self) -> bool { + self.config.model_provider.requires_openai_auth && self.has_chatgpt_account + } + + fn lower_cost_preset(&self) -> Option { + let models = self.model_catalog.try_list_models().ok()?; + models + .iter() + .find(|preset| preset.show_in_picker && preset.model == NUDGE_MODEL_SLUG) + .cloned() + } + + fn rate_limit_switch_prompt_hidden(&self) -> bool { + self.config + .notices + .hide_rate_limit_model_nudge + .unwrap_or(false) + } + + pub(super) fn maybe_show_pending_rate_limit_prompt(&mut self) { + if self.rate_limit_switch_prompt_hidden() { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + return; + } + if !matches!( + self.rate_limit_switch_prompt, + RateLimitSwitchPromptState::Pending + ) { + return; + } + if let Some(preset) = self.lower_cost_preset() { + self.open_rate_limit_switch_prompt(preset); + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Shown; + } else { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } + + fn open_rate_limit_switch_prompt(&mut self, preset: ModelPreset) { + let switch_model = preset.model; + let switch_model_for_events = switch_model.clone(); + let default_effort: ReasoningEffortConfig = preset.default_reasoning_effort; + + let switch_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*windows_sandbox_level*/ None, + Some(switch_model_for_events.clone()), + Some(Some(default_effort)), + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + tx.send(AppEvent::UpdateModel(switch_model_for_events.clone())); + tx.send(AppEvent::UpdateReasoningEffort(Some(default_effort))); + })]; + + let keep_actions: Vec = Vec::new(); + let never_actions: Vec = vec![Box::new(|tx| { + tx.send(AppEvent::UpdateRateLimitSwitchPromptHidden(true)); + tx.send(AppEvent::PersistRateLimitSwitchPromptHidden); + })]; + let description = if preset.description.is_empty() { + Some("Uses fewer credits for upcoming turns.".to_string()) + } else { + Some(preset.description) + }; + + let items = vec![ + SelectionItem { + name: format!("Switch to {switch_model}"), + description, + selected_description: None, + is_current: false, + actions: switch_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model".to_string(), + description: None, + selected_description: None, + is_current: false, + actions: keep_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Keep current model (never show again)".to_string(), + description: Some( + "Hide future rate limit reminders about switching models.".to_string(), + ), + selected_description: None, + is_current: false, + actions: never_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Approaching rate limits".to_string()), + subtitle: Some(format!("Switch to {switch_model} for lower credit usage?")), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(super) fn open_workspace_owner_nudge_prompt( + &mut self, + credit_type: AddCreditsNudgeCreditType, + ) { + if self.add_credits_nudge_email_in_flight.is_some() { + return; + } + + let (title, prompt) = match credit_type { + AddCreditsNudgeCreditType::Credits => ( + "You've reached your workspace credit limit", + "Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?", + ), + AddCreditsNudgeCreditType::UsageLimit => ( + "Usage limit reached", + "Request a limit increase from your owner to continue using codex. Request increase?", + ), + }; + let send_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::SendAddCreditsNudgeEmail { credit_type }); + })]; + let items = vec![ + SelectionItem { + name: "Yes".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))), + actions: send_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "No".to_string(), + display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))), + is_default: true, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some(title.to_string()), + subtitle: Some(prompt.to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + initial_selected_idx: Some(1), + ..Default::default() + }); + } + + pub(crate) fn start_add_credits_nudge_email_request( + &mut self, + credit_type: AddCreditsNudgeCreditType, + ) -> bool { + self.add_credits_nudge_email_in_flight = Some(credit_type); + true + } + + pub(crate) fn finish_add_credits_nudge_email_request( + &mut self, + result: Result, + ) { + let credit_type = self + .add_credits_nudge_email_in_flight + .take() + .unwrap_or(AddCreditsNudgeCreditType::Credits); + let message = match (credit_type, result) { + (AddCreditsNudgeCreditType::Credits, Ok(AddCreditsNudgeEmailStatus::Sent)) => { + "Workspace owner notified." + } + ( + AddCreditsNudgeCreditType::Credits, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "Workspace owner was already notified recently.", + (AddCreditsNudgeCreditType::Credits, Err(_)) => { + "Could not notify your workspace owner. Please try again." + } + (AddCreditsNudgeCreditType::UsageLimit, Ok(AddCreditsNudgeEmailStatus::Sent)) => { + "Limit increase requested." + } + ( + AddCreditsNudgeCreditType::UsageLimit, + Ok(AddCreditsNudgeEmailStatus::CooldownActive), + ) => "A limit increase was already requested recently.", + (AddCreditsNudgeCreditType::UsageLimit, Err(_)) => { + "Could not request a limit increase. Please try again." + } + }; + self.add_to_history(history_cell::new_info_event( + message.to_string(), + /*hint*/ None, + )); + self.request_redraw(); + } + + pub(crate) fn set_rate_limit_switch_prompt_hidden(&mut self, hidden: bool) { + self.config.notices.hide_rate_limit_model_nudge = Some(hidden); + if hidden { + self.rate_limit_switch_prompt = RateLimitSwitchPromptState::Idle; + } + } +} diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs new file mode 100644 index 0000000000..419070ef9e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -0,0 +1,611 @@ +//! Runtime settings state and model/collaboration coordination for `ChatWidget`. + +use super::*; + +impl ChatWidget { + /// Set the approval policy in the widget's config copy. + pub(crate) fn set_approval_policy(&mut self, policy: AskForApproval) { + if let Err(err) = self + .config + .permissions + .approval_policy + .set(policy.to_core()) + { + tracing::warn!(%err, "failed to set approval_policy on chat config"); + } else { + self.refresh_status_surfaces(); + } + } + + /// Set the permission profile in the widget's config copy. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_permission_profile( + &mut self, + profile: PermissionProfile, + ) -> ConstraintResult<()> { + self.config.permissions.set_permission_profile(profile)?; + self.refresh_status_surfaces(); + Ok(()) + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { + self.config.permissions.windows_sandbox_mode = mode; + #[cfg(target_os = "windows")] + self.bottom_pane.set_windows_degraded_sandbox_active( + crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ), + ); + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) -> bool { + if let Err(err) = self.config.features.set_enabled(feature, enabled) { + tracing::warn!( + error = %err, + feature = feature.key(), + "failed to update constrained chat widget feature state" + ); + } + let enabled = self.config.features.enabled(feature); + if feature == Feature::RealtimeConversation { + let realtime_conversation_enabled = self.realtime_conversation_enabled(); + self.bottom_pane + .set_realtime_conversation_enabled(realtime_conversation_enabled); + self.bottom_pane + .set_audio_device_selection_enabled(self.realtime_audio_device_selection_enabled()); + if !realtime_conversation_enabled && self.realtime_conversation.is_live() { + self.request_realtime_conversation_close(Some( + "Realtime voice mode was closed because the feature was disabled.".to_string(), + )); + } + } + if feature == Feature::FastMode { + self.sync_service_tier_commands(); + } + if feature == Feature::Personality { + self.sync_personality_command_enabled(); + } + if feature == Feature::Plugins { + self.sync_plugins_command_enabled(); + self.refresh_plugin_mentions(); + } + if feature == Feature::Goals { + self.sync_goal_command_enabled(); + if !enabled { + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.turn_lifecycle.goal_status_active_turn_started_at = None; + self.turn_lifecycle.budget_limited_turn_ids.clear(); + self.update_collaboration_mode_indicator(); + } + } + if feature == Feature::MentionsV2 { + self.sync_mentions_v2_enabled(); + } + if feature == Feature::PreventIdleSleep { + self.turn_lifecycle.set_prevent_idle_sleep(enabled); + } + #[cfg(target_os = "windows")] + if matches!( + feature, + Feature::WindowsSandbox | Feature::WindowsSandboxElevated + ) { + self.bottom_pane.set_windows_degraded_sandbox_active( + crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && matches!( + WindowsSandboxLevel::from_config(&self.config), + WindowsSandboxLevel::RestrictedToken + ), + ); + } + enabled + } + + pub(crate) fn set_approvals_reviewer(&mut self, policy: ApprovalsReviewer) { + self.config.approvals_reviewer = policy; + self.refresh_status_surfaces(); + } + + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_full_access_warning = Some(acknowledged); + } + + pub(crate) fn set_world_writable_warning_acknowledged(&mut self, acknowledged: bool) { + self.config.notices.hide_world_writable_warning = Some(acknowledged); + } + + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn world_writable_warning_hidden(&self) -> bool { + self.config + .notices + .hide_world_writable_warning + .unwrap_or(false) + } + + /// Override the reasoning effort used when Plan mode is active. + /// + /// When the active mask is already Plan, the override is applied immediately + /// so the footer reflects it without waiting for the next mode switch. + /// Passing `None` resets to the Plan-mode preset default. + pub(crate) fn set_plan_mode_reasoning_effort(&mut self, effort: Option) { + self.config.plan_mode_reasoning_effort = effort; + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + && mask.mode == Some(ModeKind::Plan) + { + if let Some(effort) = effort { + mask.reasoning_effort = Some(Some(effort)); + } else if let Some(plan_mask) = + collaboration_modes::plan_mask(self.model_catalog.as_ref()) + { + mask.reasoning_effort = plan_mask.reasoning_effort; + } + } + self.refresh_model_dependent_surfaces(); + } + + /// Set the reasoning effort for the non-Plan collaboration mode. + /// + /// Does not touch the active Plan mask — Plan reasoning is controlled + /// exclusively by the Plan preset and `set_plan_mode_reasoning_effort`. + pub(crate) fn set_reasoning_effort(&mut self, effort: Option) { + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + /*model*/ None, + Some(effort), + /*developer_instructions*/ None, + ); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + && mask.mode != Some(ModeKind::Plan) + { + // Generic "global default" updates should not mutate the active Plan mask. + // Plan reasoning is controlled by the Plan preset and Plan-only override updates. + mask.reasoning_effort = Some(effort); + } + self.refresh_model_dependent_surfaces(); + } + + /// Set the personality in the widget's config copy. + pub(crate) fn set_personality(&mut self, personality: Personality) { + self.config.personality = Some(personality); + } + + pub(crate) fn status_account_display(&self) -> Option<&StatusAccountDisplay> { + self.status_account_display.as_ref() + } + + pub(crate) fn runtime_model_provider_base_url(&self) -> Option<&str> { + self.runtime_model_provider_base_url.as_deref() + } + + #[cfg_attr(not(test), allow(dead_code))] + pub(crate) fn model_catalog(&self) -> Arc { + self.model_catalog.clone() + } + + pub(crate) fn current_plan_type(&self) -> Option { + self.plan_type + } + + pub(crate) fn has_chatgpt_account(&self) -> bool { + self.has_chatgpt_account + } + + pub(crate) fn update_account_state( + &mut self, + status_account_display: Option, + plan_type: Option, + has_chatgpt_account: bool, + ) { + self.status_account_display = status_account_display; + self.plan_type = plan_type; + self.has_chatgpt_account = has_chatgpt_account; + self.bottom_pane + .set_connectors_enabled(self.connectors_enabled()); + } + + pub(crate) fn set_realtime_audio_device( + &mut self, + kind: RealtimeAudioDeviceKind, + name: Option, + ) { + match kind { + RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone = name, + RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker = name, + } + } + + /// Set the syntax theme override in the widget's config copy. + pub(crate) fn set_tui_theme(&mut self, theme: Option) { + self.config.tui_theme = theme; + } + + /// Set the model in the widget's config copy and stored collaboration mode. + pub(crate) fn set_model(&mut self, model: &str) { + self.current_collaboration_mode = self.current_collaboration_mode.with_updates( + Some(model.to_string()), + /*effort*/ None, + /*developer_instructions*/ None, + ); + if self.collaboration_modes_enabled() + && let Some(mask) = self.active_collaboration_mask.as_mut() + { + mask.model = Some(model.to_string()); + } + self.refresh_model_dependent_surfaces(); + } + + pub(crate) fn current_model(&self) -> &str { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.model(); + } + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.model.as_deref()) + .unwrap_or_else(|| self.current_collaboration_mode.model()) + } + + pub(crate) fn realtime_conversation_is_live(&self) -> bool { + self.realtime_conversation.is_live() + } + + pub(super) fn current_realtime_audio_device_name( + &self, + kind: RealtimeAudioDeviceKind, + ) -> Option { + match kind { + RealtimeAudioDeviceKind::Microphone => self.config.realtime_audio.microphone.clone(), + RealtimeAudioDeviceKind::Speaker => self.config.realtime_audio.speaker.clone(), + } + } + + pub(super) fn current_realtime_audio_selection_label( + &self, + kind: RealtimeAudioDeviceKind, + ) -> String { + self.current_realtime_audio_device_name(kind) + .unwrap_or_else(|| "System default".to_string()) + } + + pub(super) fn sync_personality_command_enabled(&mut self) { + self.bottom_pane + .set_personality_command_enabled(self.config.features.enabled(Feature::Personality)); + } + + pub(super) fn sync_plugins_command_enabled(&mut self) { + self.bottom_pane + .set_plugins_command_enabled(self.config.features.enabled(Feature::Plugins)); + } + + pub(super) fn sync_goal_command_enabled(&mut self) { + self.bottom_pane + .set_goal_command_enabled(self.config.features.enabled(Feature::Goals)); + } + + pub(super) fn sync_mentions_v2_enabled(&mut self) { + self.bottom_pane + .set_mentions_v2_enabled(self.config.features.enabled(Feature::MentionsV2)); + } + + pub(super) fn current_model_supports_personality(&self) -> bool { + let model = self.current_model(); + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| preset.supports_personality) + }) + .unwrap_or(false) + } + + /// Return whether the effective model currently advertises image-input support. + /// + /// We intentionally default to `true` when model metadata cannot be read so transient catalog + /// failures do not hard-block user input in the UI. + pub(super) fn current_model_supports_images(&self) -> bool { + let model = self.current_model(); + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| preset.input_modalities.contains(&InputModality::Image)) + }) + .unwrap_or(true) + } + + pub(super) fn sync_image_paste_enabled(&mut self) { + let enabled = self.current_model_supports_images(); + self.bottom_pane.set_image_paste_enabled(enabled); + } + + pub(super) fn image_inputs_not_supported_message(&self) -> String { + format!( + "Model {} does not support image inputs. Remove images or switch models.", + self.current_model() + ) + } + + #[allow(dead_code)] // Used in tests + pub(crate) fn current_collaboration_mode(&self) -> &CollaborationMode { + &self.current_collaboration_mode + } + + pub(crate) fn current_reasoning_effort(&self) -> Option { + self.effective_reasoning_effort() + } + + #[cfg(test)] + pub(crate) fn active_collaboration_mode_kind(&self) -> ModeKind { + self.active_mode_kind() + } + + pub(super) fn is_session_configured(&self) -> bool { + self.thread_id.is_some() + } + + pub(super) fn collaboration_modes_enabled(&self) -> bool { + true + } + + /// Returns the dismissal scope that applies to the currently visible draft. + fn plan_mode_nudge_scope(&self) -> PlanModeNudgeScope { + self.thread_id + .map_or(PlanModeNudgeScope::NewThread, PlanModeNudgeScope::Thread) + } + + /// Returns whether the current draft should replace the normal footer with the Plan-mode nudge. + /// + /// `ChatWidget` owns this policy because it can combine lexical draft matching with mode + /// availability, interaction state, and thread-scoped dismissal. `ChatComposer` only renders + /// the resulting visibility bit. Keeping slash and shell drafts out here avoids advertising a + /// mode switch while the user is intentionally composing another local command. + pub(super) fn should_show_plan_mode_nudge(&self) -> bool { + let text = self.bottom_pane.composer_text(); + let trimmed = text.trim_start(); + self.collaboration_modes_enabled() + && collaboration_modes::plan_mask(self.model_catalog.as_ref()).is_some() + && self.active_mode_kind() != ModeKind::Plan + && self.bottom_pane.composer_input_enabled() + && !self.bottom_pane.is_task_running() + && self.bottom_pane.no_modal_or_popup_active() + && !trimmed.starts_with('/') + && !trimmed.starts_with('!') + && contains_plan_keyword(&text) + && !self + .dismissed_plan_mode_nudge_scopes + .contains(&self.plan_mode_nudge_scope()) + } + + /// Synchronizes the footer presentation with the current Plan-mode nudge policy. + pub(super) fn refresh_plan_mode_nudge(&mut self) { + self.bottom_pane + .set_plan_mode_nudge_visible(self.should_show_plan_mode_nudge()); + } + + /// Hides the nudge for the current thread scope until the user changes conversation context. + pub(super) fn dismiss_plan_mode_nudge(&mut self) { + self.dismissed_plan_mode_nudge_scopes + .insert(self.plan_mode_nudge_scope()); + self.refresh_plan_mode_nudge(); + } + + pub(super) fn initial_collaboration_mask( + _config: &Config, + model_catalog: &ModelCatalog, + model_override: Option<&str>, + ) -> Option { + let mut mask = collaboration_modes::default_mask(model_catalog)?; + if let Some(model_override) = model_override { + mask.model = Some(model_override.to_string()); + } + Some(mask) + } + + pub(super) fn active_mode_kind(&self) -> ModeKind { + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.mode) + .unwrap_or(ModeKind::Default) + } + + pub(super) fn effective_reasoning_effort(&self) -> Option { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.reasoning_effort(); + } + let current_effort = self.current_collaboration_mode.reasoning_effort(); + self.active_collaboration_mask + .as_ref() + .and_then(|mask| mask.reasoning_effort) + .unwrap_or(current_effort) + } + + pub(super) fn effective_collaboration_mode(&self) -> CollaborationMode { + if !self.collaboration_modes_enabled() { + return self.current_collaboration_mode.clone(); + } + self.active_collaboration_mask.as_ref().map_or_else( + || self.current_collaboration_mode.clone(), + |mask| self.current_collaboration_mode.apply_mask(mask), + ) + } + + pub(super) fn refresh_model_display(&mut self) { + let effective = self.effective_collaboration_mode(); + self.session_header.set_model(effective.model()); + // Keep composer paste affordances aligned with the currently effective model. + self.sync_image_paste_enabled(); + self.sync_service_tier_commands(); + self.refresh_terminal_title(); + } + + /// Refresh every UI surface that depends on the effective model, reasoning + /// effort, or collaboration mode. + /// + /// Call this at the end of any setter that mutates `current_collaboration_mode`, + /// `active_collaboration_mask`, or per-mode reasoning-effort overrides. + /// Consolidating both refreshes here prevents the bug where callers update the + /// header/title (`refresh_model_display`) but forget the footer status line + /// (`refresh_status_line`). + pub(super) fn refresh_model_dependent_surfaces(&mut self) { + self.refresh_model_display(); + self.refresh_status_line(); + } + + pub(super) fn model_display_name(&self) -> &str { + let model = self.current_model(); + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { + model + } + } + + /// Get the label for the current collaboration mode. + pub(super) fn collaboration_mode_label(&self) -> Option<&'static str> { + if !self.collaboration_modes_enabled() { + return None; + } + let active_mode = self.active_mode_kind(); + active_mode + .is_tui_visible() + .then_some(active_mode.display_name()) + } + + fn collaboration_mode_indicator(&self) -> Option { + if !self.collaboration_modes_enabled() { + return None; + } + match self.active_mode_kind() { + ModeKind::Plan => Some(CollaborationModeIndicator::Plan), + ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => None, + } + } + + pub(super) fn update_collaboration_mode_indicator(&mut self) { + let indicator = self.collaboration_mode_indicator(); + let goal_indicator = if indicator.is_none() { + self.goal_status_indicator(Instant::now()) + } else { + None + }; + self.current_goal_status_indicator = goal_indicator.clone(); + self.bottom_pane.set_collaboration_mode_indicator(indicator); + self.bottom_pane.set_goal_status_indicator(goal_indicator); + } + + pub(super) fn refresh_goal_status_indicator_for_time_tick(&mut self) { + if self.collaboration_mode_indicator().is_some() { + return; + } + let goal_indicator = self.goal_status_indicator(Instant::now()); + if goal_indicator != self.current_goal_status_indicator { + self.current_goal_status_indicator = goal_indicator.clone(); + self.bottom_pane.set_goal_status_indicator(goal_indicator); + } + } + + fn goal_status_indicator(&self, now: Instant) -> Option { + if !self.config.features.enabled(Feature::Goals) { + return None; + } + self.current_goal_status.as_ref().and_then(|state| { + state.indicator(now, self.turn_lifecycle.goal_status_active_turn_started_at) + }) + } + + pub(super) fn on_thread_goal_updated(&mut self, goal: AppThreadGoal, turn_id: Option) { + if let Some(active_thread_id) = self.thread_id + && active_thread_id.to_string() != goal.thread_id + { + return; + } + if !self.config.features.enabled(Feature::Goals) { + self.current_goal_status_indicator = None; + self.current_goal_status = None; + self.update_collaboration_mode_indicator(); + return; + } + if goal.status == AppThreadGoalStatus::BudgetLimited + && let Some(turn_id) = turn_id + { + self.turn_lifecycle.mark_budget_limited(turn_id); + } + self.current_goal_status = Some(GoalStatusState::new(goal, Instant::now())); + self.update_collaboration_mode_indicator(); + } + + /// Cycle to the next collaboration mode variant (Plan -> Default -> Plan). + pub(super) fn cycle_collaboration_mode(&mut self) { + if !self.collaboration_modes_enabled() { + return; + } + + if let Some(next_mask) = collaboration_modes::next_mask( + self.model_catalog.as_ref(), + self.active_collaboration_mask.as_ref(), + ) { + self.set_collaboration_mask(next_mask); + } + } + + /// Update the active collaboration mask. + /// + /// When collaboration modes are enabled and a preset is selected, + /// the current mode is attached to submissions as `Op::UserTurn { collaboration_mode: Some(...) }`. + pub(crate) fn set_collaboration_mask(&mut self, mut mask: CollaborationModeMask) { + if !self.collaboration_modes_enabled() { + return; + } + let previous_mode = self.active_mode_kind(); + let previous_model = self.current_model().to_string(); + let previous_effort = self.effective_reasoning_effort(); + if mask.mode == Some(ModeKind::Plan) + && let Some(effort) = self.config.plan_mode_reasoning_effort + { + mask.reasoning_effort = Some(Some(effort)); + } + if mask.mode == Some(ModeKind::Plan) { + self.dismissed_plan_mode_nudge_scopes + .insert(self.plan_mode_nudge_scope()); + } + self.active_collaboration_mask = Some(mask); + self.update_collaboration_mode_indicator(); + self.refresh_plan_mode_nudge(); + self.refresh_model_dependent_surfaces(); + let next_mode = self.active_mode_kind(); + let next_model = self.current_model(); + let next_effort = self.effective_reasoning_effort(); + if previous_mode != next_mode + && (previous_model != next_model || previous_effort != next_effort) + { + let mut message = format!("Model changed to {next_model}"); + if !next_model.starts_with("codex-auto-") { + let reasoning_label = match next_effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + }; + message.push(' '); + message.push_str(reasoning_label); + } + message.push_str(" for "); + message.push_str(next_mode.display_name()); + message.push_str(" mode."); + self.add_info_message(message, /*hint*/ None); + } + self.request_redraw(); + } +} diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs new file mode 100644 index 0000000000..cdd38ad3a8 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -0,0 +1,285 @@ +//! Settings-adjacent popup surfaces for `ChatWidget`. +//! +//! This keeps theme, personality, audio-device, and experimental-feature UI +//! out of the main orchestration module without changing their event wiring. + +use super::*; + +impl ChatWidget { + pub(super) fn open_theme_picker(&mut self) { + let codex_home = crate::legacy_core::config::find_codex_home().ok(); + let terminal_width = self + .last_rendered_width + .get() + .and_then(|width| u16::try_from(width).ok()); + let params = crate::theme_picker::build_theme_picker_params( + self.config.tui_theme.as_deref(), + codex_home.as_deref(), + terminal_width, + ); + self.bottom_pane.show_selection_view(params); + } + + pub(crate) fn open_personality_popup(&mut self) { + if !self.is_session_configured() { + self.add_info_message( + "Personality selection is disabled until startup completes.".to_string(), + /*hint*/ None, + ); + return; + } + if !self.current_model_supports_personality() { + let current_model = self.current_model(); + self.add_error_message(format!( + "Current model ({current_model}) doesn't support personalities. Try /model to pick a different model." + )); + return; + } + self.open_personality_popup_for_current_model(); + } + + fn open_personality_popup_for_current_model(&mut self) { + let current_personality = self.config.personality.unwrap_or(Personality::Friendly); + let personalities = [Personality::Friendly, Personality::Pragmatic]; + let supports_personality = self.current_model_supports_personality(); + + let items: Vec = personalities + .into_iter() + .map(|personality| { + let name = Self::personality_label(personality).to_string(); + let description = Some(Self::personality_description(personality).to_string()); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + Some(personality), + ))); + tx.send(AppEvent::UpdatePersonality(personality)); + tx.send(AppEvent::PersistPersonalitySelection { personality }); + })]; + SelectionItem { + name, + description, + is_current: current_personality == personality, + is_disabled: !supports_personality, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + let mut header = ColumnRenderable::new(); + header.push(Line::from("Select Personality".bold())); + header.push(Line::from("Choose a communication style for Codex.".dim())); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) fn open_realtime_audio_popup(&mut self) { + let items = [ + RealtimeAudioDeviceKind::Microphone, + RealtimeAudioDeviceKind::Speaker, + ] + .into_iter() + .map(|kind| { + let description = Some(format!( + "Current: {}", + self.current_realtime_audio_selection_label(kind) + )); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::OpenRealtimeAudioDeviceSelection { kind }); + })]; + SelectionItem { + name: kind.title().to_string(), + description, + actions, + dismiss_on_select: true, + ..Default::default() + } + }) + .collect(); + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: Some("Settings".to_string()), + subtitle: Some("Configure settings for Codex.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + #[cfg(not(target_os = "linux"))] + pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { + match list_realtime_audio_device_names(kind) { + Ok(device_names) => { + self.open_realtime_audio_device_selection_with_names(kind, device_names); + } + Err(err) => { + self.add_error_message(format!( + "Failed to load realtime {} devices: {err}", + kind.noun() + )); + } + } + } + + #[cfg(target_os = "linux")] + pub(crate) fn open_realtime_audio_device_selection(&mut self, kind: RealtimeAudioDeviceKind) { + let _ = kind; + } + + #[cfg(not(target_os = "linux"))] + pub(super) fn open_realtime_audio_device_selection_with_names( + &mut self, + kind: RealtimeAudioDeviceKind, + device_names: Vec, + ) { + let current_selection = self.current_realtime_audio_device_name(kind); + let current_available = current_selection + .as_deref() + .is_some_and(|name| device_names.iter().any(|device_name| device_name == name)); + let mut items = vec![SelectionItem { + name: "System default".to_string(), + description: Some("Use your operating system default device.".to_string()), + is_current: current_selection.is_none(), + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { kind, name: None }); + })], + dismiss_on_select: true, + ..Default::default() + }]; + + if let Some(selection) = current_selection.as_deref() + && !current_available + { + items.push(SelectionItem { + name: format!("Unavailable: {selection}"), + description: Some("Configured device is not currently available.".to_string()), + is_current: true, + is_disabled: true, + disabled_reason: Some("Reconnect the device or choose another one.".to_string()), + ..Default::default() + }); + } + + items.extend(device_names.into_iter().map(|device_name| { + let persisted_name = device_name.clone(); + let actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::PersistRealtimeAudioDeviceSelection { + kind, + name: Some(persisted_name.clone()), + }); + })]; + SelectionItem { + is_current: current_selection.as_deref() == Some(device_name.as_str()), + name: device_name, + actions, + dismiss_on_select: true, + ..Default::default() + } + })); + + let mut header = ColumnRenderable::new(); + header.push(Line::from(format!("Select {}", kind.title()).bold())); + header.push(Line::from( + "Saved devices apply to realtime voice only.".dim(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) fn open_realtime_audio_restart_prompt(&mut self, kind: RealtimeAudioDeviceKind) { + let restart_actions: Vec = vec![Box::new(move |tx| { + tx.send(AppEvent::RestartRealtimeAudioDevice { kind }); + })]; + let items = vec![ + SelectionItem { + name: "Restart now".to_string(), + description: Some(format!("Restart local {} audio now.", kind.noun())), + actions: restart_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Apply later".to_string(), + description: Some(format!( + "Keep the current {} until local audio starts again.", + kind.noun() + )), + dismiss_on_select: true, + ..Default::default() + }, + ]; + + let mut header = ColumnRenderable::new(); + header.push(Line::from(format!("Restart {} now?", kind.title()).bold())); + header.push(Line::from( + "Configuration is saved. Restart local audio to use it immediately.".dim(), + )); + + self.bottom_pane.show_selection_view(SelectionViewParams { + header: Box::new(header), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }); + } + + pub(crate) fn open_experimental_popup(&mut self) { + let features: Vec = FEATURES + .iter() + .filter_map(|spec| { + let name = spec.stage.experimental_menu_name()?; + let description = spec.stage.experimental_menu_description()?; + Some(ExperimentalFeatureItem { + feature: spec.id, + name: name.to_string(), + description: description.to_string(), + enabled: self.config.features.enabled(spec.id), + }) + }) + .collect(); + + let view = ExperimentalFeaturesView::new( + features, + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + fn personality_label(personality: Personality) -> &'static str { + match personality { + Personality::None => "None", + Personality::Friendly => "Friendly", + Personality::Pragmatic => "Pragmatic", + } + } + + fn personality_description(personality: Personality) -> &'static str { + match personality { + Personality::None => "No personality instructions.", + Personality::Friendly => "Warm, collaborative, and helpful.", + Personality::Pragmatic => "Concise, task-focused, and direct.", + } + } +} diff --git a/codex-rs/tui/src/chatwidget/status_controls.rs b/codex-rs/tui/src/chatwidget/status_controls.rs new file mode 100644 index 0000000000..70f213bee4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/status_controls.rs @@ -0,0 +1,380 @@ +//! Status output and setup controls for `ChatWidget`. +//! +//! Rendering details live in `status_surfaces`; this module owns the mutable +//! widget entrypoints that apply status state, open setup views, and update the +//! history-facing `/status` surface. + +use super::*; + +impl ChatWidget { + /// Update the status indicator header and details. + /// + /// Passing `None` clears any existing details. + pub(super) fn set_status( + &mut self, + header: String, + details: Option, + details_capitalization: StatusDetailsCapitalization, + details_max_lines: usize, + ) { + let details = details + .filter(|details| !details.is_empty()) + .map(|details| { + let trimmed = details.trim_start(); + match details_capitalization { + StatusDetailsCapitalization::CapitalizeFirst => { + crate::text_formatting::capitalize_first(trimmed) + } + StatusDetailsCapitalization::Preserve => trimmed.to_string(), + } + }); + self.status_state.set_status(StatusIndicatorState { + header: header.clone(), + details: details.clone(), + details_max_lines, + }); + self.bottom_pane.update_status( + header, + details, + StatusDetailsCapitalization::Preserve, + details_max_lines, + ); + let title_uses_status = self + .config + .tui_terminal_title + .as_ref() + .is_some_and(|items| { + items + .iter() + .any(|item| item == "run-state" || item == "status") + }); + if title_uses_status { + self.refresh_status_surfaces(); + } + } + + /// Convenience wrapper around [`Self::set_status`]; + /// updates the status indicator header and clears any existing details. + pub(super) fn set_status_header(&mut self, header: String) { + self.set_status( + header, + /*details*/ None, + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + } + + /// Sets the currently rendered footer status-line value. + pub(crate) fn set_status_line(&mut self, status_line: Option>) { + self.bottom_pane.set_status_line(status_line); + } + + /// Sets the terminal hyperlink target for the currently rendered footer status line. + pub(crate) fn set_status_line_hyperlink(&mut self, url: Option) { + self.bottom_pane.set_status_line_hyperlink(url); + } + + /// Forwards the contextual active-agent label into the bottom-pane footer pipeline. + /// + /// `ChatWidget` stays a pass-through here so `App` remains the owner of "which thread is the + /// user actually looking at?" and the footer stack remains a pure renderer of that decision. + pub(crate) fn set_active_agent_label(&mut self, active_agent_label: Option) { + self.bottom_pane.set_active_agent_label(active_agent_label); + } + + /// Recomputes footer status-line content from config and current runtime state. + /// + /// This method is the status-line orchestrator: it parses configured item identifiers, + /// warns once per session about invalid items, updates whether status-line mode is enabled, + /// schedules async git-branch lookup when needed, and renders only values that are currently + /// available. + /// + /// The omission behavior is intentional. If selected items are unavailable (for example before + /// a session id exists or before branch lookup completes), those items are skipped without + /// placeholders so the line remains compact and stable. + pub(crate) fn refresh_status_line(&mut self) { + self.refresh_status_surfaces(); + } + + /// Records that status-line setup was canceled. + /// + /// Cancellation is intentionally side-effect free for config state; the existing configuration + /// remains active and no persistence is attempted. + pub(crate) fn cancel_status_line_setup(&self) { + tracing::info!("Status line setup canceled by user"); + } + + /// Applies status-line item selection from the setup view to in-memory config. + /// + /// An empty selection persists as an explicit empty list. + pub(crate) fn setup_status_line(&mut self, items: Vec, use_theme_colors: bool) { + tracing::info!( + "status line setup confirmed with items: {items:#?}, use_theme_colors: {use_theme_colors}" + ); + let ids = items.iter().map(ToString::to_string).collect::>(); + self.config.tui_status_line = Some(ids); + self.config.tui_status_line_use_colors = use_theme_colors; + self.refresh_status_line(); + } + + /// Applies a temporary terminal-title selection while the setup UI is open. + pub(crate) fn preview_terminal_title(&mut self, items: Vec) { + if self.terminal_title_setup_original_items.is_none() { + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + } + + let ids = items.iter().map(ToString::to_string).collect::>(); + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); + } + + /// Restores the terminal-title config that was active before the setup UI + /// opened, undoing any preview changes. No-op if no setup session is active. + pub(crate) fn revert_terminal_title_setup_preview(&mut self) { + let Some(original_items) = self.terminal_title_setup_original_items.take() else { + return; + }; + + self.config.tui_terminal_title = original_items; + self.refresh_terminal_title(); + } + + /// Dismisses the terminal-title setup UI and reverts to the pre-setup config. + pub(crate) fn cancel_terminal_title_setup(&mut self) { + tracing::info!("Terminal title setup canceled by user"); + self.revert_terminal_title_setup_preview(); + } + + /// Commits a confirmed terminal-title selection, ending the setup session. + /// + /// After this call, `revert_terminal_title_setup_preview` becomes a no-op + /// because the original config snapshot is discarded. + pub(crate) fn setup_terminal_title(&mut self, items: Vec) { + tracing::info!("terminal title setup confirmed with items: {items:#?}"); + let ids = items.iter().map(ToString::to_string).collect::>(); + self.terminal_title_setup_original_items = None; + self.config.tui_terminal_title = Some(ids); + self.refresh_terminal_title(); + } + + /// Stores async git-branch lookup results for the current status-line cwd. + /// + /// Results are dropped when they target an out-of-date cwd to avoid rendering stale branch + /// names after directory changes. + pub(crate) fn set_status_line_branch(&mut self, cwd: PathBuf, branch: Option) { + if self.status_line_branch_cwd.as_ref() != Some(&cwd) { + self.status_line_branch_pending = false; + return; + } + self.status_line_branch = branch; + self.status_line_branch_pending = false; + self.status_line_branch_lookup_complete = true; + self.refresh_status_surfaces(); + } + + /// Stores async Git summary lookup results for the current status-line cwd. + pub(crate) fn set_status_line_git_summary( + &mut self, + cwd: PathBuf, + summary: StatusLineGitSummary, + ) { + if self.status_line_git_summary_cwd.as_ref() != Some(&cwd) { + self.status_line_git_summary_pending = false; + return; + } + self.status_line_git_summary = Some(summary); + self.status_line_git_summary_pending = false; + self.status_line_git_summary_lookup_complete = true; + self.refresh_status_surfaces(); + } + + pub(crate) fn add_status_output( + &mut self, + refreshing_rate_limits: bool, + request_id: Option, + ) { + let default_usage = TokenUsage::default(); + let token_info = self.token_info.as_ref(); + let total_usage = token_info + .map(|ti| &ti.total_token_usage) + .unwrap_or(&default_usage); + let collaboration_mode = self.collaboration_mode_label(); + let model = self.current_model().to_string(); + let model_default_reasoning_effort = + self.model_catalog + .try_list_models() + .ok() + .and_then(|models| { + models + .into_iter() + .find(|preset| preset.model == model) + .map(|preset| preset.default_reasoning_effort) + }); + let reasoning_effort_override = Some( + self.effective_reasoning_effort() + .or(self.config.model_reasoning_effort) + .or(model_default_reasoning_effort), + ); + let rate_limit_snapshots: Vec = self + .rate_limit_snapshots_by_limit_id + .values() + .cloned() + .collect(); + let agents_summary = + crate::status::compose_agents_summary(&self.config, &self.instruction_source_paths); + let (cell, handle) = crate::status::new_status_output_with_rate_limits_handle( + &self.config, + self.runtime_model_provider_base_url.as_deref(), + self.status_account_display.as_ref(), + token_info, + total_usage, + &self.thread_id, + self.thread_name.clone(), + self.forked_from, + rate_limit_snapshots.as_slice(), + self.plan_type, + Local::now(), + self.model_display_name(), + collaboration_mode, + reasoning_effort_override, + agents_summary, + refreshing_rate_limits, + ); + if let Some(request_id) = request_id { + self.refreshing_status_outputs.push((request_id, handle)); + } + self.add_to_history(cell); + } + + pub(crate) fn finish_status_rate_limit_refresh(&mut self, request_id: u64) { + if self.refreshing_status_outputs.is_empty() { + return; + } + + let rate_limit_snapshots: Vec = self + .rate_limit_snapshots_by_limit_id + .values() + .cloned() + .collect(); + let now = Local::now(); + let mut remaining = Vec::with_capacity(self.refreshing_status_outputs.len()); + let mut updated_any = false; + for (pending_request_id, handle) in self.refreshing_status_outputs.drain(..) { + if pending_request_id == request_id { + updated_any = true; + handle.finish_rate_limit_refresh(rate_limit_snapshots.as_slice(), now); + } else { + remaining.push((pending_request_id, handle)); + } + } + self.refreshing_status_outputs = remaining; + if updated_any { + self.request_redraw(); + } + } + + pub(super) fn open_status_line_setup(&mut self) { + let configured_status_line_items = self.configured_status_line_items(); + let view = StatusLineSetupView::new( + Some(configured_status_line_items.as_slice()), + self.config.tui_status_line_use_colors, + self.status_surface_preview_data(), + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(super) fn open_terminal_title_setup(&mut self) { + let configured_terminal_title_items = self.configured_terminal_title_items(); + self.terminal_title_setup_original_items = Some(self.config.tui_terminal_title.clone()); + let view = TerminalTitleSetupView::new( + Some(configured_terminal_title_items.as_slice()), + self.terminal_title_preview_data(), + self.app_event_tx.clone(), + self.bottom_pane.list_keymap(), + ); + self.bottom_pane.show_view(Box::new(view)); + } + + pub(super) fn status_surface_preview_data(&mut self) -> StatusSurfacePreviewData { + StatusSurfacePreviewData::from_iter(StatusSurfacePreviewItem::iter().filter_map(|item| { + self.status_surface_preview_value_for_item(item) + .map(|value| (item, value)) + })) + } + + pub(super) fn terminal_title_preview_data(&mut self) -> StatusSurfacePreviewData { + let mut preview_data = self.status_surface_preview_data(); + let now = Instant::now(); + for item in TerminalTitleItem::iter() { + let Some(preview_item) = item.preview_item() else { + continue; + }; + let Some(value) = self.terminal_title_value_for_item(item, now) else { + continue; + }; + preview_data.set_live(preview_item, value); + } + preview_data + } + + pub(super) fn status_line_context_window_size(&self) -> Option { + self.token_info + .as_ref() + .and_then(|info| info.model_context_window) + .or(self.config.model_context_window) + } + + pub(super) fn status_line_context_remaining_percent(&self) -> Option { + let Some(context_window) = self.status_line_context_window_size() else { + return Some(100); + }; + let default_usage = TokenUsage::default(); + let usage = self + .token_info + .as_ref() + .map(|info| &info.last_token_usage) + .unwrap_or(&default_usage); + Some( + usage + .percent_of_context_window_remaining(context_window) + .clamp(0, 100), + ) + } + + pub(super) fn status_line_context_used_percent(&self) -> Option { + let remaining = self.status_line_context_remaining_percent().unwrap_or(100); + Some((100 - remaining).clamp(0, 100)) + } + + pub(super) fn status_line_total_usage(&self) -> TokenUsage { + self.token_info + .as_ref() + .map(|info| info.total_token_usage.clone()) + .unwrap_or_default() + } + + pub(super) fn status_line_limit_display( + &self, + window: Option<&RateLimitWindowDisplay>, + label: &str, + ) -> Option { + let window = window?; + let remaining = (100.0f64 - window.used_percent).clamp(0.0f64, 100.0f64); + Some(format!("{label} {remaining:.0}%")) + } + + pub(super) fn status_line_reasoning_effort_label( + effort: Option, + ) -> &'static str { + match effort { + Some(ReasoningEffortConfig::Minimal) => "minimal", + Some(ReasoningEffortConfig::Low) => "low", + Some(ReasoningEffortConfig::Medium) => "medium", + Some(ReasoningEffortConfig::High) => "high", + Some(ReasoningEffortConfig::XHigh) => "xhigh", + None | Some(ReasoningEffortConfig::None) => "default", + } + } +} diff --git a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs index 4f4398d349..379ff7c6de 100644 --- a/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs +++ b/codex-rs/tui/src/chatwidget/tests/popups_and_settings.rs @@ -1,4 +1,6 @@ use super::*; +use crate::app_event::ConnectorsSnapshot; +use crate::chatwidget::connectors::ConnectorsCacheState; use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::HookErrorInfo; use codex_app_server_protocol::HooksListEntry; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 4cc01d7591..53b60e0d8d 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,5 +1,6 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; +use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG; use pretty_assertions::assert_eq; use ratatui::backend::TestBackend; use serial_test::serial; diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs new file mode 100644 index 0000000000..3f6de0879f --- /dev/null +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -0,0 +1,446 @@ +//! Windows sandbox prompts and warning surfaces for `ChatWidget`. + +use super::*; + +impl ChatWidget { + #[cfg(target_os = "windows")] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + if self + .config + .notices + .hide_world_writable_warning + .unwrap_or(false) + { + return None; + } + let cwd = self.config.cwd.clone(); + let env_map: std::collections::HashMap = std::env::vars().collect(); + let Ok(policy) = self + .config + .permissions + .permission_profile() + .to_legacy_sandbox_policy(self.config.cwd.as_path()) + else { + return Some((Vec::new(), 0, true)); + }; + match codex_windows_sandbox::apply_world_writable_scan_and_denies( + self.config.codex_home.as_path(), + cwd.as_path(), + &env_map, + &policy, + Some(self.config.codex_home.as_path()), + ) { + Ok(_) => None, + Err(_) => Some((Vec::new(), 0, true)), + } + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn world_writable_warning_details(&self) -> Option<(Vec, usize, bool)> { + None + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + preset: Option, + sample_paths: Vec, + extra_count: usize, + failed_scan: bool, + ) { + let (approval, permission_profile) = match &preset { + Some(p) => ( + Some(AskForApproval::from(p.approval)), + Some(p.permission_profile.clone()), + ), + None => (None, None), + }; + let mut header_children: Vec> = Vec::new(); + let describe_profile = |profile: &PermissionProfile| { + if matches!(profile, PermissionProfile::Disabled) { + "Full Access mode" + } else if profile + .file_system_sandbox_policy() + .can_write_path_with_cwd(self.config.cwd.as_path(), self.config.cwd.as_path()) + { + "Agent mode" + } else { + "Read-Only mode" + } + }; + let mode_label = preset + .as_ref() + .map(|p| describe_profile(&p.permission_profile)) + .unwrap_or_else(|| describe_profile(&self.config.permissions.permission_profile())); + let info_line = if failed_scan { + Line::from(vec![ + "We couldn't complete the world-writable scan, so protections cannot be verified. " + .into(), + format!("The Windows sandbox cannot guarantee protection in {mode_label}.") + .fg(Color::Red), + ]) + } else { + Line::from(vec![ + "The Windows sandbox cannot protect writes to folders that are writable by Everyone.".into(), + " Consider removing write access for Everyone from the following folders:".into(), + ]) + }; + header_children.push(Box::new( + Paragraph::new(vec![info_line]).wrap(Wrap { trim: false }), + )); + + if !sample_paths.is_empty() { + // Show up to three examples and optionally an "and X more" line. + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + for p in &sample_paths { + lines.push(Line::from(format!(" - {p}"))); + } + if extra_count > 0 { + lines.push(Line::from(format!("and {extra_count} more"))); + } + header_children.push(Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + } + let header = ColumnRenderable::with(header_children); + + // Build actions ensuring acknowledgement happens before applying the + // new permission profile, so downstream policy-change hooks don't + // re-trigger the warning. + let mut accept_actions: Vec = Vec::new(); + // Suppress the immediate re-scan only when a preset will be applied via + // /permissions, to avoid duplicate warnings from the ensuing policy change. + if preset.is_some() { + accept_actions.push(Box::new(|tx| { + tx.send(AppEvent::SkipNextWorldWritableScan); + })); + } + if let (Some(approval), Some(permission_profile)) = (approval, permission_profile.clone()) { + accept_actions.extend(Self::approval_preset_actions( + approval, + permission_profile, + mode_label.to_string(), + ApprovalsReviewer::User, + )); + } + + let mut accept_and_remember_actions: Vec = Vec::new(); + accept_and_remember_actions.push(Box::new(|tx| { + tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); + tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); + })); + if let (Some(approval), Some(permission_profile)) = (approval, permission_profile) { + accept_and_remember_actions.extend(Self::approval_preset_actions( + approval, + permission_profile, + mode_label.to_string(), + ApprovalsReviewer::User, + )); + } + + let items = vec![ + SelectionItem { + name: "Continue".to_string(), + description: Some(format!("Apply {mode_label} for this session")), + actions: accept_actions, + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Continue and don't warn again".to_string(), + description: Some(format!("Enable {mode_label} and remember this choice")), + actions: accept_and_remember_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_world_writable_warning_confirmation( + &mut self, + _preset: Option, + _sample_paths: Vec, + _extra_count: usize, + _failed_scan: bool, + ) { + } + + #[cfg(target_os = "windows")] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { + use ratatui_macros::line; + + if !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { + // Legacy flow (pre-NUX): explain the experimental sandbox and let the user enable it + // directly (no elevation prompts). + let mut header = ColumnRenderable::new(); + header.push(*Box::new( + Paragraph::new(vec![ + line!["Agent mode on Windows uses an experimental sandbox to limit network and filesystem access.".bold()], + line!["Learn more: https://developers.openai.com/codex/windows"], + ]) + .wrap(Wrap { trim: false }), + )); + + let preset_clone = preset; + let items = vec![ + SelectionItem { + name: "Enable experimental sandbox".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + mode: WindowsSandboxEnableMode::Legacy, + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Go back".to_string(), + description: None, + actions: vec![Box::new(|tx| { + tx.send(AppEvent::OpenApprovalsPopup); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + return; + } + + self.session_telemetry.counter( + "codex.windows_sandbox.elevated_prompt_shown", + /*inc*/ 1, + &[], + ); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new( + Paragraph::new(vec![ + line!["Set up the Codex agent sandbox to protect your files and control network access. Learn more "], + ]) + .wrap(Wrap { trim: false }), + )); + + let accept_otel = self.session_telemetry.clone(); + let legacy_otel = self.session_telemetry.clone(); + let legacy_preset = preset.clone(); + let quit_otel = self.session_telemetry.clone(); + let items = vec![ + SelectionItem { + name: "Set up default sandbox (requires Administrator permissions)".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + accept_otel.counter( + "codex.windows_sandbox.elevated_prompt_accept", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use non-admin sandbox (higher risk if prompt injected)".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + legacy_otel.counter( + "codex.windows_sandbox.elevated_prompt_use_legacy", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { + preset: legacy_preset.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Quit".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + quit_otel.counter( + "codex.windows_sandbox.elevated_prompt_quit", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + + #[cfg(target_os = "windows")] + pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) { + use ratatui_macros::line; + + let mut lines = Vec::new(); + lines.push(line![ + "Couldn't set up your sandbox with Administrator permissions".bold() + ]); + lines.push(line![""]); + lines.push(line![ + "You can still use Codex in a non-admin sandbox. It carries greater risk if prompt injected." + ]); + lines.push(line![ + "Learn more " + ]); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + + let elevated_preset = preset.clone(); + let legacy_preset = preset; + let quit_otel = self.session_telemetry.clone(); + let items = vec![ + SelectionItem { + name: "Try setting up admin sandbox again".to_string(), + description: None, + actions: vec![Box::new({ + let otel = self.session_telemetry.clone(); + let preset = elevated_preset; + move |tx| { + otel.counter( + "codex.windows_sandbox.fallback_retry_elevated", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), + }); + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use Codex with non-admin sandbox".to_string(), + description: None, + actions: vec![Box::new({ + let otel = self.session_telemetry.clone(); + let preset = legacy_preset; + move |tx| { + otel.counter( + "codex.windows_sandbox.fallback_use_legacy", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::BeginWindowsSandboxLegacySetup { + preset: preset.clone(), + }); + } + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Quit".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + quit_otel.counter( + "codex.windows_sandbox.fallback_prompt_quit", + /*inc*/ 1, + &[], + ); + tx.send(AppEvent::Exit(ExitMode::ShutdownFirst)); + })], + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {} + + #[cfg(target_os = "windows")] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { + if show_now + && WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled + && let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + { + self.open_windows_sandbox_enable_prompt(preset); + } + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, _show_now: bool) {} + + #[cfg(target_os = "windows")] + pub(crate) fn show_windows_sandbox_setup_status(&mut self) { + // While elevated sandbox setup runs, prevent typing so the user doesn't + // accidentally queue messages that will run under an unexpected mode. + self.bottom_pane.set_composer_input_enabled( + /*enabled*/ false, + Some("Input disabled until setup completes.".to_string()), + ); + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane + .set_interrupt_hint_visible(/*visible*/ false); + self.set_status( + "Setting up sandbox...".to_string(), + Some("Hang tight, this may take a few minutes".to_string()), + StatusDetailsCapitalization::CapitalizeFirst, + STATUS_DETAILS_DEFAULT_MAX_LINES, + ); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} + + #[cfg(target_os = "windows")] + pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { + self.bottom_pane + .set_composer_input_enabled(/*enabled*/ true, /*placeholder*/ None); + self.bottom_pane.hide_status_indicator(); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn clear_windows_sandbox_setup_status(&mut self) {} +} From 3d2a0b5517fada3b368165605a1b098f96d92b6c Mon Sep 17 00:00:00 2001 From: Adrian Date: Wed, 13 May 2026 11:28:52 -0700 Subject: [PATCH 04/47] [codex] Scope Windows sandbox write-root capability SIDs (#21479) ## Summary - fix by scoping Windows workspace-write capability SIDs to active effective write roots - build legacy/elevated tokens from only the active effective write roots - align setup/audit deny ACL handling with active root-specific SIDs ## Testing - just fmt - git diff --check --cached - just argument-comment-lint - cargo check -p codex-windows-sandbox --locked (blocked by libwebrtc -> libyuv fetch: CONNECT tunnel failed, response 403) --- codex-rs/windows-sandbox-rs/src/audit.rs | 80 ++-- .../src/bin/setup_main/win.rs | 246 ++++++++--- codex-rs/windows-sandbox-rs/src/cap.rs | 77 ++++ .../windows-sandbox-rs/src/elevated_impl.rs | 38 +- codex-rs/windows-sandbox-rs/src/lib.rs | 252 ++++------- codex-rs/windows-sandbox-rs/src/setup.rs | 103 ++++- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 418 ++++++++++++++---- codex-rs/windows-sandbox-rs/src/token.rs | 27 ++ .../src/unified_exec/backends/legacy.rs | 56 ++- 9 files changed, 881 insertions(+), 416 deletions(-) diff --git a/codex-rs/windows-sandbox-rs/src/audit.rs b/codex-rs/windows-sandbox-rs/src/audit.rs index c45e3341b7..c7e7fc0b11 100644 --- a/codex-rs/windows-sandbox-rs/src/audit.rs +++ b/codex-rs/windows-sandbox-rs/src/audit.rs @@ -2,14 +2,16 @@ use crate::acl::add_deny_write_ace; use crate::acl::path_mask_allows; use crate::cap::cap_sid_file; use crate::cap::load_or_create_cap_sids; +use crate::cap::workspace_write_cap_sid_for_root; +use crate::cap::workspace_write_root_contains_path; use crate::logging::debug_log; use crate::logging::log_note; use crate::path_normalization::canonical_path_key; use crate::policy::SandboxPolicy; -use crate::token::convert_string_sid_to_sid; +use crate::setup::effective_write_roots_for_setup; +use crate::token::LocalSid; use crate::token::world_sid; use anyhow::Result; -use anyhow::anyhow; use std::collections::HashSet; use std::ffi::OsStr; use std::ffi::c_void; @@ -231,6 +233,7 @@ pub fn apply_world_writable_scan_and_denies( &flagged, sandbox_policy, cwd, + env_map, logs_base_dir, ) { log_note( @@ -246,6 +249,7 @@ pub fn apply_capability_denies_for_world_writable( flagged: &[PathBuf], sandbox_policy: &SandboxPolicy, cwd: &Path, + env_map: &std::collections::HashMap, logs_base_dir: Option<&Path>, ) -> Result<()> { if flagged.is_empty() { @@ -255,46 +259,56 @@ pub fn apply_capability_denies_for_world_writable( let cap_path = cap_sid_file(codex_home); let caps = load_or_create_cap_sids(codex_home)?; std::fs::write(&cap_path, serde_json::to_string(&caps)?)?; - let (active_sid, workspace_roots): (*mut c_void, Vec) = match sandbox_policy { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { - let sid = unsafe { convert_string_sid_to_sid(&caps.workspace) } - .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for workspace capability"))?; - let mut roots: Vec = - vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())]; - for root in writable_roots { - let candidate = root.as_path(); - roots.push(dunce::canonicalize(candidate).unwrap_or_else(|_| root.to_path_buf())); - } - (sid, roots) + let (active_sids, workspace_roots): (Vec, Vec) = match sandbox_policy { + SandboxPolicy::WorkspaceWrite { .. } => { + let roots = effective_write_roots_for_setup( + sandbox_policy, + cwd, + cwd, + env_map, + codex_home, + /*write_roots_override*/ None, + ); + let active_sids = roots + .iter() + .map(|root| { + workspace_write_cap_sid_for_root(codex_home, cwd, root) + .and_then(|sid| LocalSid::from_string(&sid)) + }) + .collect::>>()?; + (active_sids, roots) + } + SandboxPolicy::ReadOnly { .. } => { + (vec![LocalSid::from_string(&caps.readonly)?], Vec::new()) } - SandboxPolicy::ReadOnly { .. } => ( - unsafe { convert_string_sid_to_sid(&caps.readonly) } - .ok_or_else(|| anyhow!("ConvertStringSidToSidW failed for readonly capability"))?, - Vec::new(), - ), SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { return Ok(()); } }; for path in flagged { - if workspace_roots.iter().any(|root| path.starts_with(root)) { + if workspace_roots + .iter() + .any(|root| workspace_write_root_contains_path(root, path)) + { continue; } - let res = unsafe { add_deny_write_ace(path, active_sid) }; - match res { - Ok(true) => log_note( - &format!("AUDIT: applied capability deny ACE to {}", path.display()), - logs_base_dir, - ), - Ok(false) => {} - Err(err) => log_note( - &format!( - "AUDIT: failed to apply capability deny ACE to {}: {}", - path.display(), - err + for active_sid in &active_sids { + let res = unsafe { add_deny_write_ace(path, active_sid.as_ptr()) }; + match res { + Ok(true) => log_note( + &format!("AUDIT: applied capability deny ACE to {}", path.display()), + logs_base_dir, ), - logs_base_dir, - ), + Ok(false) => {} + Err(err) => log_note( + &format!( + "AUDIT: failed to apply capability deny ACE to {}: {}", + path.display(), + err + ), + logs_base_dir, + ), + } } } Ok(()) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs index d862f33eca..1f93d0a5ee 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win.rs @@ -20,7 +20,6 @@ use codex_windows_sandbox::extract_setup_failure; use codex_windows_sandbox::hide_newly_created_users; use codex_windows_sandbox::install_wfp_filters; use codex_windows_sandbox::is_command_cwd_root; -use codex_windows_sandbox::load_or_create_cap_sids; use codex_windows_sandbox::log_note; use codex_windows_sandbox::path_mask_allows; use codex_windows_sandbox::sandbox_bin_dir; @@ -29,7 +28,8 @@ use codex_windows_sandbox::sandbox_secrets_dir; use codex_windows_sandbox::string_from_sid_bytes; use codex_windows_sandbox::sync_persistent_deny_read_acls; use codex_windows_sandbox::to_wide; -use codex_windows_sandbox::workspace_cap_sid_for_cwd; +use codex_windows_sandbox::workspace_write_cap_sid_for_root; +use codex_windows_sandbox::workspace_write_root_overlaps_path; use codex_windows_sandbox::write_setup_error_report; use serde::Deserialize; use serde::Serialize; @@ -120,6 +120,42 @@ fn log_line(log: &mut File, msg: &str) -> Result<()> { Ok(()) } +fn workspace_write_cap_sids_for_path( + codex_home: &Path, + command_cwd: &Path, + write_roots: &[PathBuf], + path: &Path, +) -> Result> { + let mut sid_strs = Vec::new(); + for root in write_roots { + if workspace_write_root_overlaps_path(root, path) { + sid_strs.push(workspace_write_cap_sid_for_root( + codex_home, + command_cwd, + root, + )?); + } + } + if sid_strs.is_empty() { + if write_roots.is_empty() { + sid_strs.push(workspace_write_cap_sid_for_root( + codex_home, + command_cwd, + command_cwd, + )?); + } else { + for root in write_roots { + sid_strs.push(workspace_write_cap_sid_for_root( + codex_home, + command_cwd, + root, + )?); + } + } + } + Ok(sid_strs) +} + fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> { let mut read_payload = payload.clone(); read_payload.mode = SetupMode::ReadAclsOnly; @@ -566,25 +602,6 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( let sandbox_group_sid_str = string_from_sid_bytes(&sandbox_group_sid).map_err(anyhow::Error::msg)?; - let caps = load_or_create_cap_sids(&payload.codex_home).map_err(|err| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperCapabilitySidFailed, - format!("load or create capability SIDs failed: {err}"), - )) - })?; - let cap_psid = unsafe { - convert_string_sid_to_sid(&caps.workspace).ok_or_else(|| { - anyhow::Error::new(SetupFailure::new( - SetupErrorCode::HelperCapabilitySidFailed, - format!("convert capability SID {} failed", caps.workspace), - )) - })? - }; - let workspace_sid_str = workspace_cap_sid_for_cwd(&payload.codex_home, &payload.command_cwd)?; - let workspace_psid = unsafe { - convert_string_sid_to_sid(&workspace_sid_str) - .ok_or_else(|| anyhow::anyhow!("convert workspace capability SID failed"))? - }; let mut refresh_errors: Vec = Vec::new(); if !refresh_only { let proxy_allowlist_result = firewall::ensure_offline_proxy_allowlist( @@ -681,10 +698,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( )?; } - let cap_sid_str = caps.workspace; let write_mask = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE | FILE_DELETE_CHILD; - let mut grant_tasks: Vec = Vec::new(); + let mut grant_tasks: Vec<(PathBuf, String)> = Vec::new(); let mut seen_deny_paths: HashSet = HashSet::new(); let mut seen_write_roots: HashSet = HashSet::new(); @@ -706,16 +722,17 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( let cap_label = if is_command_cwd { "workspace_cap" } else { - "cap" + "root_cap" }; - let cap_psid_for_root = if is_command_cwd { - workspace_psid - } else { - cap_psid + let root_cap_sid_str = + workspace_write_cap_sid_for_root(&payload.codex_home, &payload.command_cwd, root)?; + let root_cap_psid = unsafe { + convert_string_sid_to_sid(&root_cap_sid_str) + .ok_or_else(|| anyhow::anyhow!("convert write root capability SID failed"))? }; for (label, psid) in [ ("sandbox_group", sandbox_group_psid), - (cap_label, cap_psid_for_root), + (cap_label, root_cap_psid), ] { let has = match path_mask_allows(root, &[psid], write_mask, /*require_all_bits*/ true) { @@ -741,6 +758,9 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( need_grant = true; } } + unsafe { + LocalFree(root_cap_psid as HLOCAL); + } if need_grant { log_line( log, @@ -749,19 +769,14 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( root.display() ), )?; - grant_tasks.push(root.clone()); + grant_tasks.push((root.clone(), root_cap_sid_str)); } } let (tx, rx) = mpsc::channel::<(PathBuf, Result)>(); std::thread::scope(|scope| { - for root in grant_tasks { - let is_command_cwd = is_command_cwd_root(&root, &canonical_command_cwd); - let sid_strings = if is_command_cwd { - vec![sandbox_group_sid_str.clone(), workspace_sid_str.clone()] - } else { - vec![sandbox_group_sid_str.clone(), cap_sid_str.clone()] - }; + for (root, root_cap_sid_str) in grant_tasks { + let sid_strings = vec![sandbox_group_sid_str.clone(), root_cap_sid_str]; let tx = tx.clone(); scope.spawn(move || { // Convert SID strings to psids locally in this thread. @@ -823,27 +838,36 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( .with_context(|| format!("failed to create deny-write path {}", path.display()))?; } - let canonical_path = canonicalize_path(path); - let deny_psid = if canonical_path.starts_with(&canonical_command_cwd) { - workspace_psid - } else { - cap_psid - }; + let deny_sid_strs = workspace_write_cap_sids_for_path( + &payload.codex_home, + &payload.command_cwd, + &payload.write_roots, + path, + )?; + for deny_sid_str in deny_sid_strs { + let deny_psid = unsafe { + convert_string_sid_to_sid(&deny_sid_str) + .ok_or_else(|| anyhow::anyhow!("convert deny capability SID failed"))? + }; - match unsafe { add_deny_write_ace(path, deny_psid) } { - Ok(true) => { - log_line( - log, - &format!("applied deny ACE to protect {}", path.display()), - )?; + match unsafe { add_deny_write_ace(path, deny_psid) } { + Ok(true) => { + log_line( + log, + &format!("applied deny ACE to protect {}", path.display()), + )?; + } + Ok(false) => {} + Err(err) => { + refresh_errors.push(format!("deny ACE failed on {}: {err}", path.display())); + log_line( + log, + &format!("deny ACE failed on {}: {err}", path.display()), + )?; + } } - Ok(false) => {} - Err(err) => { - refresh_errors.push(format!("deny ACE failed on {}: {err}", path.display())); - log_line( - log, - &format!("deny ACE failed on {}: {err}", path.display()), - )?; + unsafe { + LocalFree(deny_psid as HLOCAL); } } } @@ -924,12 +948,6 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( if !sandbox_group_psid.is_null() { LocalFree(sandbox_group_psid as HLOCAL); } - if !cap_psid.is_null() { - LocalFree(cap_psid as HLOCAL); - } - if !workspace_psid.is_null() { - LocalFree(workspace_psid as HLOCAL); - } } if refresh_only && !refresh_errors.is_empty() { log_line( @@ -946,9 +964,13 @@ fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<( mod tests { use super::Payload; use super::SETUP_VERSION; + use super::workspace_write_cap_sids_for_path; use codex_otel::StatsigMetricsSettings; + use codex_windows_sandbox::load_or_create_cap_sids; + use codex_windows_sandbox::workspace_write_cap_sid_for_root; use pretty_assertions::assert_eq; use serde_json::json; + use std::fs; fn payload_json() -> serde_json::Value { json!({ @@ -986,4 +1008,104 @@ mod tests { }) ); } + + #[test] + fn deny_path_under_active_root_uses_only_matching_root_sid() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let stale_root = temp.path().join("stale-root"); + let deny_path = active_root.join("protected"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&active_root).expect("create active root"); + fs::create_dir_all(&stale_root).expect("create stale root"); + fs::create_dir_all(&deny_path).expect("create deny path"); + + let stale_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &stale_root) + .expect("stale sid"); + let active_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &active_root) + .expect("active sid"); + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + + let deny_sids = workspace_write_cap_sids_for_path( + &codex_home, + &workspace, + &[workspace.clone(), active_root.clone()], + &deny_path, + ) + .expect("deny sids"); + + assert_eq!(deny_sids, vec![active_sid]); + assert!(!deny_sids.contains(&workspace_sid)); + assert!(!deny_sids.contains(&stale_sid)); + assert!(!deny_sids.contains(&caps.workspace)); + } + + #[test] + fn deny_path_outside_active_roots_falls_back_to_all_active_root_sids() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let stale_root = temp.path().join("stale-root"); + let deny_path = temp.path().join("outside-deny"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&active_root).expect("create active root"); + fs::create_dir_all(&stale_root).expect("create stale root"); + fs::create_dir_all(&deny_path).expect("create deny path"); + + let stale_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &stale_root) + .expect("stale sid"); + let active_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &active_root) + .expect("active sid"); + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + + let deny_sids = workspace_write_cap_sids_for_path( + &codex_home, + &workspace, + &[workspace.clone(), active_root.clone()], + &deny_path, + ) + .expect("deny sids"); + + assert_eq!(deny_sids.len(), 2); + assert!(deny_sids.contains(&workspace_sid)); + assert!(deny_sids.contains(&active_sid)); + assert!(!deny_sids.contains(&stale_sid)); + assert!(!deny_sids.contains(&caps.workspace)); + } + + #[test] + fn deny_path_includes_nested_active_root_sid() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let protected_dir = workspace.join(".codex"); + let nested_root = protected_dir.join("nested-root"); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&workspace).expect("create workspace"); + fs::create_dir_all(&nested_root).expect("create nested root"); + + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let nested_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &nested_root) + .expect("nested sid"); + + let deny_sids = workspace_write_cap_sids_for_path( + &codex_home, + &workspace, + &[workspace.clone(), nested_root], + &protected_dir, + ) + .expect("deny sids"); + + assert_eq!(deny_sids, vec![workspace_sid, nested_sid]); + } } diff --git a/codex-rs/windows-sandbox-rs/src/cap.rs b/codex-rs/windows-sandbox-rs/src/cap.rs index 85f7e2c9d6..41b33b63fd 100644 --- a/codex-rs/windows-sandbox-rs/src/cap.rs +++ b/codex-rs/windows-sandbox-rs/src/cap.rs @@ -1,4 +1,5 @@ use crate::path_normalization::canonical_path_key; +use crate::path_normalization::canonicalize_path; use anyhow::Context; use anyhow::Result; use rand::RngCore; @@ -22,6 +23,13 @@ pub struct CapSids { /// without permanently affecting other workspaces. #[serde(default)] pub workspace_by_cwd: HashMap, + /// Per-write-root capability SIDs keyed by canonicalized write-root path. + /// + /// These are included in a workspace-write token only when the root is + /// currently allowed, so stale ACLs from earlier extra roots do not expand + /// later workspace sandboxes. + #[serde(default)] + pub writable_root_by_path: HashMap, } pub fn cap_sid_file(codex_home: &Path) -> PathBuf { @@ -61,6 +69,7 @@ pub fn load_or_create_cap_sids(codex_home: &Path) -> Result { workspace: t.to_string(), readonly: make_random_cap_sid_string(), workspace_by_cwd: HashMap::new(), + writable_root_by_path: HashMap::new(), }; persist_caps(&path, &caps)?; return Ok(caps); @@ -70,6 +79,7 @@ pub fn load_or_create_cap_sids(codex_home: &Path) -> Result { workspace: make_random_cap_sid_string(), readonly: make_random_cap_sid_string(), workspace_by_cwd: HashMap::new(), + writable_root_by_path: HashMap::new(), }; persist_caps(&path, &caps)?; Ok(caps) @@ -89,10 +99,50 @@ pub fn workspace_cap_sid_for_cwd(codex_home: &Path, cwd: &Path) -> Result Result { + let path = cap_sid_file(codex_home); + let mut caps = load_or_create_cap_sids(codex_home)?; + let key = canonical_path_key(root); + if let Some(sid) = caps.writable_root_by_path.get(&key) { + return Ok(sid.clone()); + } + let sid = make_random_cap_sid_string(); + caps.writable_root_by_path.insert(key, sid.clone()); + persist_caps(&path, &caps)?; + Ok(sid) +} + +pub fn workspace_write_cap_sid_for_root( + codex_home: &Path, + cwd: &Path, + root: &Path, +) -> Result { + if canonical_path_key(root) == canonical_path_key(cwd) { + workspace_cap_sid_for_cwd(codex_home, cwd) + } else { + writable_root_cap_sid_for_path(codex_home, root) + } +} + +pub fn workspace_write_root_contains_path(root: &Path, path: &Path) -> bool { + canonicalize_path(path).starts_with(canonicalize_path(root)) +} + +pub fn workspace_write_root_overlaps_path(root: &Path, path: &Path) -> bool { + workspace_write_root_contains_path(root, path) || workspace_write_root_contains_path(path, root) +} + +pub fn workspace_write_root_specificity(root: &Path) -> usize { + canonicalize_path(root).components().count() +} + #[cfg(test)] mod tests { use super::load_or_create_cap_sids; use super::workspace_cap_sid_for_cwd; + use super::workspace_write_cap_sid_for_root; + use super::writable_root_cap_sid_for_path; use pretty_assertions::assert_eq; use std::path::PathBuf; @@ -123,4 +173,31 @@ mod tests { let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); assert_eq!(caps.workspace_by_cwd.len(), 1); } + + #[test] + fn write_roots_get_path_scoped_sids() { + let temp = tempfile::tempdir().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + + let workspace = temp.path().join("workspace"); + let extra_root = temp.path().join("extra-root"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&extra_root).expect("create extra root"); + + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let extra_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &extra_root) + .expect("extra root sid"); + + assert_ne!(workspace_sid, extra_sid); + assert_eq!( + extra_sid, + writable_root_cap_sid_for_path(&codex_home, &extra_root).expect("extra root sid again") + ); + + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + assert_eq!(caps.workspace_by_cwd.len(), 1); + assert_eq!(caps.writable_root_by_path.len(), 1); + } } diff --git a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs index e1ce8c1db7..f43715c122 100644 --- a/codex-rs/windows-sandbox-rs/src/elevated_impl.rs +++ b/codex-rs/windows-sandbox-rs/src/elevated_impl.rs @@ -24,6 +24,7 @@ mod windows_impl { use super::ElevatedSandboxCaptureRequest; use crate::acl::allow_null_device; use crate::cap::load_or_create_cap_sids; + use crate::cap::workspace_write_cap_sid_for_root; use crate::env::ensure_non_interactive_pager; use crate::env::inherit_path_env; use crate::env::normalize_null_device_env; @@ -41,7 +42,8 @@ mod windows_impl { use crate::runner_client::spawn_runner_transport; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; - use crate::token::convert_string_sid_to_sid; + use crate::setup::effective_write_roots_for_setup; + use crate::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use std::path::Path; @@ -109,22 +111,28 @@ mod windows_impl { anyhow::bail!("DangerFullAccess and ExternalSandbox are not supported for sandboxing") } let caps = load_or_create_cap_sids(codex_home)?; - let (psid_to_use, cap_sids) = match &policy { + let (sid_for_null, cap_sids) = match &policy { SandboxPolicy::ReadOnly { .. } => { - #[allow(clippy::unwrap_used)] - let psid = unsafe { convert_string_sid_to_sid(&caps.readonly).unwrap() }; - (psid, vec![caps.readonly]) + let sid = LocalSid::from_string(&caps.readonly)?; + (sid, vec![caps.readonly]) } SandboxPolicy::WorkspaceWrite { .. } => { - #[allow(clippy::unwrap_used)] - let psid = unsafe { convert_string_sid_to_sid(&caps.workspace).unwrap() }; - ( - psid, - vec![ - caps.workspace, - crate::cap::workspace_cap_sid_for_cwd(codex_home, cwd)?, - ], - ) + let write_roots = effective_write_roots_for_setup( + &policy, + sandbox_policy_cwd, + cwd, + &env_map, + codex_home, + write_roots_override, + ); + let cap_sids = write_roots + .iter() + .map(|root| workspace_write_cap_sid_for_root(codex_home, cwd, root)) + .collect::>>()?; + if cap_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); + } + (LocalSid::from_string(&cap_sids[0])?, cap_sids) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("DangerFullAccess handled above") @@ -132,7 +140,7 @@ mod windows_impl { }; unsafe { - allow_null_device(psid_to_use); + allow_null_device(sid_for_null.as_ptr()); } (|| -> Result { diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 78081b5e1f..db958c9975 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -110,6 +110,12 @@ pub use cap::load_or_create_cap_sids; #[cfg(target_os = "windows")] pub use cap::workspace_cap_sid_for_cwd; #[cfg(target_os = "windows")] +pub use cap::workspace_write_cap_sid_for_root; +#[cfg(target_os = "windows")] +pub use cap::workspace_write_root_contains_path; +#[cfg(target_os = "windows")] +pub use cap::workspace_write_root_overlaps_path; +#[cfg(target_os = "windows")] pub use conpty::ConptyInstance; #[cfg(target_os = "windows")] pub use conpty::spawn_conpty_process_as_user; @@ -222,7 +228,7 @@ pub use setup_error::setup_error_path; pub use setup_error::write_setup_error_report; #[cfg(target_os = "windows")] #[doc(hidden)] -pub use spawn_prep::LocalSid; +pub use token::LocalSid; #[cfg(target_os = "windows")] pub use token::convert_string_sid_to_sid; #[cfg(target_os = "windows")] @@ -273,34 +279,25 @@ pub use stub::run_windows_sandbox_legacy_preflight; #[cfg(target_os = "windows")] mod windows_impl { - use super::acl::add_allow_ace; - use super::acl::add_deny_write_ace; - use super::acl::allow_null_device; use super::acl::revoke_ace; - use super::allow::AllowDenyPaths; - use super::allow::compute_allow_paths; - use super::cap::load_or_create_cap_sids; - use super::cap::workspace_cap_sid_for_cwd; - use super::deny_read_acl::apply_deny_read_acls; - use super::deny_read_state::sync_persistent_deny_read_acls; use super::logging::log_failure; use super::logging::log_success; - use super::path_normalization::canonicalize_path; use super::policy::SandboxPolicy; use super::process::create_process_as_user; use super::sandbox_utils::ensure_codex_home_exists; + use super::spawn_prep::LegacyAclSids; + use super::spawn_prep::allow_null_device_for_workspace_write; + use super::spawn_prep::apply_legacy_session_acl_rules; + use super::spawn_prep::legacy_session_capability_roots; + use super::spawn_prep::prepare_legacy_session_security; use super::spawn_prep::prepare_legacy_spawn_context; - use super::token::convert_string_sid_to_sid; - use super::token::create_workspace_write_token_with_caps_from; - use super::workspace_acl::is_command_cwd_root; - use anyhow::Context; + use super::spawn_prep::root_capability_sids; + use super::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; - use std::ffi::c_void; use std::io; use std::path::Path; - use std::path::PathBuf; use std::ptr; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::GetLastError; @@ -418,130 +415,36 @@ mod windows_impl { if !additional_deny_read_paths.is_empty() { anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend"); } - let caps = load_or_create_cap_sids(codex_home)?; - let (h_token, psid_generic, psid_workspace): (HANDLE, *mut c_void, Option<*mut c_void>) = unsafe { - match &policy { - SandboxPolicy::ReadOnly { .. } => { - #[allow(clippy::expect_used)] - let psid = - convert_string_sid_to_sid(&caps.readonly).expect("valid readonly SID"); - let (h, _) = super::token::create_readonly_token_with_cap(psid)?; - (h, psid, None) - } - SandboxPolicy::WorkspaceWrite { .. } => { - #[allow(clippy::expect_used)] - let psid_generic = - convert_string_sid_to_sid(&caps.workspace).expect("valid workspace SID"); - let ws_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - #[allow(clippy::expect_used)] - let psid_workspace = - convert_string_sid_to_sid(&ws_sid).expect("valid workspace SID"); - let base = super::token::get_current_token_for_restriction()?; - let h_res = create_workspace_write_token_with_caps_from( - base, - &[psid_generic, psid_workspace], - ); - windows_sys::Win32::Foundation::CloseHandle(base); - let h = h_res?; - (h, psid_generic, Some(psid_workspace)) - } - SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { - unreachable!("DangerFullAccess handled above") - } - } - }; - - unsafe { - if is_workspace_write - && let Ok(base) = super::token::get_current_token_for_restriction() - { - if let Ok(bytes) = super::token::get_logon_sid_bytes(base) { - let mut tmp = bytes; - let psid2 = tmp.as_mut_ptr() as *mut c_void; - allow_null_device(psid2); - } - windows_sys::Win32::Foundation::CloseHandle(base); - } - } - + let capability_roots = legacy_session_capability_roots( + &policy, + sandbox_policy_cwd, + ¤t_dir, + &env_map, + codex_home, + ); + let security = prepare_legacy_session_security(&policy, codex_home, cwd, capability_roots)?; + allow_null_device_for_workspace_write(is_workspace_write); let persist_aces = is_workspace_write; - let AllowDenyPaths { allow, mut deny } = - compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); - for path in additional_deny_write_paths { - // Explicit deny-write carveouts must already exist when the process - // starts, otherwise it could create a missing path under a writable - // parent before the deny-write ACE exists. - if !path.exists() { - std::fs::create_dir_all(&path) - .with_context(|| format!("create deny-write path {}", path.display()))?; - } - deny.insert(path.clone()); - } - let canonical_cwd = canonicalize_path(¤t_dir); - let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); - unsafe { - for p in &allow { - let psid = if is_workspace_write && is_command_cwd_root(p, &canonical_cwd) { - psid_workspace.unwrap_or(psid_generic) - } else { - psid_generic - }; - if let Ok(added) = add_allow_ace(p, psid) - && added - { - if persist_aces { - if p.is_dir() { - // best-effort seeding omitted intentionally - } - } else { - guards.push((p.clone(), psid)); - } - } - } - for p in &deny { - if let Ok(added) = add_deny_write_ace(p, psid_generic) - && added - && !persist_aces - { - guards.push((p.clone(), psid_generic)); - } - } - // Read denies are layered after allow/deny-write setup so they can - // override broad read grants for the sandbox principal without - // changing the existing write policy computation. - let applied_deny_read_paths = match if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - &caps.workspace, - &additional_deny_read_paths, - psid_generic, - ) - } else { - apply_deny_read_acls(&additional_deny_read_paths, psid_generic) - } { - Ok(paths) => paths, - Err(err) => { - if !persist_aces { - cleanup_acl_guards(&mut guards); - } - return Err(err); - } - }; - if !persist_aces { - for path in applied_deny_read_paths { - guards.push((path, psid_generic)); - } - } - allow_null_device(psid_generic); - if let Some(psid) = psid_workspace { - allow_null_device(psid); - } - } + let guards = apply_legacy_session_acl_rules( + &policy, + sandbox_policy_cwd, + codex_home, + ¤t_dir, + &env_map, + &additional_deny_read_paths, + &additional_deny_write_paths, + LegacyAclSids { + readonly_sid: security.readonly_sid.as_ref(), + readonly_sid_str: security.readonly_sid_str.as_deref(), + write_root_sids: &security.write_root_sids, + }, + persist_aces, + )?; let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; let ((in_r, in_w), (out_r, out_w), (err_r, err_w)) = (stdin_pair, stdout_pair, stderr_pair); let spawn_res = unsafe { create_process_as_user( - h_token, + security.h_token, &command, cwd, &env_map, @@ -553,7 +456,6 @@ mod windows_impl { let created = match spawn_res { Ok(v) => v, Err(err) => { - cleanup_acl_guards(&mut guards); unsafe { CloseHandle(in_r); CloseHandle(in_w); @@ -561,7 +463,14 @@ mod windows_impl { CloseHandle(out_w); CloseHandle(err_r); CloseHandle(err_w); - CloseHandle(h_token); + if !persist_aces { + for (p, sid_str) in &guards { + if let Ok(sid) = LocalSid::from_string(sid_str) { + revoke_ace(p, sid.as_ptr()); + } + } + } + CloseHandle(security.h_token); } return Err(err); } @@ -643,7 +552,7 @@ mod windows_impl { if pi.hProcess != 0 { CloseHandle(pi.hProcess); } - CloseHandle(h_token); + CloseHandle(security.h_token); } let _ = t_out.join(); let _ = t_err.join(); @@ -662,7 +571,13 @@ mod windows_impl { } if !persist_aces { - cleanup_acl_guards(&mut guards); + unsafe { + for (p, sid_str) in guards { + if let Ok(sid) = LocalSid::from_string(&sid_str) { + revoke_ace(&p, sid.as_ptr()); + } + } + } } Ok(CaptureResult { exit_code, @@ -672,14 +587,6 @@ mod windows_impl { }) } - fn cleanup_acl_guards(guards: &mut Vec<(PathBuf, *mut c_void)>) { - unsafe { - for (p, sid) in guards.drain(..) { - revoke_ace(&p, sid); - } - } - } - pub fn run_windows_sandbox_legacy_preflight( sandbox_policy: &SandboxPolicy, sandbox_policy_cwd: &Path, @@ -693,33 +600,30 @@ mod windows_impl { } ensure_codex_home_exists(codex_home)?; - let caps = load_or_create_cap_sids(codex_home)?; - #[allow(clippy::expect_used)] - let psid_generic = - unsafe { convert_string_sid_to_sid(&caps.workspace) }.expect("valid workspace SID"); - let ws_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - #[allow(clippy::expect_used)] - let psid_workspace = - unsafe { convert_string_sid_to_sid(&ws_sid) }.expect("valid workspace SID"); let current_dir = cwd.to_path_buf(); - let AllowDenyPaths { allow, deny } = - compute_allow_paths(sandbox_policy, sandbox_policy_cwd, ¤t_dir, env_map); - let canonical_cwd = canonicalize_path(¤t_dir); - unsafe { - for p in &allow { - let psid = if is_command_cwd_root(p, &canonical_cwd) { - psid_workspace - } else { - psid_generic - }; - let _ = add_allow_ace(p, psid); - } - for p in &deny { - let _ = add_deny_write_ace(p, psid_generic); - } - allow_null_device(psid_generic); - allow_null_device(psid_workspace); - } + let capability_roots = legacy_session_capability_roots( + sandbox_policy, + sandbox_policy_cwd, + ¤t_dir, + env_map, + codex_home, + ); + let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; + let _guards = apply_legacy_session_acl_rules( + sandbox_policy, + sandbox_policy_cwd, + codex_home, + ¤t_dir, + env_map, + &[], + &[], + LegacyAclSids { + readonly_sid: None, + readonly_sid_str: None, + write_root_sids: &write_root_sids, + }, + /*persist_aces*/ true, + )?; Ok(()) } diff --git a/codex-rs/windows-sandbox-rs/src/setup.rs b/codex-rs/windows-sandbox-rs/src/setup.rs index 3b6e47086e..0de2c6e029 100644 --- a/codex-rs/windows-sandbox-rs/src/setup.rs +++ b/codex-rs/windows-sandbox-rs/src/setup.rs @@ -413,6 +413,26 @@ pub(crate) fn gather_write_roots( out } +pub(crate) fn effective_write_roots_for_setup( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, + write_roots_override: Option<&[PathBuf]>, +) -> Vec { + let write_roots = if let Some(roots) = write_roots_override { + canonical_existing(roots) + } else { + gather_write_roots(policy, policy_cwd, command_cwd, env_map) + }; + let write_roots = expand_user_profile_root(write_roots); + let write_roots = filter_user_profile_root(write_roots); + let write_roots = filter_user_profile_root_exclusions(write_roots); + let write_roots = filter_ssh_config_dependency_roots(write_roots); + filter_sensitive_write_roots(write_roots, codex_home) +} + #[derive(Serialize)] struct ElevationPayload { version: u32, @@ -761,21 +781,14 @@ fn build_payload_roots( request: &SandboxSetupRequest<'_>, overrides: &SetupRootOverrides, ) -> (Vec, Vec) { - let write_roots = if let Some(roots) = overrides.write_roots.as_deref() { - canonical_existing(roots) - } else { - gather_write_roots( - request.policy, - request.policy_cwd, - request.command_cwd, - request.env_map, - ) - }; - let write_roots = expand_user_profile_root(write_roots); - let write_roots = filter_user_profile_root(write_roots); - let write_roots = filter_user_profile_root_exclusions(write_roots); - let write_roots = filter_ssh_config_dependency_roots(write_roots); - let write_roots = filter_sensitive_write_roots(write_roots, request.codex_home); + let write_roots = effective_write_roots_for_setup( + request.policy, + request.policy_cwd, + request.command_cwd, + request.env_map, + request.codex_home, + overrides.write_roots.as_deref(), + ); let mut read_roots = if let Some(roots) = overrides.read_roots.as_deref() { // An explicit override is the split policy's complete readable set. Keep only the // helper/platform roots the elevated setup needs; do not re-add legacy cwd/full-read roots. @@ -1405,6 +1418,66 @@ mod tests { ); } + #[test] + fn effective_write_roots_match_payload_filtering_for_overrides() { + let tmp = TempDir::new().expect("tempdir"); + let codex_home = tmp.path().join("codex-home"); + let command_cwd = tmp.path().join("workspace"); + let extra_root = tmp.path().join("extra-root"); + let sandbox_root = super::sandbox_dir(&codex_home); + fs::create_dir_all(&codex_home).expect("create codex home"); + fs::create_dir_all(&command_cwd).expect("create workspace"); + fs::create_dir_all(&extra_root).expect("create extra root"); + fs::create_dir_all(&sandbox_root).expect("create sandbox root"); + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + let override_roots = vec![ + command_cwd.clone(), + extra_root.clone(), + codex_home.clone(), + sandbox_root.clone(), + ]; + let request = super::SandboxSetupRequest { + policy: &policy, + policy_cwd: &command_cwd, + command_cwd: &command_cwd, + env_map: &HashMap::new(), + codex_home: &codex_home, + proxy_enforced: false, + }; + let overrides = super::SetupRootOverrides { + read_roots: None, + read_roots_include_platform_defaults: false, + write_roots: Some(override_roots.clone()), + deny_read_paths: None, + deny_write_paths: None, + }; + + let effective_write_roots = super::effective_write_roots_for_setup( + &policy, + &command_cwd, + &command_cwd, + &HashMap::new(), + &codex_home, + Some(&override_roots), + ); + let (_read_roots, payload_write_roots) = build_payload_roots(&request, &overrides); + + let expected_workspace = dunce::canonicalize(&command_cwd).expect("canonical workspace"); + let expected_extra = dunce::canonicalize(&extra_root).expect("canonical extra root"); + let forbidden_codex_home = dunce::canonicalize(&codex_home).expect("canonical codex home"); + let forbidden_sandbox = dunce::canonicalize(&sandbox_root).expect("canonical sandbox root"); + assert_eq!(effective_write_roots, payload_write_roots); + assert!(effective_write_roots.contains(&expected_workspace)); + assert!(effective_write_roots.contains(&expected_extra)); + assert!(!effective_write_roots.contains(&forbidden_codex_home)); + assert!(!effective_write_roots.contains(&forbidden_sandbox)); + } + #[test] fn payload_deny_write_paths_merge_explicit_and_protected_children() { let tmp = TempDir::new().expect("tempdir"); diff --git a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs index b0cf0f3075..331df4f438 100644 --- a/codex-rs/windows-sandbox-rs/src/spawn_prep.rs +++ b/codex-rs/windows-sandbox-rs/src/spawn_prep.rs @@ -4,7 +4,10 @@ use crate::acl::allow_null_device; use crate::allow::AllowDenyPaths; use crate::allow::compute_allow_paths; use crate::cap::load_or_create_cap_sids; -use crate::cap::workspace_cap_sid_for_cwd; +use crate::cap::workspace_write_cap_sid_for_root; +use crate::cap::workspace_write_root_contains_path; +use crate::cap::workspace_write_root_overlaps_path; +use crate::cap::workspace_write_root_specificity; use crate::deny_read_acl::apply_deny_read_acls; use crate::deny_read_state::sync_persistent_deny_read_acls; use crate::env::apply_no_network_to_env; @@ -19,7 +22,8 @@ use crate::policy::SandboxPolicy; use crate::policy::parse_policy; use crate::sandbox_utils::ensure_codex_home_exists; use crate::sandbox_utils::inject_git_safe_directory; -use crate::token::convert_string_sid_to_sid; +use crate::setup::effective_write_roots_for_setup; +use crate::token::LocalSid; use crate::token::create_readonly_token_with_cap; use crate::token::create_workspace_write_token_with_caps_from; use crate::token::get_current_token_for_restriction; @@ -35,8 +39,6 @@ use std::path::Path; use std::path::PathBuf; use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::Foundation::HANDLE; -use windows_sys::Win32::Foundation::HLOCAL; -use windows_sys::Win32::Foundation::LocalFree; pub(crate) struct SpawnContext { pub(crate) policy: SandboxPolicy, @@ -54,36 +56,21 @@ pub(crate) struct ElevatedSpawnContext { pub(crate) struct LegacySessionSecurity { pub(crate) h_token: HANDLE, - pub(crate) psid_generic: LocalSid, - pub(crate) psid_workspace: Option, - pub(crate) cap_sid_str: String, + pub(crate) readonly_sid: Option, + pub(crate) readonly_sid_str: Option, + pub(crate) write_root_sids: Vec, } -/// Owns a SID allocated by `ConvertStringSidToSidW` and releases it with `LocalFree`. -pub struct LocalSid { - psid: *mut c_void, +pub(crate) struct RootCapabilitySid { + pub(crate) root: PathBuf, + pub(crate) sid: LocalSid, + pub(crate) sid_str: String, } -impl LocalSid { - pub fn from_string(sid: &str) -> Result { - let psid = unsafe { convert_string_sid_to_sid(sid) } - .ok_or_else(|| anyhow::anyhow!("invalid SID string: {sid}"))?; - Ok(Self { psid }) - } - - pub fn as_ptr(&self) -> *mut c_void { - self.psid - } -} - -impl Drop for LocalSid { - fn drop(&mut self) { - if !self.psid.is_null() { - unsafe { - LocalFree(self.psid as HLOCAL); - } - } - } +pub(crate) struct LegacyAclSids<'a> { + pub(crate) readonly_sid: Option<&'a LocalSid>, + pub(crate) readonly_sid_str: Option<&'a str>, + pub(crate) write_root_sids: &'a [RootCapabilitySid], } pub(crate) fn should_apply_network_block(policy: &SandboxPolicy) -> bool { @@ -161,27 +148,31 @@ pub(crate) fn prepare_legacy_session_security( policy: &SandboxPolicy, codex_home: &Path, cwd: &Path, + capability_roots: impl IntoIterator, ) -> Result { let caps = load_or_create_cap_sids(codex_home)?; - let (h_token, psid_generic, psid_workspace, cap_sid_str) = unsafe { + let (h_token, readonly_sid, readonly_sid_str, write_root_sids) = unsafe { match policy { SandboxPolicy::ReadOnly { .. } => { let psid = LocalSid::from_string(&caps.readonly)?; let (h_token, _psid) = create_readonly_token_with_cap(psid.as_ptr())?; - (h_token, psid, None, caps.readonly) + (h_token, Some(psid), Some(caps.readonly), Vec::new()) } SandboxPolicy::WorkspaceWrite { .. } => { - let psid_generic = LocalSid::from_string(&caps.workspace)?; - let workspace_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - let psid_workspace = LocalSid::from_string(&workspace_sid)?; + let write_root_sids = root_capability_sids(codex_home, cwd, capability_roots)?; + if write_root_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); + } let base = get_current_token_for_restriction()?; - let h_token = create_workspace_write_token_with_caps_from( - base, - &[psid_generic.as_ptr(), psid_workspace.as_ptr()], - ); + let cap_ptrs: Vec<*mut c_void> = write_root_sids + .iter() + .map(|root| root.sid.as_ptr()) + .collect(); + let h_token = + create_workspace_write_token_with_caps_from(base, cap_ptrs.as_slice()); CloseHandle(base); let h_token = h_token?; - (h_token, psid_generic, Some(psid_workspace), caps.workspace) + (h_token, None, None, write_root_sids) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("dangerous policies rejected before legacy session prep") @@ -191,12 +182,80 @@ pub(crate) fn prepare_legacy_session_security( Ok(LegacySessionSecurity { h_token, - psid_generic, - psid_workspace, - cap_sid_str, + readonly_sid, + readonly_sid_str, + write_root_sids, }) } +pub(crate) fn legacy_session_capability_roots( + policy: &SandboxPolicy, + policy_cwd: &Path, + current_dir: &Path, + env_map: &HashMap, + codex_home: &Path, +) -> Vec { + let allow_paths = compute_allow_paths(policy, policy_cwd, current_dir, env_map) + .allow + .into_iter() + .collect::>(); + if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { + effective_write_roots_for_setup( + policy, + policy_cwd, + current_dir, + env_map, + codex_home, + Some(allow_paths.as_slice()), + ) + } else { + allow_paths + } +} + +pub(crate) fn root_capability_sids( + codex_home: &Path, + cwd: &Path, + allow_paths: impl IntoIterator, +) -> Result> { + let mut roots: Vec = allow_paths.into_iter().collect(); + roots.sort_by_key(|root| canonicalize_path(root.as_path())); + roots.dedup_by(|a, b| canonicalize_path(a.as_path()) == canonicalize_path(b.as_path())); + + let mut out = Vec::with_capacity(roots.len()); + for root in roots { + let sid_str = workspace_write_cap_sid_for_root(codex_home, cwd, &root)?; + let sid = LocalSid::from_string(&sid_str)?; + out.push(RootCapabilitySid { root, sid, sid_str }); + } + Ok(out) +} + +fn matching_root_capability<'a>( + path: &Path, + root_sids: &'a [RootCapabilitySid], +) -> Option<&'a RootCapabilitySid> { + root_sids + .iter() + .filter(|root_sid| workspace_write_root_contains_path(&root_sid.root, path)) + .max_by_key(|root_sid| workspace_write_root_specificity(&root_sid.root)) +} + +fn deny_root_capabilities_for_path<'a>( + path: &Path, + root_sids: &'a [RootCapabilitySid], +) -> Vec<&'a RootCapabilitySid> { + let matching_root_sids = root_sids + .iter() + .filter(|root_sid| workspace_write_root_overlaps_path(&root_sid.root, path)) + .collect::>(); + if matching_root_sids.is_empty() { + root_sids.iter().collect() + } else { + matching_root_sids + } +} + pub(crate) fn allow_null_device_for_workspace_write(is_workspace_write: bool) { if !is_workspace_write { return; @@ -221,17 +280,14 @@ pub(crate) fn apply_legacy_session_acl_rules( codex_home: &Path, current_dir: &Path, env_map: &HashMap, - psid_generic: &LocalSid, - psid_workspace: Option<&LocalSid>, - cap_sid_str: &str, additional_deny_read_paths: &[PathBuf], additional_deny_write_paths: &[PathBuf], + acl_sids: LegacyAclSids<'_>, persist_aces: bool, -) -> Result> { +) -> Result> { let AllowDenyPaths { allow, mut deny } = compute_allow_paths(policy, sandbox_policy_cwd, current_dir, env_map); - let mut guards: Vec = Vec::new(); - let canonical_cwd = canonicalize_path(current_dir); + let mut guards: Vec<(PathBuf, String)> = Vec::new(); unsafe { for path in additional_deny_write_paths { // Explicit carveouts must exist before the command starts so the @@ -242,45 +298,94 @@ pub(crate) fn apply_legacy_session_acl_rules( } deny.insert(path.clone()); } - for p in &allow { - let psid = if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) - && is_command_cwd_root(p, &canonical_cwd) - { - psid_workspace.unwrap_or(psid_generic).as_ptr() - } else { - psid_generic.as_ptr() - }; - if matches!(add_allow_ace(p, psid), Ok(true)) && !persist_aces { - guards.push(p.clone()); + if let Some(readonly_sid) = acl_sids.readonly_sid { + for p in &allow { + if matches!(add_allow_ace(p, readonly_sid.as_ptr()), Ok(true)) + && !persist_aces + && let Some(readonly_sid_str) = acl_sids.readonly_sid_str + { + guards.push((p.clone(), readonly_sid_str.to_string())); + } + } + } else { + for p in &allow { + let Some(root_sid) = matching_root_capability(p, acl_sids.write_root_sids) else { + continue; + }; + if matches!(add_allow_ace(p, root_sid.sid.as_ptr()), Ok(true)) && !persist_aces { + guards.push((p.clone(), root_sid.sid_str.clone())); + } } } for p in &deny { - if let Ok(added) = add_deny_write_ace(p, psid_generic.as_ptr()) - && added - && !persist_aces - { - guards.push(p.clone()); + for root_sid in deny_root_capabilities_for_path(p, acl_sids.write_root_sids) { + if let Ok(added) = add_deny_write_ace(p, root_sid.sid.as_ptr()) + && added + && !persist_aces + { + guards.push((p.clone(), root_sid.sid_str.clone())); + } } } - let applied_deny_read_paths = if persist_aces { - sync_persistent_deny_read_acls( - codex_home, - cap_sid_str, - additional_deny_read_paths, - psid_generic.as_ptr(), - )? - } else { - apply_deny_read_acls(additional_deny_read_paths, psid_generic.as_ptr())? - }; - if !persist_aces { - guards.extend(applied_deny_read_paths); + if !additional_deny_read_paths.is_empty() { + if let Some(readonly_sid) = acl_sids.readonly_sid { + let Some(readonly_sid_str) = acl_sids.readonly_sid_str else { + anyhow::bail!("readonly capability SID string missing"); + }; + let applied_deny_read_paths = if persist_aces { + sync_persistent_deny_read_acls( + codex_home, + readonly_sid_str, + additional_deny_read_paths, + readonly_sid.as_ptr(), + )? + } else { + apply_deny_read_acls(additional_deny_read_paths, readonly_sid.as_ptr())? + }; + if !persist_aces { + guards.extend( + applied_deny_read_paths + .into_iter() + .map(|path| (path, readonly_sid_str.to_string())), + ); + } + } else { + for root_sid in acl_sids.write_root_sids { + let applied_deny_read_paths = if persist_aces { + sync_persistent_deny_read_acls( + codex_home, + &root_sid.sid_str, + additional_deny_read_paths, + root_sid.sid.as_ptr(), + )? + } else { + apply_deny_read_acls(additional_deny_read_paths, root_sid.sid.as_ptr())? + }; + if !persist_aces { + guards.extend( + applied_deny_read_paths + .into_iter() + .map(|path| (path, root_sid.sid_str.clone())), + ); + } + } + } } - allow_null_device(psid_generic.as_ptr()); - if let Some(psid_workspace) = psid_workspace { - allow_null_device(psid_workspace.as_ptr()); - if persist_aces && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { - let _ = protect_workspace_codex_dir(current_dir, psid_workspace.as_ptr()); - let _ = protect_workspace_agents_dir(current_dir, psid_workspace.as_ptr()); + for root_sid in acl_sids.write_root_sids { + allow_null_device(root_sid.sid.as_ptr()); + } + if let Some(readonly_sid) = acl_sids.readonly_sid { + allow_null_device(readonly_sid.as_ptr()); + } + if persist_aces + && matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) + && let Some(workspace_sid) = + matching_root_capability(current_dir, acl_sids.write_root_sids) + { + let canonical_cwd = canonicalize_path(current_dir); + if is_command_cwd_root(&workspace_sid.root, &canonical_cwd) { + let _ = protect_workspace_codex_dir(current_dir, workspace_sid.sid.as_ptr()); + let _ = protect_workspace_agents_dir(current_dir, workspace_sid.sid.as_ptr()); } } } @@ -324,6 +429,24 @@ pub(crate) fn prepare_elevated_spawn_context( } else { None }; + let write_roots_for_setup = write_roots_override.or(computed_write_roots_override); + let effective_write_roots = if common.is_workspace_write { + effective_write_roots_for_setup( + &common.policy, + sandbox_policy_cwd, + &common.current_dir, + env_map, + codex_home, + write_roots_for_setup, + ) + } else { + Vec::new() + }; + let setup_write_roots_override = if common.is_workspace_write { + Some(effective_write_roots.as_slice()) + } else { + write_roots_override + }; let sandbox_creds = require_logon_sandbox_creds( &common.policy, sandbox_policy_cwd, @@ -332,7 +455,7 @@ pub(crate) fn prepare_elevated_spawn_context( codex_home, read_roots_override, read_roots_include_platform_defaults, - write_roots_override.or(computed_write_roots_override), + setup_write_roots_override, deny_read_paths_override, if deny_write_paths_override.is_empty() { &deny_write_paths @@ -348,11 +471,14 @@ pub(crate) fn prepare_elevated_spawn_context( vec![caps.readonly.clone()], ), SandboxPolicy::WorkspaceWrite { .. } => { - let cap_sid = workspace_cap_sid_for_cwd(codex_home, cwd)?; - ( - LocalSid::from_string(&caps.workspace)?, - vec![caps.workspace.clone(), cap_sid], - ) + let cap_sids = root_capability_sids(codex_home, cwd, effective_write_roots)? + .into_iter() + .map(|root_sid| root_sid.sid_str) + .collect::>(); + if cap_sids.is_empty() { + anyhow::bail!("workspace-write sandbox has no writable root capability SIDs"); + } + (LocalSid::from_string(&cap_sids[0])?, cap_sids) } SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => { unreachable!("dangerous policies rejected before elevated session prep") @@ -373,9 +499,15 @@ pub(crate) fn prepare_elevated_spawn_context( #[cfg(test)] mod tests { use super::SandboxPolicy; + use super::deny_root_capabilities_for_path; + use super::legacy_session_capability_roots; use super::prepare_legacy_spawn_context; use super::prepare_spawn_context_common; + use super::root_capability_sids; use super::should_apply_network_block; + use crate::cap::load_or_create_cap_sids; + use crate::cap::workspace_write_cap_sid_for_root; + use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use std::collections::HashMap; use tempfile::TempDir; @@ -450,4 +582,114 @@ mod tests { Some(&"http://user.proxy:8080".to_string()) ); } + + #[test] + fn root_capability_sids_only_include_active_roots() { + let temp = TempDir::new().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let stale_root = temp.path().join("stale-root"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&active_root).expect("create active root"); + std::fs::create_dir_all(&stale_root).expect("create stale root"); + + let stale_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &stale_root) + .expect("stale sid"); + let active_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &active_root) + .expect("active sid"); + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let caps = load_or_create_cap_sids(&codex_home).expect("load caps"); + + let sid_strs = root_capability_sids( + &codex_home, + &workspace, + vec![workspace.clone(), active_root], + ) + .expect("root capabilities") + .into_iter() + .map(|root_sid| root_sid.sid_str) + .collect::>(); + + assert_eq!(sid_strs.len(), 2); + assert!(sid_strs.contains(&workspace_sid)); + assert!(sid_strs.contains(&active_sid)); + assert!(!sid_strs.contains(&stale_sid)); + assert!(!sid_strs.contains(&caps.workspace)); + } + + #[test] + fn legacy_deny_path_includes_nested_active_root_sid() { + let temp = TempDir::new().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let protected_dir = workspace.join(".codex"); + let nested_root = protected_dir.join("nested-root"); + let unrelated_root = temp.path().join("unrelated-root"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&nested_root).expect("create nested root"); + std::fs::create_dir_all(&unrelated_root).expect("create unrelated root"); + + let workspace_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &workspace) + .expect("workspace sid"); + let nested_sid = workspace_write_cap_sid_for_root(&codex_home, &workspace, &nested_root) + .expect("nested sid"); + let unrelated_sid = + workspace_write_cap_sid_for_root(&codex_home, &workspace, &unrelated_root) + .expect("unrelated sid"); + let root_sids = root_capability_sids( + &codex_home, + &workspace, + vec![workspace.clone(), nested_root, unrelated_root], + ) + .expect("root capabilities"); + + let deny_sid_strs = deny_root_capabilities_for_path(&protected_dir, &root_sids) + .into_iter() + .map(|root_sid| root_sid.sid_str.clone()) + .collect::>(); + + assert_eq!(deny_sid_strs, vec![workspace_sid, nested_sid]); + assert!(!deny_sid_strs.contains(&unrelated_sid)); + } + + #[test] + fn legacy_capability_roots_use_effective_write_roots() { + let temp = TempDir::new().expect("tempdir"); + let codex_home = temp.path().join("codex-home"); + let workspace = temp.path().join("workspace"); + let active_root = temp.path().join("active-root"); + let sandbox_root = codex_home.join(".sandbox"); + std::fs::create_dir_all(&codex_home).expect("create codex home"); + std::fs::create_dir_all(&workspace).expect("create workspace"); + std::fs::create_dir_all(&active_root).expect("create active root"); + std::fs::create_dir_all(&sandbox_root).expect("create sandbox root"); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![ + AbsolutePathBuf::try_from(active_root.as_path()).expect("active root"), + AbsolutePathBuf::try_from(codex_home.as_path()).expect("codex home"), + AbsolutePathBuf::try_from(sandbox_root.as_path()).expect("sandbox root"), + ], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: true, + }; + + let roots = legacy_session_capability_roots( + &policy, + &workspace, + &workspace, + &HashMap::new(), + &codex_home, + ); + + assert!(roots.contains(&dunce::canonicalize(&workspace).expect("workspace"))); + assert!(roots.contains(&dunce::canonicalize(&active_root).expect("active root"))); + assert!(!roots.contains(&dunce::canonicalize(&codex_home).expect("codex home"))); + assert!(!roots.contains(&dunce::canonicalize(&sandbox_root).expect("sandbox root"))); + } } diff --git a/codex-rs/windows-sandbox-rs/src/token.rs b/codex-rs/windows-sandbox-rs/src/token.rs index aabf77469f..2c7a746474 100644 --- a/codex-rs/windows-sandbox-rs/src/token.rs +++ b/codex-rs/windows-sandbox-rs/src/token.rs @@ -139,6 +139,33 @@ pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> { if ok != 0 { Some(psid) } else { None } } +/// Owns a SID allocated by `ConvertStringSidToSidW` and releases it with `LocalFree`. +pub struct LocalSid { + psid: *mut c_void, +} + +impl LocalSid { + pub fn from_string(sid: &str) -> Result { + let psid = unsafe { convert_string_sid_to_sid(sid) } + .ok_or_else(|| anyhow!("invalid SID string: {sid}"))?; + Ok(Self { psid }) + } + + pub fn as_ptr(&self) -> *mut c_void { + self.psid + } +} + +impl Drop for LocalSid { + fn drop(&mut self) { + if !self.psid.is_null() { + unsafe { + LocalFree(self.psid as HLOCAL); + } + } + } +} + /// # Safety /// Caller must close the returned token handle. pub unsafe fn get_current_token_for_restriction() -> Result { diff --git a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs index 81096e8c62..2f76147ce9 100644 --- a/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs +++ b/codex-rs/windows-sandbox-rs/src/unified_exec/backends/legacy.rs @@ -10,11 +10,13 @@ use crate::process::StderrMode; use crate::process::StdinMode; use crate::process::read_handle_loop; use crate::process::spawn_process_with_pipes; -use crate::spawn_prep::LocalSid; +use crate::spawn_prep::LegacyAclSids; use crate::spawn_prep::allow_null_device_for_workspace_write; use crate::spawn_prep::apply_legacy_session_acl_rules; +use crate::spawn_prep::legacy_session_capability_roots; use crate::spawn_prep::prepare_legacy_session_security; use crate::spawn_prep::prepare_legacy_spawn_context; +use crate::token::LocalSid; use anyhow::Result; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_pty::ProcessDriver; @@ -204,8 +206,7 @@ fn finalize_exit( process_handle: Arc>>, thread_handle: HANDLE, output_join: std::thread::JoinHandle<()>, - guards: Vec, - cap_sid: Option, + guards: Vec<(PathBuf, String)>, logs_base_dir: Option<&Path>, command: Vec, ) { @@ -242,11 +243,9 @@ fn finalize_exit( log_failure(&command, &format!("exit code {exit_code}"), logs_base_dir); } - if let Some(cap_sid) = cap_sid - && let Ok(sid) = LocalSid::from_string(&cap_sid) - { - unsafe { - for path in guards { + unsafe { + for (path, cap_sid) in guards { + if let Ok(sid) = LocalSid::from_string(&cap_sid) { revoke_ace(&path, sid.as_ptr()); } } @@ -311,15 +310,19 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( if !additional_deny_read_paths.is_empty() { anyhow::bail!("deny-read overrides require the elevated Windows sandbox backend"); } - let additional_deny_read_paths = additional_deny_read_paths - .iter() - .map(AbsolutePathBuf::to_path_buf) - .collect::>(); let additional_deny_write_paths = additional_deny_write_paths .iter() .map(AbsolutePathBuf::to_path_buf) .collect::>(); - let security = prepare_legacy_session_security(&common.policy, codex_home, cwd)?; + let capability_roots = legacy_session_capability_roots( + &common.policy, + sandbox_policy_cwd, + &common.current_dir, + &env_map, + codex_home, + ); + let security = + prepare_legacy_session_security(&common.policy, codex_home, cwd, capability_roots)?; allow_null_device_for_workspace_write(common.is_workspace_write); let persist_aces = common.is_workspace_write; @@ -329,11 +332,13 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( codex_home, &common.current_dir, &env_map, - &security.psid_generic, - security.psid_workspace.as_ref(), - &security.cap_sid_str, - &additional_deny_read_paths, + &[], &additional_deny_write_paths, + LegacyAclSids { + readonly_sid: security.readonly_sid.as_ref(), + readonly_sid_str: security.readonly_sid_str.as_deref(), + write_root_sids: &security.write_root_sids, + }, persist_aces, )?; @@ -370,12 +375,11 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( Ok(handles) => handles, Err(err) => { unsafe { - if !persist_aces - && !guards.is_empty() - && let Ok(sid) = LocalSid::from_string(&security.cap_sid_str) - { - for path in &guards { - revoke_ace(path, sid.as_ptr()); + if !persist_aces { + for (path, cap_sid) in &guards { + if let Ok(sid) = LocalSid::from_string(cap_sid) { + revoke_ace(path, sid.as_ptr()); + } } } CloseHandle(security.h_token); @@ -389,11 +393,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( let wait_handle = Arc::clone(&process_handle); let command_for_wait = command.clone(); let guards_for_wait = if persist_aces { Vec::new() } else { guards }; - let cap_sid_for_wait = if guards_for_wait.is_empty() { - None - } else { - Some(security.cap_sid_str) - }; let hpc_for_wait = hpc_handle.clone(); std::thread::spawn(move || { let _desktop = desktop; @@ -425,7 +424,6 @@ pub(crate) async fn spawn_windows_sandbox_session_legacy( pi.hThread, output_join, guards_for_wait, - cap_sid_for_wait, common.logs_base_dir.as_deref(), command_for_wait, ); From c9edb26755427bd01f82fd7b4e8677f32ad221a4 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 13 May 2026 11:43:14 -0700 Subject: [PATCH 05/47] windows-sandbox: fail elevated setup when firewall policy is ineffective (#22353) ## Why Elevated Windows sandbox setup currently assumes that the firewall rules it writes will take effect. On managed Windows hosts, local firewall policy changes can be ignored or only partially apply across the active profiles, which means setup can appear to succeed without providing the expected network isolation. ## What changed - Query `INetFwPolicy2::LocalPolicyModifyState` before configuring the elevated sandbox firewall rules. - Fail setup when Windows reports that local firewall policy edits are ineffective or only apply to some current profiles. - Surface that condition with a dedicated `helper_firewall_policy_ineffective` setup error code so support and IT-facing diagnostics can distinguish it from COM access failures. - Add focused coverage for effective policy, group-policy override, and partial-profile coverage cases. ## Testing - `cargo test -p codex-windows-sandbox --bin codex-windows-sandbox-setup` --- .../src/bin/setup_main/win/firewall.rs | 88 +++++++++++++++++++ .../windows-sandbox-rs/src/setup_error.rs | 3 + 2 files changed, 91 insertions(+) diff --git a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs index caa1780437..0a83950853 100644 --- a/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs +++ b/codex-rs/windows-sandbox-rs/src/bin/setup_main/win/firewall.rs @@ -2,6 +2,7 @@ use anyhow::Result; use std::fs::File; use std::io::Write; +use windows::Win32::Foundation::S_OK; use windows::Win32::Foundation::VARIANT_TRUE; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwPolicy2; use windows::Win32::NetworkManagement::WindowsFirewall::INetFwRule3; @@ -10,6 +11,8 @@ use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_ACTION_BLOCK; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_ANY; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_TCP; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_IP_PROTOCOL_UDP; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_MODIFY_STATE; +use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_MODIFY_STATE_OK; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_PROFILE2_ALL; use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_RULE_DIR_OUT; use windows::Win32::NetworkManagement::WindowsFirewall::NetFwPolicy2; @@ -75,6 +78,7 @@ pub fn ensure_offline_proxy_allowlist( format!("CoCreateInstance NetFwPolicy2 failed: {err:?}"), )) })?; + ensure_local_policy_rules_take_effect(&policy)?; let rules = policy.Rules().map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallPolicyAccessFailed, @@ -170,6 +174,7 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul format!("CoCreateInstance NetFwPolicy2 failed: {err:?}"), )) })?; + ensure_local_policy_rules_take_effect(&policy)?; let rules = policy.Rules().map_err(|err| { anyhow::Error::new(SetupFailure::new( SetupErrorCode::HelperFirewallPolicyAccessFailed, @@ -215,6 +220,52 @@ fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut Fi Ok(()) } +fn ensure_local_policy_rules_take_effect(policy: &INetFwPolicy2) -> Result<()> { + let mut modify_state = NET_FW_MODIFY_STATE::default(); + let result = unsafe { + (Interface::vtable(policy).LocalPolicyModifyState)( + Interface::as_raw(policy), + &mut modify_state, + ) + }; + validate_local_policy_modify_result(result, modify_state) +} + +fn validate_local_policy_modify_result( + result: windows::core::HRESULT, + modify_state: NET_FW_MODIFY_STATE, +) -> Result<()> { + if result.is_err() { + // The COM query itself failed, so Windows never gave us a policy answer. + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyAccessFailed, + format!("INetFwPolicy2::LocalPolicyModifyState failed: {result:?}"), + ))); + } + + if result != S_OK { + // S_FALSE means the answer only holds for some active profiles, not all of them. + return Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyIneffective, + format!( + "local firewall policy modifications do not apply to every current profile: LocalPolicyModifyState result={result:?}" + ), + ))); + } + + if modify_state == NET_FW_MODIFY_STATE_OK { + return Ok(()); + } + + // Windows answered uniformly, and that answer says local rule edits are ineffective. + Err(anyhow::Error::new(SetupFailure::new( + SetupErrorCode::HelperFirewallPolicyIneffective, + format!( + "local firewall policy modifications will not take effect: LocalPolicyModifyState={modify_state:?}" + ), + ))) +} + fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> { let name = BSTR::from(spec.internal_name); let rule: INetFwRule3 = match unsafe { rules.Item(&name) } { @@ -410,6 +461,10 @@ fn log_line(log: &mut File, msg: &str) -> Result<()> { #[cfg(test)] mod tests { + use pretty_assertions::assert_eq; + use windows::Win32::Foundation::S_FALSE; + use windows::Win32::NetworkManagement::WindowsFirewall::NET_FW_MODIFY_STATE_GP_OVERRIDE; + use super::*; #[test] @@ -507,4 +562,37 @@ mod tests { ); } } + + #[test] + fn local_policy_modify_state_accepts_effective_policy() { + assert!(validate_local_policy_modify_result(S_OK, NET_FW_MODIFY_STATE_OK).is_ok()); + } + + #[test] + fn local_policy_modify_state_rejects_ineffective_policy() { + let err = validate_local_policy_modify_result(S_OK, NET_FW_MODIFY_STATE_GP_OVERRIDE) + .expect_err("group-policy override should fail sandbox firewall setup"); + let failure = err + .downcast_ref::() + .expect("expected setup failure"); + + assert_eq!( + failure.code, + SetupErrorCode::HelperFirewallPolicyIneffective + ); + } + + #[test] + fn local_policy_modify_state_rejects_partial_profile_coverage() { + let err = validate_local_policy_modify_result(S_FALSE, NET_FW_MODIFY_STATE_OK) + .expect_err("partial profile coverage should fail sandbox firewall setup"); + let failure = err + .downcast_ref::() + .expect("expected setup failure"); + + assert_eq!( + failure.code, + SetupErrorCode::HelperFirewallPolicyIneffective + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/setup_error.rs b/codex-rs/windows-sandbox-rs/src/setup_error.rs index 64e618ace1..efaaa531e2 100644 --- a/codex-rs/windows-sandbox-rs/src/setup_error.rs +++ b/codex-rs/windows-sandbox-rs/src/setup_error.rs @@ -56,6 +56,8 @@ pub enum SetupErrorCode { HelperFirewallComInitFailed, /// Helper failed to access firewall policy or rule collections. HelperFirewallPolicyAccessFailed, + /// Helper detected that local firewall policy changes will not fully take effect. + HelperFirewallPolicyIneffective, /// Helper failed to create, update, or add the firewall rule. HelperFirewallRuleCreateOrAddFailed, /// Helper failed to verify the configured firewall rule scope. @@ -91,6 +93,7 @@ impl SetupErrorCode { Self::HelperCapabilitySidFailed => "helper_capability_sid_failed", Self::HelperFirewallComInitFailed => "helper_firewall_com_init_failed", Self::HelperFirewallPolicyAccessFailed => "helper_firewall_policy_access_failed", + Self::HelperFirewallPolicyIneffective => "helper_firewall_policy_ineffective", Self::HelperFirewallRuleCreateOrAddFailed => { "helper_firewall_rule_create_or_add_failed" } From a98b57d0659cff6f9518e3db511e5622669cf48c Mon Sep 17 00:00:00 2001 From: Alex Daley Date: Wed, 13 May 2026 15:18:35 -0400 Subject: [PATCH 06/47] [codex] Reuse Apps MCP path override for plugin-service rollout (#22527) ## Summary - reuse `apps_mcp_path_override` for the plugin-service rollout, defaulting enabled boolean overrides to `/ps/mcp` while preserving explicit configured paths ## Validation - `just write-config-schema` - `just fmt` - `cargo test -p codex-mcp` - `cargo test -p codex-core apps_mcp_path_override` - `cargo test -p codex-core to_mcp_config_preserves_apps_feature_from_config` - `cargo test -p codex-features` --- codex-rs/core/src/config/config_tests.rs | 52 ++++++++++++++++++++++++ codex-rs/core/src/config/mod.rs | 1 + 2 files changed, 53 insertions(+) diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 9fc8c2643f..664730805a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8769,6 +8769,58 @@ path = "/custom/mcp" Ok(()) } +#[tokio::test] +async fn config_defaults_enabled_apps_mcp_path_override_to_plugin_service() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features] +apps_mcp_path_override = true +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert!(config.features.enabled(Feature::AppsMcpPathOverride)); + assert_eq!(config.apps_mcp_path_override.as_deref(), Some("/ps/mcp")); + Ok(()) +} + +#[tokio::test] +async fn config_preserves_explicit_apps_mcp_path_override_path() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features.apps_mcp_path_override] +enabled = true +path = "/custom/mcp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.apps_mcp_path_override.as_deref(), + Some("/custom/mcp") + ); + assert!(config.features.enabled(Feature::AppsMcpPathOverride)); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3b4e674446..2071e1ed25 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2615,6 +2615,7 @@ impl Config { .and_then(|config| config.path.as_ref()) .or_else(|| base.and_then(|config| config.path.as_ref())) .cloned() + .or_else(|| Some("/ps/mcp".to_string())) } else { None }; From d18a7c982e4abad5bf549cda6f4b61a18c10702e Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 13 May 2026 12:33:36 -0700 Subject: [PATCH 07/47] chore(config) rm Feature::CodexGitCommit (#22412) ## Summary Removes the unused Feature::CodexGitCommit ## Testing - [x] tests pass --- codex-rs/Cargo.lock | 11 -- codex-rs/Cargo.toml | 2 - codex-rs/app-server/Cargo.toml | 1 - codex-rs/app-server/src/extensions.rs | 1 - codex-rs/config/src/config_toml.rs | 8 -- codex-rs/core/config.schema.json | 4 - codex-rs/core/src/config/config_tests.rs | 4 - codex-rs/core/src/config/mod.rs | 12 -- codex-rs/core/src/session/tests.rs | 32 ++--- codex-rs/ext/git-attribution/BUILD.bazel | 6 - codex-rs/ext/git-attribution/Cargo.toml | 21 ---- codex-rs/ext/git-attribution/src/lib.rs | 134 --------------------- codex-rs/features/src/lib.rs | 5 +- codex-rs/thread-manager-sample/src/main.rs | 1 - 14 files changed, 18 insertions(+), 224 deletions(-) delete mode 100644 codex-rs/ext/git-attribution/BUILD.bazel delete mode 100644 codex-rs/ext/git-attribution/Cargo.toml delete mode 100644 codex-rs/ext/git-attribution/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 25827331b2..a0a49e4399 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1902,7 +1902,6 @@ dependencies = [ "codex-feedback", "codex-file-search", "codex-file-watcher", - "codex-git-attribution", "codex-git-utils", "codex-guardian", "codex-hooks", @@ -2912,16 +2911,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "codex-git-attribution" -version = "0.0.0" -dependencies = [ - "codex-core", - "codex-extension-api", - "codex-features", - "pretty_assertions", -] - [[package]] name = "codex-git-utils" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index f5cfda7296..14b1264a81 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -46,7 +46,6 @@ members = [ "execpolicy-legacy", "ext/extension-api", "ext/guardian", - "ext/git-attribution", "ext/memories", "external-agent-migration", "external-agent-sessions", @@ -164,7 +163,6 @@ codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } codex-guardian = { path = "ext/guardian" } -codex-git-attribution = { path = "ext/git-attribution" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } codex-experimental-api-macros = { path = "codex-experimental-api-macros" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index ecc8d1c25a..c75461ecf7 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -42,7 +42,6 @@ codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } codex-guardian = { workspace = true } -codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } codex-hooks = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 411685e7d8..a293daf4f2 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -17,7 +17,6 @@ where S: AgentSpawner + 'static, { let mut builder = ExtensionRegistryBuilder::::new(); - codex_git_attribution::install(&mut builder); codex_guardian::install(&mut builder, guardian_agent_spawner); Arc::new(builder.build()) } diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 06993e046a..fdca03de1a 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -177,14 +177,6 @@ pub struct ConfigToml { /// Compact prompt used for history compaction. pub compact_prompt: Option, - /// Optional commit attribution text for commit message co-author trailers. - /// This top-level setting only takes effect when `[features].codex_git_commit` - /// is enabled. - /// - /// When enabled and unset, Codex uses `Codex `. - /// Set to an empty string to disable automatic commit attribution. - pub commit_attribution: Option, - /// When set, restricts ChatGPT login to a specific workspace identifier. #[serde(default)] pub forced_chatgpt_workspace_id: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index faf7ec6817..847cd48399 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -4026,10 +4026,6 @@ "default": null, "description": "Preferred backend for storing CLI auth credentials. file (default): Use a file in the Codex home directory. keyring: Use an OS-specific keyring service. auto: Use the keyring if available, otherwise use a file." }, - "commit_attribution": { - "description": "Optional commit attribution text for commit message co-author trailers. This top-level setting only takes effect when `[features].codex_git_commit` is enabled.\n\nWhen enabled and unset, Codex uses `Codex `. Set to an empty string to disable automatic commit attribution.", - "type": "string" - }, "compact_prompt": { "description": "Compact prompt used for history compaction.", "type": "string" diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 664730805a..d4db387631 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -7357,7 +7357,6 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -7805,7 +7804,6 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -7967,7 +7965,6 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, @@ -8114,7 +8111,6 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { include_skill_instructions: true, include_environment_context: true, compact_prompt: None, - commit_attribution: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: true, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 2071e1ed25..4aa3230332 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -478,15 +478,6 @@ pub struct Config { /// Compact prompt override. pub compact_prompt: Option, - /// Optional commit attribution text for commit message co-author trailers. - /// This top-level setting only takes effect when `[features].codex_git_commit` - /// is enabled. - /// - /// - `None`: use default attribution (`Codex `) - /// - `Some("")` or whitespace-only: disable commit attribution - /// - `Some("...")`: use the provided attribution text verbatim - pub commit_attribution: Option, - /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command @@ -2809,8 +2800,6 @@ impl Config { } }); - let commit_attribution = cfg.commit_attribution; - // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -3052,7 +3041,6 @@ impl Config { personality, developer_instructions, compact_prompt, - commit_attribution, include_permissions_instructions, include_apps_instructions, include_collaboration_mode_instructions, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 577b2fa5ec..36e4e866d9 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -6493,10 +6493,10 @@ async fn make_multi_agent_v2_usage_hint_test_session( (session, turn_context) } -struct GitAttributionTestContributor; -struct GitAttributionTestState; +struct PromptExtensionTestContributor; +struct PromptExtensionTestState; -impl codex_extension_api::ContextContributor for GitAttributionTestContributor { +impl codex_extension_api::ContextContributor for PromptExtensionTestContributor { fn contribute<'a>( &'a self, _session_store: &'a codex_extension_api::ExtensionData, @@ -6506,11 +6506,11 @@ impl codex_extension_api::ContextContributor for GitAttributionTestContributor { > { Box::pin(async move { thread_store - .get::() + .get::() .is_some() .then(|| { codex_extension_api::PromptFragment::developer_policy( - "git attribution extension enabled", + "prompt extension enabled", ) }) .into_iter() @@ -6519,21 +6519,21 @@ impl codex_extension_api::ContextContributor for GitAttributionTestContributor { } } -fn git_attribution_test_registry() +fn prompt_extension_test_registry() -> Arc> { let mut builder = codex_extension_api::ExtensionRegistryBuilder::new(); - builder.prompt_contributor(Arc::new(GitAttributionTestContributor)); + builder.prompt_contributor(Arc::new(PromptExtensionTestContributor)); Arc::new(builder.build()) } #[tokio::test] -async fn build_initial_context_includes_git_attribution_from_extensions() { +async fn build_initial_context_includes_prompt_fragments_from_extensions() { let (mut session, turn_context) = make_session_and_context().await; - session.services.extensions = git_attribution_test_registry(); + session.services.extensions = prompt_extension_test_registry(); session .services .thread_extension_data - .insert(GitAttributionTestState); + .insert(PromptExtensionTestState); let initial_context = session.build_initial_context(&turn_context).await; let developer_messages = developer_message_texts(&initial_context); @@ -6542,15 +6542,15 @@ async fn build_initial_context_includes_git_attribution_from_extensions() { developer_messages .iter() .flatten() - .any(|text| *text == "git attribution extension enabled"), - "expected git attribution developer text, got {developer_messages:?}" + .any(|text| *text == "prompt extension enabled"), + "expected prompt extension developer text, got {developer_messages:?}" ); } #[tokio::test] -async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() { +async fn build_initial_context_omits_prompt_fragments_without_extension_state() { let (mut session, turn_context) = make_session_and_context().await; - session.services.extensions = git_attribution_test_registry(); + session.services.extensions = prompt_extension_test_registry(); let initial_context = session.build_initial_context(&turn_context).await; let developer_messages = developer_message_texts(&initial_context); @@ -6559,8 +6559,8 @@ async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() !developer_messages .iter() .flatten() - .any(|text| *text == "git attribution extension enabled"), - "did not expect git attribution developer text, got {developer_messages:?}" + .any(|text| *text == "prompt extension enabled"), + "did not expect prompt extension developer text, got {developer_messages:?}" ); } diff --git a/codex-rs/ext/git-attribution/BUILD.bazel b/codex-rs/ext/git-attribution/BUILD.bazel deleted file mode 100644 index 0cb1ab5764..0000000000 --- a/codex-rs/ext/git-attribution/BUILD.bazel +++ /dev/null @@ -1,6 +0,0 @@ -load("//:defs.bzl", "codex_rust_crate") - -codex_rust_crate( - name = "git-attribution", - crate_name = "codex_git_attribution", -) diff --git a/codex-rs/ext/git-attribution/Cargo.toml b/codex-rs/ext/git-attribution/Cargo.toml deleted file mode 100644 index edf8c07196..0000000000 --- a/codex-rs/ext/git-attribution/Cargo.toml +++ /dev/null @@ -1,21 +0,0 @@ -[package] -edition.workspace = true -license.workspace = true -name = "codex-git-attribution" -version.workspace = true - -[lib] -name = "codex_git_attribution" -path = "src/lib.rs" -doctest = false - -[lints] -workspace = true - -[dependencies] -codex-core = { workspace = true } -codex-extension-api = { workspace = true } -codex-features = { workspace = true } - -[dev-dependencies] -pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/git-attribution/src/lib.rs b/codex-rs/ext/git-attribution/src/lib.rs deleted file mode 100644 index 03655296da..0000000000 --- a/codex-rs/ext/git-attribution/src/lib.rs +++ /dev/null @@ -1,134 +0,0 @@ -use std::sync::Arc; - -use codex_core::config::Config; -use codex_extension_api::ContextContributor; -use codex_extension_api::ExtensionData; -use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::PromptFragment; -use codex_extension_api::ThreadLifecycleContributor; -use codex_extension_api::ThreadStartInput; -use codex_features::Feature; - -const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; - -/// Contributes the configured git-attribution instruction. -#[derive(Clone, Copy, Debug, Default)] -pub struct GitAttributionExtension; - -impl ContextContributor for GitAttributionExtension { - fn contribute<'a>( - &'a self, - _session_store: &'a ExtensionData, - thread_store: &'a ExtensionData, - ) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - let Some(config_store) = thread_store.get::() else { - return Vec::new(); - }; - if !config_store.enabled { - return Vec::new(); - } - build_instruction(config_store.prompt.as_deref()) - .map(PromptFragment::developer_policy) - .into_iter() - .collect() - }) - } -} - -#[derive(Clone, Debug, Default)] -struct GitAttributionConfig { - enabled: bool, - prompt: Option, -} - -impl ThreadLifecycleContributor for GitAttributionExtension { - fn on_thread_start(&self, input: ThreadStartInput<'_, Config>) { - input.thread_store.insert(GitAttributionConfig { - enabled: input.config.features.enabled(Feature::CodexGitCommit), - prompt: input.config.commit_attribution.clone(), - }); - } -} - -/// Installs the git-attribution contributors into the extension registry. -pub fn install(registry: &mut ExtensionRegistryBuilder) { - let extension = Arc::new(GitAttributionExtension); - registry.thread_lifecycle_contributor(extension.clone()); - registry.prompt_contributor(extension); -} - -fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option { - let value = resolve_attribution_value(config_attribution)?; - Some(format!("Co-authored-by: {value}")) -} - -fn build_instruction(config_attribution: Option<&str>) -> Option { - let trailer = build_commit_message_trailer(config_attribution)?; - Some(format!( - "When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block." - )) -} - -fn resolve_attribution_value(config_attribution: Option<&str>) -> Option { - match config_attribution { - Some(value) => { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_string()) - } - } - None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()), - } -} - -#[cfg(test)] -mod tests { - use pretty_assertions::assert_eq; - - use super::build_commit_message_trailer; - use super::build_instruction; - use super::resolve_attribution_value; - - #[test] - fn blank_attribution_disables_trailer_prompt() { - assert_eq!(build_commit_message_trailer(Some("")), None); - assert_eq!(build_instruction(Some(" ")), None); - } - - #[test] - fn default_attribution_uses_codex_trailer() { - assert_eq!( - build_commit_message_trailer(/*config_attribution*/ None).as_deref(), - Some("Co-authored-by: Codex ") - ); - } - - #[test] - fn resolve_value_handles_default_custom_and_blank() { - assert_eq!( - resolve_attribution_value(/*config_attribution*/ None), - Some("Codex ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent ")), - Some("MyAgent ".to_string()) - ); - assert_eq!( - resolve_attribution_value(Some("MyAgent")), - Some("MyAgent".to_string()) - ); - assert_eq!(resolve_attribution_value(Some(" ")), None); - } - - #[test] - fn instruction_mentions_trailer_and_omits_generated_with() { - let instruction = - build_instruction(Some("AgentX ")).expect("instruction expected"); - assert!(instruction.contains("Co-authored-by: AgentX ")); - assert!(instruction.contains("exactly once")); - assert!(!instruction.contains("Generated-with")); - } -} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 75535cbaaa..c0a7a8a54d 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -130,7 +130,7 @@ pub enum Feature { RemoteModels, /// Experimental shell snapshotting. ShellSnapshot, - /// Enable git commit attribution guidance via model instructions. + /// Removed legacy git commit attribution guidance flag. CodexGitCommit, /// Enable runtime metrics snapshots via a manual reader. RuntimeMetrics, @@ -784,11 +784,10 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, - // Experimental program. Rendered in the `/experimental` menu for users. FeatureSpec { id: Feature::CodexGitCommit, key: "codex_git_commit", - stage: Stage::UnderDevelopment, + stage: Stage::Removed, default_enabled: false, }, FeatureSpec { diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 5f9fa813e0..fff914eff3 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -196,7 +196,6 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R include_skill_instructions: false, include_environment_context: false, compact_prompt: None, - commit_attribution: None, notify: None, tui_notifications: TuiNotificationSettings::default(), animations: true, From 9c691b74d682bd9cc37d6f294336b3210f429424 Mon Sep 17 00:00:00 2001 From: Dylan Hurd Date: Wed, 13 May 2026 12:35:37 -0700 Subject: [PATCH 08/47] chore(config) rm tools.view_image (#22501) ## Summary It appears this config flag has been broken/a noop for quite some time: since https://github.com/openai/codex/pull/8850. Let's simplify and get rid of this. ## Testing - [x] Updated unit tests --- .../json/codex_app_server_protocol.schemas.json | 6 ------ .../codex_app_server_protocol.v2.schemas.json | 6 ------ .../schema/json/v2/ConfigReadResponse.json | 6 ------ .../schema/typescript/v2/ToolsV2.ts | 2 +- codex-rs/app-server-protocol/src/protocol/v1.rs | 1 - .../src/protocol/v2/config.rs | 1 - codex-rs/app-server/tests/suite/v2/config_rpc.rs | 11 ----------- codex-rs/config/src/config_toml.rs | 5 ----- codex-rs/config/src/profile_toml.rs | 1 - codex-rs/core/config.schema.json | 8 -------- codex-rs/core/src/config/config_tests.rs | 16 ++-------------- 11 files changed, 3 insertions(+), 60 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 667607d43e..6da572813f 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 @@ -17693,12 +17693,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { 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 c1a99eddda..e338f4151f 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 @@ -15517,12 +15517,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 87a826e5af..2e0a05725c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -748,12 +748,6 @@ }, "ToolsV2": { "properties": { - "view_image": { - "type": [ - "boolean", - "null" - ] - }, "web_search": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts index 784991f017..13dc06e915 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ToolsV2.ts @@ -3,4 +3,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { WebSearchToolConfig } from "../WebSearchToolConfig"; -export type ToolsV2 = { web_search: WebSearchToolConfig | null, view_image: boolean | null, }; +export type ToolsV2 = { web_search: WebSearchToolConfig | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index 95ab710a6b..f8e9afdd12 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -228,7 +228,6 @@ pub struct Profile { #[serde(rename_all = "camelCase")] pub struct Tools { pub web_search: Option, - pub view_image: Option, } #[derive(Deserialize, Debug, Clone, PartialEq, Serialize, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index 2c0e2df8e5..28a2ad01f9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -120,7 +120,6 @@ pub struct SandboxWorkspaceWrite { #[ts(export_to = "v2/")] pub struct ToolsV2 { pub web_search: Option, - pub view_image: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, ExperimentalApi)] diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index b5f795740c..406dc9ef6c 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -101,9 +101,6 @@ model = "gpt-user" [tools.web_search] context_size = "low" allowed_domains = ["example.com"] - -[tools] -view_image = false "#, )?; let codex_home_path = codex_home.path().canonicalize()?; @@ -138,7 +135,6 @@ view_image = false allowed_domains: Some(vec!["example.com".to_string()]), location: None, }), - view_image: Some(false), } ); assert_eq!( @@ -159,13 +155,6 @@ view_image = false file: user_file.clone(), } ); - assert_eq!( - origins.get("tools.view_image").expect("origin").name, - ConfigLayerSource::User { - file: user_file.clone(), - } - ); - let layers = layers.expect("layers present"); assert_layers_user_then_optional_system(&layers, user_file)?; diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index fdca03de1a..b627a83726 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -598,10 +598,6 @@ pub struct ToolsToml { deserialize_with = "deserialize_optional_web_search_tool_config" )] pub web_search: Option, - - /// Enable the `view_image` tool that lets the agent attach local images. - #[serde(default)] - pub view_image: Option, } #[derive(Deserialize)] @@ -679,7 +675,6 @@ impl From for Tools { fn from(tools_toml: ToolsToml) -> Self { Self { web_search: tools_toml.web_search.is_some().then_some(true), - view_image: tools_toml.view_image, } } } diff --git a/codex-rs/config/src/profile_toml.rs b/codex-rs/config/src/profile_toml.rs index 11d3a6a403..ed036d1b4e 100644 --- a/codex-rs/config/src/profile_toml.rs +++ b/codex-rs/config/src/profile_toml.rs @@ -61,7 +61,6 @@ pub struct ConfigProfile { pub include_environment_context: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_freeform_apply_patch: Option, - pub tools_view_image: Option, pub tools: Option, pub web_search: Option, pub analytics: Option, diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 847cd48399..82fde87e78 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -685,9 +685,6 @@ "tools": { "$ref": "#/definitions/ToolsToml" }, - "tools_view_image": { - "type": "boolean" - }, "tui": { "allOf": [ { @@ -2504,11 +2501,6 @@ "ToolsToml": { "additionalProperties": false, "properties": { - "view_image": { - "default": null, - "description": "Enable the `view_image` tool that lets the agent attach local images.", - "type": "boolean" - }, "web_search": { "allOf": [ { diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index d4db387631..f6d6eff968 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -376,13 +376,7 @@ web_search = true ) .expect("TOML deserialization should succeed"); - assert_eq!( - cfg.tools, - Some(ToolsToml { - web_search: None, - view_image: None, - }) - ); + assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); } #[test] @@ -395,13 +389,7 @@ web_search = false ) .expect("TOML deserialization should succeed"); - assert_eq!( - cfg.tools, - Some(ToolsToml { - web_search: None, - view_image: None, - }) - ); + assert_eq!(cfg.tools, Some(ToolsToml { web_search: None })); } #[test] From fb7cfc813a4fa09a3fe479a33ca071c4c4f97fc3 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Wed, 13 May 2026 12:38:34 -0700 Subject: [PATCH 09/47] fix: prevent codex-backend from stealing originator (#22533) ## Why Remote control starts by letting `codex-backend` initialize against the app-server as an infrastructure health/proxy client before the real remote client connects. App-server initialization also sets the process-wide `originator` from `client_info.name`, so `codex-backend` could become the sticky originator for later model/API requests even after the real client initialized. ## What changed - Treat `codex-backend` as a non-originating initialize client, alongside the existing `codex_app_server_daemon` probe client. - Preserve normal per-connection initialize behavior, including session metadata and initialize analytics. - Add regression coverage that verifies `codex-backend` initialize does not replace the default originator. ## Testing - `cargo test -p codex-app-server --test all initialize_codex_backend_does_not_override_originator` --- .../initialize_processor.rs | 4 +-- .../app-server/tests/suite/v2/initialize.rs | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index c33af189cf..a40007db11 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -13,7 +13,7 @@ use super::*; use crate::message_processor::ConnectionSessionState; use crate::message_processor::InitializedConnectionSessionState; -const DAEMON_PROBE_CLIENT_NAME: &str = "codex_app_server_daemon"; +const NON_ORIGINATING_CLIENT_NAMES: &[&str] = &["codex_app_server_daemon", "codex-backend"]; #[derive(Clone)] pub(crate) struct InitializeRequestProcessor { @@ -92,7 +92,7 @@ impl InitializeRequestProcessor { } let originator = name.clone(); let user_agent_suffix = format!("{name}; {version}"); - let mutates_global_identity = name != DAEMON_PROBE_CLIENT_NAME; + let mutates_global_identity = !NON_ORIGINATING_CLIENT_NAMES.contains(&name.as_str()); let codex_home = self.config.codex_home.clone(); if session .initialize(InitializedConnectionSessionState { diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 3d3a473fab..47cacdb20e 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -89,6 +89,33 @@ async fn initialize_probe_does_not_override_originator() -> Result<()> { Ok(()) } +#[tokio::test] +async fn initialize_codex_backend_does_not_override_originator() -> Result<()> { + let responses = Vec::new(); + let server = create_mock_responses_server_sequence_unchecked(responses).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri(), "never")?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + + let message = timeout( + DEFAULT_READ_TIMEOUT, + mcp.initialize_with_client_info(ClientInfo { + name: "codex-backend".to_string(), + title: Some("Codex Backend".to_string()), + version: "0.1.0".to_string(), + }), + ) + .await??; + + let JSONRPCMessage::Response(response) = message else { + anyhow::bail!("expected initialize response, got {message:?}"); + }; + let InitializeResponse { user_agent, .. } = to_response::(response)?; + + assert!(user_agent.starts_with("codex_cli_rs/")); + Ok(()) +} + #[tokio::test] async fn initialize_respects_originator_override_env_var() -> Result<()> { let responses = Vec::new(); From d666238b4058e5f77c5b8925c25929e3ceae55a6 Mon Sep 17 00:00:00 2001 From: starr-openai Date: Wed, 13 May 2026 12:46:51 -0700 Subject: [PATCH 10/47] Shard Bazel Windows tests across jobs (#22408) ## Summary - split the single PR-blocking Bazel Windows test leg into four Windows shard jobs - preserve the existing required Windows Bazel check name with a lightweight aggregate gate - keep Linux/macOS Bazel test jobs and the separate Windows clippy/release jobs unchanged ## Why The ordinary PR Windows Bazel test leg was one GitHub Actions job, so Bazel only had in-job parallelism. This gives that lane real job-level fanout across separate Windows hosts while keeping the target set disjoint via stable label hashing. ## Evidence - final pre-rebase green run: `25774733562` - Windows shard target counts: `61/212`, `48/212`, `52/212`, `51/212` - Windows test fanout completed in about 7m29s versus a recent monolithic median around 22m26s ## Notes - this is scoped to the Bazel Windows test leg only - each shard keeps the existing Windows cross-compile/RBE path and restores the former monolithic Windows test cache - shard jobs do not upload duplicate repository caches after test work, keeping cache cleanup off the PR-blocking shard path - no local validation run; relying on GitHub Actions for the workflow-shaped check Co-authored-by: Codex --- .github/workflows/bazel.yml | 133 +++++++++++++++++++++++++++++++----- 1 file changed, 116 insertions(+), 17 deletions(-) diff --git a/.github/workflows/bazel.yml b/.github/workflows/bazel.yml index cc3968d306..11c0988ceb 100644 --- a/.github/workflows/bazel.yml +++ b/.github/workflows/bazel.yml @@ -17,10 +17,10 @@ concurrency: cancel-in-progress: ${{ github.ref_name != 'main' }} jobs: test: - # PRs use a fast Windows cross-compiled test leg for pre-merge signal. - # Post-merge pushes to main also run the native Windows test job below for - # broader Windows signal without putting PR latency back on the critical - # path. Cargo CI owns V8/code-mode test coverage for now. + # PRs use the sharded Windows cross-compiled test jobs below. Post-merge + # pushes to main also run the native Windows test job for broader Windows + # signal without putting PR latency back on the critical path. Cargo CI + # owns V8/code-mode test coverage for now. timeout-minutes: 30 strategy: fail-fast: false @@ -44,12 +44,6 @@ jobs: # - os: ubuntu-24.04-arm # target: aarch64-unknown-linux-gnu - # Windows fast path: build the windows-gnullvm binaries with Linux - # RBE, then run the resulting Windows tests on the Windows runner. - # Cargo CI preserves V8/code-mode coverage while Bazel CI keeps broad - # non-code-mode signal. - - os: windows-latest - target: x86_64-pc-windows-gnullvm runs-on: ${{ matrix.os }} # Configure a human readable name for each job @@ -108,13 +102,6 @@ jobs: --test_verbose_timeout_warnings --build_metadata=COMMIT_SHA=${GITHUB_SHA} ) - if [[ "${RUNNER_OS}" == "Windows" ]]; then - bazel_wrapper_args+=( - --windows-cross-compile - --remote-download-toplevel - ) - fi - ./.github/scripts/run-bazel-ci.sh \ "${bazel_wrapper_args[@]}" \ -- \ @@ -141,6 +128,118 @@ jobs: path: ${{ steps.prepare_bazel.outputs.repository-cache-path }} key: ${{ steps.prepare_bazel.outputs.repository-cache-key }} + test-windows-shard: + # Split the Windows Bazel test leg across separate Windows + # hosts. Each shard still uses Linux RBE for build actions, but the test + # execution itself happens on its own Windows runner. + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + shard: + - 1 + - 2 + - 3 + - 4 + runs-on: windows-latest + name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm shard ${{ matrix.shard }}/4 + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Prepare Bazel CI + id: prepare_bazel + uses: ./.github/actions/prepare-bazel-ci + with: + target: x86_64-pc-windows-gnullvm + # Reuse the former monolithic Windows test cache for restores. Do + # not save it from every shard below; duplicate uploads would sit on + # the PR-blocking critical path after the useful test work is done. + cache-scope: bazel-test + install-test-prereqs: "true" + + - name: bazel test shard + env: + BAZEL_TEST_SHARD: ${{ matrix.shard }} + BAZEL_TEST_SHARD_COUNT: 4 + BUILDBUDDY_API_KEY: ${{ secrets.BUILDBUDDY_API_KEY }} + shell: bash + run: | + set -euo pipefail + + bazel_test_query='tests(//...) except tests(//third_party/v8:all) except //codex-rs/code-mode:code-mode-unit-tests except //codex-rs/v8-poc:v8-poc-unit-tests except attr(tags, "manual", tests(//...))' + mapfile -t bazel_targets < <( + MSYS2_ARG_CONV_EXCL='*' bazel query --output=label "${bazel_test_query}" \ + | LC_ALL=C sort + ) + + selected_targets=() + for bazel_target in "${bazel_targets[@]}"; do + target_bucket="$( + printf '%s\n' "${bazel_target}" \ + | cksum \ + | awk -v shard_count="${BAZEL_TEST_SHARD_COUNT}" '{ print ($1 % shard_count) + 1 }' + )" + if [[ "${target_bucket}" == "${BAZEL_TEST_SHARD}" ]]; then + selected_targets+=("${bazel_target}") + fi + done + + if [[ ${#selected_targets[@]} -eq 0 ]]; then + echo "No Bazel test targets selected for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." >&2 + exit 1 + fi + + echo "Selected ${#selected_targets[@]} of ${#bazel_targets[@]} Bazel test targets for Windows shard ${BAZEL_TEST_SHARD}/${BAZEL_TEST_SHARD_COUNT}." + + bazel_test_args=( + test + --skip_incompatible_explicit_targets + --test_tag_filters=-argument-comment-lint + --test_verbose_timeout_warnings + --build_metadata=COMMIT_SHA=${GITHUB_SHA} + --build_metadata=TAG_windows_test_shard=${BAZEL_TEST_SHARD} + ) + + ./.github/scripts/run-bazel-ci.sh \ + --print-failed-action-summary \ + --print-failed-test-logs \ + --windows-cross-compile \ + --remote-download-toplevel \ + -- \ + "${bazel_test_args[@]}" \ + -- \ + "${selected_targets[@]}" + + - name: Upload Bazel execution logs + if: always() && !cancelled() + continue-on-error: true + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 + with: + name: bazel-execution-logs-test-x86_64-pc-windows-gnullvm-shard-${{ matrix.shard }} + path: ${{ runner.temp }}/bazel-execution-logs + if-no-files-found: ignore + + test-windows: + # Preserve the existing required-check surface while the real work happens + # in the sharded Windows jobs above. + if: always() + needs: test-windows-shard + runs-on: ubuntu-24.04 + name: Bazel test on windows-latest for x86_64-pc-windows-gnullvm + + steps: + - name: Confirm Windows Bazel test shards passed + shell: bash + run: | + if [[ "${{ needs.test-windows-shard.result }}" != "success" ]]; then + echo "Windows Bazel test shards finished with result: ${{ needs.test-windows-shard.result }}" >&2 + exit 1 + fi + test-windows-native-main: # Native Windows Bazel tests are slower and frequently approach the # 30-minute PR budget. Run this only for post-merge commits to main and give From 3ac1d155987b40a2a4564428b44460f8fe746473 Mon Sep 17 00:00:00 2001 From: pakrym-oai Date: Wed, 13 May 2026 13:18:56 -0700 Subject: [PATCH 11/47] Use selected environment cwd for filesystem helpers (#22542) ## Why `TurnContext::cwd` is deprecated in favor of resolving paths from the selected turn environment cwd. A few filesystem-oriented paths were still constructing sandbox context from the legacy cwd and then mutating it afterward, or resolving local file paths through the deprecated helper. ## What changed - Make `TurnContext::file_system_sandbox_context` take the trusted cwd explicitly. - Pass the selected turn environment cwd directly from `apply_patch` and `view_image` call sites. - Restrict `spawn_agents_on_csv` to exactly one local environment and resolve input/output CSV paths from that local environment cwd. - Remove a redundant test setup assignment that only synchronized deprecated `TurnContext::cwd` with a replaced config. ## Validation - `cargo test -p codex-core view_image` - `cargo test -p codex-core maybe_persist_mcp_tool_approval_writes_project_config_for_project_server` - `cargo test -p codex-core parse_csv_supports_quotes_and_commas` - `git diff --check` --- codex-rs/core/src/mcp_tool_call_tests.rs | 4 --- codex-rs/core/src/session/turn_context.rs | 4 +-- .../agent_jobs/spawn_agents_on_csv.rs | 26 ++++++++++++++----- .../core/src/tools/handlers/apply_patch.rs | 6 ++--- .../core/src/tools/handlers/view_image.rs | 8 ++---- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/codex-rs/core/src/mcp_tool_call_tests.rs b/codex-rs/core/src/mcp_tool_call_tests.rs index 8b4f1ba223..714b0b99e6 100644 --- a/codex-rs/core/src/mcp_tool_call_tests.rs +++ b/codex-rs/core/src/mcp_tool_call_tests.rs @@ -2233,10 +2233,6 @@ async fn maybe_persist_mcp_tool_approval_writes_project_config_for_project_serve .build() .await .expect("load project config"); - #[allow(deprecated)] - { - turn_context.cwd = config.cwd.clone(); - } turn_context.config = Arc::new(config); let key = McpToolApprovalKey { server: "docs".to_string(), diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 8caec7aaea..71e238a540 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -301,6 +301,7 @@ impl TurnContext { pub(crate) fn file_system_sandbox_context( &self, additional_permissions: Option, + cwd: &AbsolutePathBuf, ) -> FileSystemSandboxContext { let (base_file_system_sandbox_policy, base_network_sandbox_policy) = self.permission_profile.to_runtime_permissions(); @@ -319,8 +320,7 @@ impl TurnContext { ); FileSystemSandboxContext { permissions, - #[allow(deprecated)] - cwd: Some(self.cwd.clone()), + cwd: Some(cwd.clone()), windows_sandbox_level: self.windows_sandbox_level, windows_sandbox_private_desktop: self .config diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index e1a37be6b6..6ad0515e81 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -7,6 +7,7 @@ use crate::tools::registry::ToolExecutor; use crate::tools::registry::ToolHandler; use codex_tools::ToolName; use codex_tools::ToolSpec; +use codex_utils_absolute_path::AbsolutePathBuf; use super::*; @@ -67,9 +68,9 @@ pub async fn handle( )); } + let cwd = single_local_environment_cwd(&turn)?; let db = required_state_db(&session)?; - #[allow(deprecated)] - let input_path = turn.resolve_path(Some(args.csv_path)); + let input_path = cwd.join(args.csv_path); let input_path_display = input_path.display().to_string(); let csv_content = tokio::fs::read_to_string(&input_path) .await @@ -142,10 +143,7 @@ pub async fn handle( let job_id = Uuid::new_v4().to_string(); let output_csv_path = args.output_csv_path.map_or_else( || default_output_csv_path(&input_path, job_id.as_str()), - |path| { - #[allow(deprecated)] - turn.resolve_path(Some(path)) - }, + |path| cwd.join(path), ); let job_suffix = &job_id[..8]; let job_name = format!("agent-job-{job_suffix}"); @@ -290,3 +288,19 @@ pub async fn handle( })?; Ok(FunctionToolOutput::from_text(content, Some(true))) } + +fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, FunctionCallError> { + let [turn_environment] = turn.environments.turn_environments.as_slice() else { + return Err(FunctionCallError::RespondToModel( + "spawn_agents_on_csv requires exactly one local environment".to_string(), + )); + }; + + if turn_environment.environment.is_remote() { + return Err(FunctionCallError::RespondToModel( + "spawn_agents_on_csv is not supported for remote environments".to_string(), + )); + } + + Ok(&turn_environment.cwd) +} diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index 32edd21da1..5a709b287f 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -345,8 +345,7 @@ impl ToolExecutor for ApplyPatchHandler { }; let cwd = turn_environment.cwd.clone(); let fs = turn_environment.environment.get_filesystem(); - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) .await { @@ -499,8 +498,7 @@ pub(crate) async fn intercept_apply_patch( call_id: &str, tool_name: &str, ) -> Result, FunctionCallError> { - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, cwd); match codex_apply_patch::maybe_parse_apply_patch_verified(command, cwd, fs, Some(&sandbox)) .await { diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index bb74adb86d..1587e90ddb 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -134,8 +134,7 @@ impl ToolExecutor for ViewImageHandler { }; let cwd = turn_environment.cwd.clone(); let abs_path = cwd.join(path); - let mut sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None); - sandbox.cwd = Some(cwd.clone()); + let sandbox = turn.file_system_sandbox_context(/*additional_permissions*/ None, &cwd); let fs = turn_environment.environment.get_filesystem(); let metadata = fs @@ -282,10 +281,7 @@ mod tests { let (session, mut turn) = make_session_and_context().await; let image_dir = tempfile::tempdir().expect("create image temp dir"); let image_cwd = image_dir.abs(); - #[allow(deprecated)] - { - turn.cwd = image_cwd.clone(); - } + turn.environments .turn_environments .first_mut() From 0a2d751fc2704773432751312f171066357ebdad Mon Sep 17 00:00:00 2001 From: stevenlee-oai Date: Wed, 13 May 2026 13:26:04 -0700 Subject: [PATCH 12/47] Add callback ids to local MCP OAuth redirects (#20237) ## Summary - Add a deterministic callback-id path segment to local MCP OAuth redirect URIs before starting authorization. - Derive the callback id from the normalized MCP server URL and encode it as a 12-character URL-safe hash. - Reuse the existing exact callback-path validation so OAuth completion only succeeds on the callback path that was sent in the redirect URI. ## Context Slack thread: https://openai.slack.com/archives/C087WB3AGCR/p1777480566571699 That thread calls out the OAuth mix-up class of issue for MCP servers. The connector/App Connect flow already has a callback_id concept that binds the OAuth callback URL to the MCP app/server identity. Codex desktop's local MCP OAuth flow was still using a generic local callback path like `/callback`, so this PR adds the same shape to the shared local MCP OAuth helper. ## Behavior Before this change, local MCP OAuth used: - default local callback URL: `http://127.0.0.1:/callback` - configured callback URL: `` unchanged After this change, Codex appends a deterministic callback-id segment: - default local callback URL: `http://127.0.0.1:/callback/` - configured callback URL: `/` The local callback server already compares the incoming request path against the path from the redirect URI. By appending the callback id before both authorization and callback validation, callbacks that arrive on the old generic path or a mismatched callback-id path are rejected. The callback id is bound to the MCP endpoint URL, including path and query, so path-based multi-tenant MCP deployments on the same origin do not share a callback path. URL fragments are ignored because they are not sent to the server. The change lives in `codex-rmcp-client`, so it covers both the normal desktop MCP OAuth login path and silent/plugin-triggered MCP OAuth login paths that use the same `perform_oauth_login_*` helpers. ## Scope and non-goals - This does not change the app-server protocol or desktop webview request shape. - This does not implement RFC 9207 `iss` validation; issuer validation is still useful when providers return `iss`. - This does not make arbitrary untrusted MCP servers safe to use. It specifically adds callback URL binding for the local MCP OAuth flow. ## Validation - `cargo fmt --all` - `cargo test -p codex-rmcp-client perform_oauth_login` --- codex-rs/Cargo.lock | 1 + codex-rs/rmcp-client/Cargo.toml | 1 + .../rmcp-client/src/perform_oauth_login.rs | 95 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a0a49e4399..564c1a9c78 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3471,6 +3471,7 @@ version = "0.0.0" dependencies = [ "anyhow", "axum", + "base64 0.22.1", "bytes", "codex-api", "codex-client", diff --git a/codex-rs/rmcp-client/Cargo.toml b/codex-rs/rmcp-client/Cargo.toml index 9be90e277a..0efd8a4178 100644 --- a/codex-rs/rmcp-client/Cargo.toml +++ b/codex-rs/rmcp-client/Cargo.toml @@ -13,6 +13,7 @@ axum = { workspace = true, default-features = false, features = [ "http1", "tokio", ] } +base64 = { workspace = true } codex-api = { workspace = true } codex-client = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/rmcp-client/src/perform_oauth_login.rs b/codex-rs/rmcp-client/src/perform_oauth_login.rs index 5bdb315381..f42786c652 100644 --- a/codex-rs/rmcp-client/src/perform_oauth_login.rs +++ b/codex-rs/rmcp-client/src/perform_oauth_login.rs @@ -7,9 +7,13 @@ use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use anyhow::bail; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; use reqwest::ClientBuilder; use reqwest::Url; use rmcp::transport::auth::OAuthState; +use sha2::Digest; +use sha2::Sha256; use tiny_http::Response; use tiny_http::Server; use tokio::sync::oneshot; @@ -378,6 +382,31 @@ fn resolve_redirect_uri(server: &Server, callback_url: Option<&str>) -> Result Result { + let mut parsed = + Url::parse(server_url).with_context(|| format!("invalid MCP server URL `{server_url}`"))?; + parsed + .host_str() + .ok_or_else(|| anyhow!("MCP server URL `{server_url}` must include a host"))?; + parsed.set_fragment(None); + + let digest = Sha256::digest(parsed.as_str().as_bytes()); + Ok(URL_SAFE_NO_PAD.encode(&digest[..9])) +} + +fn append_callback_id_to_redirect_uri(redirect_uri: &str, callback_id: &str) -> Result { + let mut parsed = Url::parse(redirect_uri) + .with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?; + let path = parsed.path(); + let new_path = if path.ends_with('/') { + format!("{path}{callback_id}") + } else { + format!("{path}/{callback_id}") + }; + parsed.set_path(&new_path); + Ok(parsed.to_string()) +} + fn callback_path_from_redirect_uri(redirect_uri: &str) -> Result { let parsed = Url::parse(redirect_uri) .with_context(|| format!("invalid redirect URI `{redirect_uri}`"))?; @@ -428,6 +457,8 @@ impl OauthLoginFlow { }; let redirect_uri = resolve_redirect_uri(&server, callback_url)?; + let callback_id = callback_id_from_server_url(server_url)?; + let redirect_uri = append_callback_id_to_redirect_uri(&redirect_uri, &callback_id)?; let callback_path = callback_path_from_redirect_uri(&redirect_uri)?; let (tx, rx) = oneshot::channel(); @@ -577,7 +608,9 @@ mod tests { use super::CallbackOutcome; use super::OAuthProviderError; + use super::append_callback_id_to_redirect_uri; use super::append_query_param; + use super::callback_id_from_server_url; use super::callback_path_from_redirect_uri; use super::parse_oauth_callback; @@ -593,6 +626,19 @@ mod tests { assert!(matches!(parsed, CallbackOutcome::Success(_))); } + #[test] + fn parse_oauth_callback_accepts_callback_id_path() { + let parsed = + parse_oauth_callback("/callback/abc123?code=abc&state=xyz", "/callback/abc123"); + assert!(matches!(parsed, CallbackOutcome::Success(_))); + } + + #[test] + fn parse_oauth_callback_rejects_missing_callback_id_path() { + let parsed = parse_oauth_callback("/callback?code=abc&state=xyz", "/callback/abc123"); + assert!(matches!(parsed, CallbackOutcome::Invalid)); + } + #[test] fn parse_oauth_callback_rejects_wrong_path() { let parsed = parse_oauth_callback("/callback?code=abc&state=xyz", "/oauth/callback"); @@ -622,6 +668,55 @@ mod tests { assert_eq!(path, "/oauth/callback"); } + #[test] + fn callback_id_is_bound_to_server_url() { + let callback_id = callback_id_from_server_url("https://mcp.example.com/mcp?tenant=one") + .expect("server URL should parse"); + let same_without_fragment = + callback_id_from_server_url("https://mcp.example.com/mcp?tenant=one#unused") + .expect("server URL should parse"); + let different_path = callback_id_from_server_url("https://mcp.example.com/sse?tenant=one") + .expect("server URL should parse"); + let different_query = callback_id_from_server_url("https://mcp.example.com/mcp?tenant=two") + .expect("server URL should parse"); + let different_origin = callback_id_from_server_url("https://mcp.example.com:8443/mcp") + .expect("server URL should parse"); + + assert_eq!(callback_id, same_without_fragment); + assert_ne!(callback_id, different_path); + assert_ne!(callback_id, different_query); + assert_ne!(callback_id, different_origin); + assert_eq!(callback_id.len(), 12); + assert!( + callback_id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') + ); + } + + #[test] + fn callback_id_is_appended_to_redirect_uri_path() { + let redirect_uri = + append_callback_id_to_redirect_uri("http://127.0.0.1:1234/callback", "abc123") + .expect("redirect URI should parse"); + + assert_eq!(redirect_uri, "http://127.0.0.1:1234/callback/abc123"); + } + + #[test] + fn callback_id_is_appended_before_redirect_uri_query() { + let redirect_uri = append_callback_id_to_redirect_uri( + "https://callbacks.example.com/oauth/callback?provider=github", + "abc123", + ) + .expect("redirect URI should parse"); + + assert_eq!( + redirect_uri, + "https://callbacks.example.com/oauth/callback/abc123?provider=github" + ); + } + #[test] fn append_query_param_adds_resource_to_absolute_url() { let url = append_query_param( From 14473c216ff7bae23951860840c7d68105507657 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Wed, 13 May 2026 14:10:28 -0700 Subject: [PATCH 13/47] Enable plugin hooks by default (#22549) # Why Plugin-bundled hooks are already wired through the plugin manager, session setup, and app-server hook listing paths. Keeping `plugin_hooks` disabled by default means users still need an explicit feature opt-in before that existing behavior participates in normal plugin loading. # What - mark `plugin_hooks` as stable and enable it by default - add feature-registry test coverage for the new default/stage pairing Validation: - `cargo test -p codex-features` - `just fmt` --- codex-rs/features/src/lib.rs | 4 ++-- codex-rs/features/src/tests.rs | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index c0a7a8a54d..d8a0a598e7 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -975,8 +975,8 @@ pub const FEATURES: &[FeatureSpec] = &[ FeatureSpec { id: Feature::PluginHooks, key: "plugin_hooks", - stage: Stage::UnderDevelopment, - default_enabled: false, + stage: Stage::Stable, + default_enabled: true, }, FeatureSpec { id: Feature::InAppBrowser, diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 04e236f5ee..2b10f83362 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -183,6 +183,12 @@ fn tool_search_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSearch.default_enabled(), true); } +#[test] +fn plugin_hooks_are_stable_and_enabled_by_default() { + assert_eq!(Feature::PluginHooks.stage(), Stage::Stable); + assert_eq!(Feature::PluginHooks.default_enabled(), true); +} + #[test] fn browser_controls_are_stable_and_enabled_by_default() { assert_eq!(Feature::InAppBrowser.stage(), Stage::Stable); From 16592f593dbdc72cbf717489947483e7baa7d1cf Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 13 May 2026 14:11:10 -0700 Subject: [PATCH 14/47] Use plugin/list to get list of plugins for mentions (#22375) This switches TUI plugin mentions to use app-server `plugin/list` for plugin inventory and metadata instead of `PluginManager`, while keeping the same mention-eligibility filters as before. Same filters as before: - Only plugins in the current config / cwd scope. - Only installed and enabled plugins. - Only plugins that actually expose a capability, meaning at least one skill, MCP server, or app connector. - Uses `plugin/list` for the mention names/descriptions --- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui/src/app/background_requests.rs | 54 ++++--- codex-rs/tui/src/app/event_dispatch.rs | 2 +- codex-rs/tui/src/app/plugin_mentions.rs | 147 ++++++++++++++++++++ 4 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 codex-rs/tui/src/app/plugin_mentions.rs diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 62b29f5b3e..bab42be2f1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -127,7 +127,6 @@ use codex_app_server_protocol::TurnStatus; use codex_config::ConfigLayerStackOrdering; use codex_config::types::ApprovalsReviewer; use codex_config::types::ModelAvailabilityNuxConfig; -use codex_core_plugins::PluginsManager; use codex_exec_server::EnvironmentManager; use codex_features::Feature; use codex_model_provider::create_model_provider; @@ -196,6 +195,7 @@ mod loaded_threads; mod pending_interactive_replay; mod pets; mod platform_actions; +mod plugin_mentions; mod replay_filter; mod resize_reflow; mod session_lifecycle; diff --git a/codex-rs/tui/src/app/background_requests.rs b/codex-rs/tui/src/app/background_requests.rs index 57c82cb7f7..c323a2d5bf 100644 --- a/codex-rs/tui/src/app/background_requests.rs +++ b/codex-rs/tui/src/app/background_requests.rs @@ -4,6 +4,7 @@ //! limits, add-credit nudges, and feedback uploads. Results are routed back through `AppEvent` so //! the main event loop remains single-threaded. +use super::plugin_mentions::fetch_plugin_mentions; use super::*; use codex_app_server_protocol::MarketplaceAddParams; use codex_app_server_protocol::MarketplaceAddResponse; @@ -358,8 +359,9 @@ impl App { }); } - pub(super) fn refresh_plugin_mentions(&mut self) { + pub(super) fn refresh_plugin_mentions(&mut self, app_server: &AppServerSession) { let config = self.config.clone(); + let request_handle = app_server.request_handle(); let app_event_tx = self.app_event_tx.clone(); if !config.features.enabled(Feature::Plugins) { app_event_tx.send(AppEvent::PluginMentionsLoaded { plugins: None }); @@ -367,15 +369,16 @@ impl App { } tokio::spawn(async move { - let plugins_input = config.plugins_config_input(); - let plugins = PluginsManager::new(config.codex_home.to_path_buf()) - .plugins_for_config(&plugins_input) - .await - .capability_summaries() - .to_vec(); - app_event_tx.send(AppEvent::PluginMentionsLoaded { - plugins: Some(plugins), - }); + match fetch_plugin_mentions(request_handle, config).await { + Ok(plugins) => { + app_event_tx.send(AppEvent::PluginMentionsLoaded { + plugins: Some(plugins), + }); + } + Err(err) => { + tracing::warn!(error = %err, "plugin/list failed while refreshing plugin mention candidates"); + } + } }); } @@ -635,18 +638,9 @@ pub(super) async fn fetch_plugins_list( request_handle: AppServerRequestHandle, cwd: PathBuf, ) -> Result { - let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?; - let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4())); - let mut response = request_handle - .request_typed(ClientRequest::PluginList { - request_id, - params: PluginListParams { - cwds: Some(vec![cwd]), - marketplace_kinds: None, - }, - }) + let mut response = request_plugin_list(request_handle, cwd) .await - .wrap_err("plugin/list failed in TUI")?; + .wrap_err("plugin/list failed while loading the plugins menu")?; hide_cli_only_plugin_marketplaces(&mut response); Ok(response) } @@ -659,6 +653,24 @@ pub(super) fn hide_cli_only_plugin_marketplaces(response: &mut PluginListRespons .retain(|marketplace| !CLI_HIDDEN_PLUGIN_MARKETPLACES.contains(&marketplace.name.as_str())); } +pub(super) async fn request_plugin_list( + request_handle: AppServerRequestHandle, + cwd: PathBuf, +) -> Result { + let cwd = AbsolutePathBuf::try_from(cwd).wrap_err("plugin list cwd must be absolute")?; + let request_id = RequestId::String(format!("plugin-list-{}", Uuid::new_v4())); + request_handle + .request_typed(ClientRequest::PluginList { + request_id, + params: PluginListParams { + cwds: Some(vec![cwd]), + marketplace_kinds: None, + }, + }) + .await + .wrap_err("plugin/list failed in TUI") +} + pub(super) async fn fetch_plugin_detail( request_handle: AppServerRequestHandle, params: PluginReadParams, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 1d2df3db16..16d7daee72 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1253,7 +1253,7 @@ impl App { } } AppEvent::RefreshPluginMentions => { - self.refresh_plugin_mentions(); + self.refresh_plugin_mentions(app_server); } AppEvent::PluginMentionsLoaded { mut plugins } => { if !self.config.features.enabled(Feature::Plugins) { diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs new file mode 100644 index 0000000000..ef300896bc --- /dev/null +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -0,0 +1,147 @@ +//! Plugin mention capability enrichment for the TUI. +//! +//! Mention inventory comes from app-server `plugin/list`, while mention eligibility still reuses +//! the older local bulk capability summaries. That keeps the feature app-server-shaped without +//! paying for a `plugin/read` per plugin. + +use super::background_requests::request_plugin_list; +use super::*; +use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSummary; +use codex_core_plugins::PluginsManager; +use codex_plugin::PluginCapabilitySummary; +use std::collections::HashMap; + +#[derive(Debug, Clone)] +struct PluginMentionEntry { + config_name: String, + display_name: String, + description: Option, +} + +impl PluginMentionEntry { + fn capability_summary( + self, + capabilities_by_config_name: &HashMap, + ) -> Option { + let capabilities = capabilities_by_config_name.get(&self.config_name)?; + Some(PluginCapabilitySummary { + config_name: self.config_name, + display_name: self.display_name, + description: self.description, + has_skills: capabilities.has_skills, + mcp_server_names: capabilities.mcp_server_names.clone(), + app_connector_ids: capabilities.app_connector_ids.clone(), + }) + } +} + +pub(super) async fn fetch_plugin_mentions( + request_handle: AppServerRequestHandle, + config: crate::legacy_core::config::Config, +) -> Result> { + let response = request_plugin_list(request_handle, config.cwd.to_path_buf()).await?; + let mention_entries = plugin_mention_entries_from_list_response(response); + let capabilities_by_config_name = load_plugin_mention_capabilities(&config).await; + + Ok(mention_entries + .into_iter() + .filter_map(|entry| entry.capability_summary(&capabilities_by_config_name)) + .collect()) +} + +async fn load_plugin_mention_capabilities( + config: &crate::legacy_core::config::Config, +) -> HashMap { + let plugins_input = config.plugins_config_input(); + PluginsManager::new(config.codex_home.to_path_buf()) + .plugins_for_config(&plugins_input) + .await + .capability_summaries() + .iter() + .cloned() + .map(|summary| (summary.config_name.clone(), summary)) + .collect() +} + +fn plugin_mention_entries_from_list_response( + response: PluginListResponse, +) -> Vec { + response + .marketplaces + .into_iter() + .flat_map(plugin_mention_entries_from_marketplace) + .collect() +} + +fn plugin_mention_entries_from_marketplace( + marketplace: PluginMarketplaceEntry, +) -> Vec { + let marketplace_name = marketplace.name; + marketplace + .plugins + .into_iter() + .filter_map(|plugin| plugin_mention_entry(&marketplace_name, plugin)) + .collect() +} + +fn plugin_mention_entry( + marketplace_name: &str, + plugin: PluginSummary, +) -> Option { + if !plugin_is_eligible_for_mentions(&plugin) { + return None; + } + + let config_name = plugin_mention_config_name(marketplace_name, &plugin)?; + Some(PluginMentionEntry { + config_name, + display_name: plugin_mention_display_name(&plugin), + description: plugin_mention_description(&plugin), + }) +} + +fn plugin_is_eligible_for_mentions(plugin: &PluginSummary) -> bool { + plugin.installed && plugin.enabled +} + +fn plugin_mention_config_name(marketplace_name: &str, plugin: &PluginSummary) -> Option { + codex_plugin::PluginId::new(plugin.name.clone(), marketplace_name.to_string()) + .map(|plugin_id| plugin_id.as_key()) + .map_err(|err| { + tracing::warn!( + plugin_name = plugin.name, + marketplace_name, + error = %err, + "skipping plugin mention with invalid identity" + ); + }) + .ok() +} + +fn plugin_mention_display_name(plugin: &PluginSummary) -> String { + plugin + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()) + .map(str::trim) + .filter(|display_name| !display_name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| plugin.name.clone()) +} + +fn plugin_mention_description(plugin: &PluginSummary) -> Option { + plugin + .interface + .as_ref() + .and_then(|interface| { + interface + .short_description + .as_deref() + .or(interface.long_description.as_deref()) + }) + .map(str::trim) + .filter(|description| !description.is_empty()) + .map(str::to_string) +} From 5d7e6a2503fc71f09cea71bfca9e193e0c3fd215 Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 13 May 2026 14:19:05 -0700 Subject: [PATCH 15/47] [codex] Fix TUI wrapping for external borrowed slices (#21235) Fixes #20587, reported by @noeljackson. This prevents the TUI wrapping code from panicking when `textwrap` returns a borrowed slice that does not point into the original source text. The fix follows the direction proposed by @misrtjakub in the issue comment: validate the borrowed slice pointer range first, and fall back to the existing owned-line mapper when the slice is external. - Guards borrowed wrapped slices before converting pointer offsets into byte ranges. - Reuses the existing owned-line range recovery path for external borrowed slices. - Adds coverage for rejecting borrowed slices outside the source text. End-user testing steps: - Start Codex in TUI mode under a PTY wrapper that can inject stdin after startup. - Inject `\x1b[200~test message\x1b[201~\r` after the TUI is ready. - Confirm Codex does not panic and the pasted text is handled normally. Local validation: - `cargo test -p codex-tui wrapping::tests::` - `cargo test -p codex-tui -- --skip status::tests::status_permissions_full_disk_managed_with_network_is_danger_full_access --skip status::tests::status_permissions_full_disk_managed_without_network_is_external_sandbox` --- codex-rs/tui/src/wrapping.rs | 50 +++++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/wrapping.rs b/codex-rs/tui/src/wrapping.rs index 61bd8147ab..bc66b18ad5 100644 --- a/codex-rs/tui/src/wrapping.rs +++ b/codex-rs/tui/src/wrapping.rs @@ -49,8 +49,16 @@ where for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() { match line { std::borrow::Cow::Borrowed(slice) => { - let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; - let end = start + slice.len(); + let range = borrowed_slice_range(text, slice).unwrap_or_else(|| { + let synthetic_prefix = if line_index == 0 { + opts.initial_indent + } else { + opts.subsequent_indent + }; + map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix) + }); + let start = range.start; + let end = range.end; let trailing_spaces = text[end..].chars().take_while(|c| *c == ' ').count(); lines.push(start..end + trailing_spaces + 1); cursor = end + trailing_spaces; @@ -84,10 +92,16 @@ where for (line_index, line) in textwrap::wrap(text, &opts).iter().enumerate() { match line { std::borrow::Cow::Borrowed(slice) => { - let start = unsafe { slice.as_ptr().offset_from(text.as_ptr()) as usize }; - let end = start + slice.len(); - lines.push(start..end); - cursor = end; + let range = borrowed_slice_range(text, slice).unwrap_or_else(|| { + let synthetic_prefix = if line_index == 0 { + opts.initial_indent + } else { + opts.subsequent_indent + }; + map_owned_wrapped_line_to_range(text, cursor, slice, synthetic_prefix) + }); + cursor = range.end; + lines.push(range); } std::borrow::Cow::Owned(slice) => { let synthetic_prefix = if line_index == 0 { @@ -104,6 +118,19 @@ where lines } +fn borrowed_slice_range(text: &str, slice: &str) -> Option> { + let text_start = text.as_ptr() as usize; + let text_end = text_start.checked_add(text.len())?; + let slice_start = slice.as_ptr() as usize; + let slice_end = slice_start.checked_add(slice.len())?; + + if slice_start < text_start || slice_end > text_end { + return None; + } + + Some((slice_start - text_start)..(slice_end - text_start)) +} + /// Maps an owned (materialized) wrapped line back to a byte range in `text`. /// /// `textwrap` returns `Cow::Owned` when it inserts a hyphenation penalty @@ -1485,6 +1512,17 @@ them."# assert_eq!(range, 0..5); } + #[test] + fn borrowed_slice_range_rejects_slices_outside_source_text() { + let text = "test message"; + let external = String::from("test"); + + assert_eq!(borrowed_slice_range(text, &external), None); + + let fallback = map_owned_wrapped_line_to_range(text, /*cursor*/ 0, &external, ""); + assert_eq!(fallback, 0..4); + } + #[test] fn map_owned_wrapped_line_to_range_indent_coincides_with_source() { // When the synthetic indent prefix starts with a character that also From 9798eb377a4bac2d76cf90e3b71025d377f7cfe4 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Wed, 13 May 2026 18:23:19 -0300 Subject: [PATCH 16/47] feat(cli): add codex doctor diagnostics (#22336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why Users and support need a single command that captures the local Codex runtime, configuration, auth, terminal, network, and state shape without asking the user to know which diagnostic depth to choose first. `codex doctor` now runs the useful checks by default and makes the detailed human output the default because the command is usually run when someone already needs context. The command also targets concrete support failure modes we have seen while iterating on the design: - update-target mismatches like #21956, where the installed package manager target can differ from the running executable - terminal and multiplexer issues that depend on `TERM`, tmux/zellij state, color handling, and TTY metadata - provider-specific HTTP/WebSocket connectivity, including ChatGPT WebSocket handshakes and API-key/provider endpoint reachability - local state/log SQLite integrity problems and large rollout directories - feedback reports that need an attached, redacted diagnostic snapshot without asking the user to run a second command ## What Changed - Adds `codex doctor` as a grouped CLI diagnostic report with default detailed output and `--summary` for the compact view. - Adds stable report sections for Environment, Configuration, Updates, Connectivity, and Background Server, plus a top Notes block that promotes anomalies such as available updates, large rollout directories, optional MCP issues, and mixed auth signals. - Adds runtime provenance, install consistency, bundled/system search readiness, terminal/multiplexer metadata, `config.toml` parse status, auth mode details, sandbox details, feature flag summaries, update cache/latest-version state, app-server daemon state, SQLite integrity checks, rollout statistics, and provider-aware network diagnostics. - Adds ChatGPT WebSocket diagnostics that report the negotiated HTTP upgrade as `HTTP 101 Switching Protocols` and include timeout, DNS, auth, and provider context in detailed output. - Makes reachability provider-aware: API-key OpenAI setups check the API endpoint, ChatGPT auth checks the ChatGPT path, and custom/AWS/local providers check configured HTTP endpoints when available. - Adds structured, redacted JSON output where `checks` is keyed by check id and `details` is a key/value object for support tooling. - Integrates doctor with feedback uploads by attaching a best-effort `codex-doctor-report.json` report and adding derived Sentry tags for overall status and failing/warning checks. - Updates the TUI feedback consent copy so users can see that the doctor report is included when logs/diagnostics are uploaded. - Updates the CLI bug issue template to ask reporters for `codex doctor --json` and render pasted reports as JSON. ## Example Output The examples below are sanitized from local smoke runs with `--no-color` so the structure is reviewable in plain text. ### `codex doctor` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build version 0.0.0 install method other commit unknown executable ~/code/codex.fcoury-doct…x-rs/target/debug/codex ✓ install consistent context other managed by npm: no · bun: no · package root — PATH entries (2) ~/.local/share/mise/installs/node/24/bin/codex ~/.local/share/mise/shims/codex ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color terminal Ghostty TERM_PROGRAM ghostty terminal version 1.3.2-main-+b0f827665 TERM xterm-256color multiplexer tmux 3.6a tmux extended-keys on tmux allow-passthrough on tmux set-clipboard on ✓ state databases healthy CODEX_HOME ~/.codex (dir) state DB ~/.codex/state_5.sqlite (file) · integrity ok log DB ~/.codex/logs_2.sqlite (file) · integrity ok active rollouts 1,526 files · 2.53 GB (avg 1.70 MB) archived rollouts 8 files · 3.84 MB (avg 491.11 KB) Configuration ✓ config loaded model gpt-5.5 · openai cwd ~/code/codex.fcoury-doctor/codex-rs config.toml ~/.codex/config.toml config.toml parse ok MCP servers 1 feature flags 36 enabled · 7 overridden (full list with --all) overrides code_mode, code_mode_only, memories, chronicle, goals, remote_control, prevent_idle_sleep ✓ auth auth is configured auth storage mode File auth file ~/.codex/auth.json auth env vars present OPENAI_API_KEY stored auth mode chatgpt stored API key false stored ChatGPT tokens true stored agent identity false ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. configured servers 1 disabled servers 0 streamable_http servers 1 optional reachability openaiDeveloperDocs: https://developers.openai.com/mcp (HEAD connect failed; GET connect failed) ✓ sandbox restricted fs + restricted network · approval OnRequest approval policy OnRequest filesystem sandbox restricted network sandbox restricted Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout model provider openai provider name OpenAI wire API responses supports websockets true connect timeout 15000 ms auth mode chatgpt endpoint wss://chatgpt.com/backend-api/ DNS 2 IPv4, 2 IPv6, first IPv6 handshake result HTTP 101 Switching Protocols ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. reachability mode API key auth openai API https://api.openai.com/v1 connect failed (required) Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report ``` ### `codex doctor --summary` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build ✓ install consistent ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color ✓ state databases healthy Configuration ✓ config loaded ✓ auth auth is configured ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. ✓ sandbox restricted fs + restricted network · approval OnRequest Updates ✓ updates update configuration is locally consistent Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed Run codex doctor without --summary for detailed diagnostics. --all expand truncated lists --json redacted report ``` ### `codex doctor --json` shape ```json { "schema_version": 1, "overall_status": "fail", "checks": { "runtime.provenance": { "id": "runtime.provenance", "category": "Environment", "status": "ok", "summary": "local debug build", "details": { "version": "0.0.0", "install method": "other", "commit": "unknown" } }, "sandbox.helpers": { "id": "sandbox.helpers", "category": "Configuration", "status": "ok", "summary": "restricted fs + restricted network · approval OnRequest", "details": { "approval policy": "OnRequest", "filesystem sandbox": "restricted", "network sandbox": "restricted" } } } } ``` ### `/feedback` new sentry attachment CleanShot 2026-05-13 at 15 36 14 ### New section in CLI issue template CleanShot 2026-05-13 at 15 47 24 ## How to Test 1. Run `cargo run --bin codex -- doctor --no-color`. 2. Confirm the detailed report is the default and includes promoted Notes, grouped sections, terminal details, state DB integrity, rollout stats, provider reachability, WebSocket diagnostics, and app-server status. 3. Run `cargo run --bin codex -- doctor --summary --no-color`. 4. Confirm the compact view keeps the same sections and summary counts but omits detailed key/value rows. 5. Run `cargo run --bin codex -- doctor --json`. 6. Confirm the output is redacted JSON, `checks` is an object keyed by check id, and each check's `details` is a key/value object. 7. Preview the CLI bug issue template and confirm the `Codex doctor report` field appears after the terminal field, asks for `codex doctor --json`, and renders pasted output as JSON. 8. Start a feedback flow that includes logs. 9. Confirm the upload consent copy lists `codex-doctor-report.json` alongside the log attachments. Targeted tests: - `cargo test -p codex-cli doctor` - `cargo test -p codex-app-server doctor_report_tags_summarize_status_counts` - `cargo test -p codex-feedback` - `cargo test -p codex-tui feedback_view` - `just argument-comment-lint` - `git diff --check` --- .github/ISSUE_TEMPLATE/3-cli.yml | 12 + codex-cli/bin/codex.js | 3 +- codex-rs/Cargo.lock | 6 + codex-rs/app-server/src/request_processors.rs | 1 + .../feedback_doctor_report.rs | 212 + .../request_processors/feedback_processor.rs | 16 +- codex-rs/cli/Cargo.toml | 8 + codex-rs/cli/src/doctor.rs | 3844 +++++++++++++++++ codex-rs/cli/src/doctor/background.rs | 150 + codex-rs/cli/src/doctor/output.rs | 1555 +++++++ codex-rs/cli/src/doctor/output/detail.rs | 648 +++ codex-rs/cli/src/doctor/progress.rs | 139 + codex-rs/cli/src/doctor/runtime.rs | 141 + codex-rs/cli/src/doctor/updates.rs | 227 + codex-rs/cli/src/main.rs | 22 +- codex-rs/codex-api/src/endpoint/mod.rs | 2 + .../src/endpoint/responses_websocket.rs | 92 +- codex-rs/codex-api/src/lib.rs | 2 + codex-rs/feedback/src/lib.rs | 53 +- codex-rs/state/src/lib.rs | 1 + codex-rs/state/src/runtime.rs | 47 + codex-rs/terminal-detection/src/lib.rs | 47 +- .../terminal-detection/src/terminal_tests.rs | 67 +- codex-rs/tui/src/bottom_pane/feedback_view.rs | 36 +- ...ck_upload_consent_lists_doctor_report.snap | 12 + ...ack_view__tests__feedback_view_render.snap | 4 +- ...s__feedback_good_result_consent_popup.snap | 5 +- ..._tests__feedback_upload_consent_popup.snap | 5 +- codex-rs/tui/src/pets/image_protocol.rs | 4 +- 29 files changed, 7339 insertions(+), 22 deletions(-) create mode 100644 codex-rs/app-server/src/request_processors/feedback_doctor_report.rs create mode 100644 codex-rs/cli/src/doctor.rs create mode 100644 codex-rs/cli/src/doctor/background.rs create mode 100644 codex-rs/cli/src/doctor/output.rs create mode 100644 codex-rs/cli/src/doctor/output/detail.rs create mode 100644 codex-rs/cli/src/doctor/progress.rs create mode 100644 codex-rs/cli/src/doctor/runtime.rs create mode 100644 codex-rs/cli/src/doctor/updates.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap diff --git a/.github/ISSUE_TEMPLATE/3-cli.yml b/.github/ISSUE_TEMPLATE/3-cli.yml index 37229c7f2b..cfd368c0ba 100644 --- a/.github/ISSUE_TEMPLATE/3-cli.yml +++ b/.github/ISSUE_TEMPLATE/3-cli.yml @@ -11,6 +11,8 @@ body: Make sure you are running the [latest](https://npmjs.com/package/@openai/codex) version of Codex CLI. The bug you are experiencing may already have been fixed. + If your version supports it, please run `codex doctor --json` and paste the output in the "Codex doctor report" field below. This helps us diagnose install, config, auth, terminal, MCP, network, and local state issues. + - type: input id: version attributes: @@ -43,6 +45,16 @@ body: description: | Also note any multiplexer in use (screen / tmux / zellij). E.g., VS Code, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell) + - type: textarea + id: doctor + attributes: + label: Codex doctor report + description: | + If available, run `codex doctor --json` and paste the full output here. + + The report is designed to redact secrets, but please review it before submitting. + If your Codex version does not support `doctor`, write `not available`. + render: json - type: textarea id: actual attributes: diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 67ab3e2d95..475239549a 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -2,7 +2,7 @@ // Unified entry point for the Codex CLI. import { spawn } from "node:child_process"; -import { existsSync } from "fs"; +import { existsSync, realpathSync } from "fs"; import { createRequire } from "node:module"; import path from "path"; import { fileURLToPath } from "url"; @@ -171,6 +171,7 @@ const packageManagerEnvVar = ? "CODEX_MANAGED_BY_BUN" : "CODEX_MANAGED_BY_NPM"; env[packageManagerEnvVar] = "1"; +env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, "..")); const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 564c1a9c78..ec43bd6f38 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2220,6 +2220,7 @@ dependencies = [ "assert_matches", "clap", "clap_complete", + "codex-api", "codex-app-server", "codex-app-server-daemon", "codex-app-server-protocol", @@ -2234,10 +2235,12 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", + "codex-install-context", "codex-login", "codex-mcp", "codex-mcp-server", "codex-memories-write", + "codex-model-provider", "codex-models-manager", "codex-protocol", "codex-responses-api-proxy", @@ -2253,11 +2256,14 @@ dependencies = [ "codex-utils-cli", "codex-utils-path", "codex-windows-sandbox", + "crossterm", + "http 1.4.0", "libc", "owo-colors", "predicates", "pretty_assertions", "regex-lite", + "serde", "serde_json", "sqlx", "supports-color 3.0.2", diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cf68f638ba..d2b681d063 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -439,6 +439,7 @@ mod command_exec_processor; mod config_processor; mod environment_processor; mod external_agent_config_processor; +mod feedback_doctor_report; mod feedback_processor; mod fs_processor; mod git_processor; diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs new file mode 100644 index 0000000000..3bd9c9fd9b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs @@ -0,0 +1,212 @@ +//! Builds a redacted doctor report attachment for feedback uploads. +//! +//! Feedback upload should never depend on doctor succeeding. This module runs +//! the configured Codex executable as a subprocess, accepts only valid JSON from +//! `codex doctor --json`, derives a small set of Sentry tags, and otherwise +//! skips the attachment with a warning. Keeping the report generation out of the +//! app-server process avoids sharing doctor internals across crates while still +//! attaching exactly the same JSON a user could copy from the CLI. + +use std::collections::BTreeMap; +use std::time::Duration; + +use codex_core::config::Config; +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; +use codex_feedback::FeedbackAttachment; +use serde_json::Value; +use tokio::process::Command; +use tokio::time::timeout; +use tracing::warn; + +const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(25); +const MAX_DOCTOR_TAG_VALUE_LEN: usize = 256; + +/// Redacted doctor report data that can be merged into a feedback upload. +pub(crate) struct DoctorFeedbackReport { + /// JSON support report to upload as `codex-doctor-report.json`. + pub(crate) attachment: FeedbackAttachment, + /// Low-cardinality Sentry tags derived from the report status and check ids. + pub(crate) tags: BTreeMap, +} + +/// Runs `codex doctor --json` and returns a best-effort feedback attachment. +/// +/// Failure to spawn Codex, finish before the timeout, or parse JSON means the +/// feedback upload proceeds without the doctor report. Callers should merge the +/// returned tags without overriding explicit client-provided tags. +pub(crate) async fn doctor_feedback_report(config: &Config) -> Option { + let executable = config + .codex_self_exe + .clone() + .or_else(|| std::env::current_exe().ok())?; + + let mut command = Command::new(&executable); + command.arg("doctor").arg("--json"); + command.kill_on_drop(/*kill_on_drop*/ true); + let output = match timeout(DOCTOR_FEEDBACK_REPORT_TIMEOUT, command.output()).await { + Ok(Ok(output)) => output, + Ok(Err(err)) => { + warn!( + executable = %executable.display(), + error = %err, + "failed to run doctor report for feedback; skipping attachment" + ); + return None; + } + Err(_) => { + warn!( + executable = %executable.display(), + "timed out running doctor report for feedback; skipping attachment" + ); + return None; + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(json_start) = stdout.find('{') else { + warn!( + executable = %executable.display(), + status = %output.status, + stderr = %String::from_utf8_lossy(&output.stderr), + "doctor report for feedback did not produce JSON; skipping attachment" + ); + return None; + }; + let json = stdout[json_start..].trim(); + let report: Value = match serde_json::from_str(json) { + Ok(report) => report, + Err(err) => { + warn!( + executable = %executable.display(), + status = %output.status, + error = %err, + "doctor report for feedback was not valid JSON; skipping attachment" + ); + return None; + } + }; + + let pretty = serde_json::to_vec_pretty(&report).unwrap_or_else(|_| json.as_bytes().to_vec()); + Some(DoctorFeedbackReport { + tags: doctor_report_tags(&report), + attachment: FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: pretty, + }, + }) +} + +fn doctor_report_tags(report: &Value) -> BTreeMap { + let mut tags = BTreeMap::new(); + if let Some(overall_status) = report.get("overallStatus").and_then(Value::as_str) { + tags.insert( + "doctor_overall_status".to_string(), + truncate_tag_value(overall_status), + ); + } + + let mut ok_count = 0usize; + let mut warning_count = 0usize; + let mut fail_count = 0usize; + let mut failed_checks = Vec::new(); + let mut warning_checks = Vec::new(); + if let Some(checks) = report.get("checks") { + for check in check_values(checks) { + let status = check.get("status").and_then(Value::as_str); + let id = check.get("id").and_then(Value::as_str).unwrap_or("unknown"); + match status { + Some("ok") => ok_count += 1, + Some("warning") => { + warning_count += 1; + warning_checks.push(id.to_string()); + } + Some("fail") => { + fail_count += 1; + failed_checks.push(id.to_string()); + } + _ => {} + } + } + } + tags.insert("doctor_ok_count".to_string(), ok_count.to_string()); + tags.insert( + "doctor_warning_count".to_string(), + warning_count.to_string(), + ); + tags.insert("doctor_fail_count".to_string(), fail_count.to_string()); + if !failed_checks.is_empty() { + tags.insert( + "doctor_failed_checks".to_string(), + truncate_tag_value(&failed_checks.join(",")), + ); + } + if !warning_checks.is_empty() { + tags.insert( + "doctor_warning_checks".to_string(), + truncate_tag_value(&warning_checks.join(",")), + ); + } + + tags +} + +/// Iterates checks from both the current keyed JSON shape and older array reports. +fn check_values(checks: &Value) -> Box + '_> { + match checks { + Value::Array(values) => Box::new(values.iter()), + Value::Object(values) => Box::new(values.values()), + _ => Box::new(std::iter::empty()), + } +} + +fn truncate_tag_value(value: &str) -> String { + if value.chars().count() <= MAX_DOCTOR_TAG_VALUE_LEN { + return value.to_string(); + } + let prefix = value + .chars() + .take(MAX_DOCTOR_TAG_VALUE_LEN.saturating_sub(3)) + .collect::(); + format!("{prefix}...") +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn doctor_report_tags_summarize_status_counts() { + let report = json!({ + "overallStatus": "fail", + "checks": { + "runtime.provenance": {"id": "runtime.provenance", "status": "ok"}, + "websocket.reachability": { + "id": "websocket.reachability", + "status": "warning" + }, + "auth.credentials": {"id": "auth.credentials", "status": "fail"} + } + }); + + let tags = doctor_report_tags(&report); + + let expected = BTreeMap::from([ + ("doctor_fail_count".to_string(), "1".to_string()), + ( + "doctor_failed_checks".to_string(), + "auth.credentials".to_string(), + ), + ("doctor_ok_count".to_string(), "1".to_string()), + ("doctor_overall_status".to_string(), "fail".to_string()), + ( + "doctor_warning_checks".to_string(), + "websocket.reachability".to_string(), + ), + ("doctor_warning_count".to_string(), "1".to_string()), + ]); + assert_eq!(tags, expected); + } +} diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 5b9039b57d..18f519d3c0 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -56,6 +56,7 @@ impl FeedbackRequestProcessor { extra_log_files, tags, } = params; + let mut upload_tags = tags.unwrap_or_default(); let conversation_id = match thread_id.as_deref() { Some(thread_id) => match ThreadId::from_string(thread_id) { @@ -197,14 +198,27 @@ impl FeedbackRequestProcessor { } } + let mut extra_attachments = Vec::new(); + if include_logs + && let Some(doctor_report) = + super::feedback_doctor_report::doctor_feedback_report(&self.config).await + { + extra_attachments.push(doctor_report.attachment); + for (key, value) in doctor_report.tags { + upload_tags.entry(key).or_insert(value); + } + } + let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { + let tags = (!upload_tags.is_empty()).then_some(&upload_tags); snapshot.upload_feedback(FeedbackUploadOptions { classification: &classification, reason: reason.as_deref(), - tags: tags.as_ref(), + tags, include_logs, + extra_attachments: &extra_attachments, extra_attachment_paths: &attachment_paths, session_source: Some(session_source), logs_override: sqlite_feedback_logs, diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 120d2e497a..0831163934 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -26,6 +26,7 @@ codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-api = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } @@ -36,10 +37,12 @@ codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } +codex-install-context = { workspace = true } codex-login = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } +codex-model-provider = { workspace = true } codex-models-manager = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } @@ -52,18 +55,23 @@ codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } +crossterm = { workspace = true } +http = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", + "net", "process", "rt-multi-thread", "signal", + "time", ] } toml = { workspace = true } tracing = { workspace = true } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs new file mode 100644 index 0000000000..a3742baa72 --- /dev/null +++ b/codex-rs/cli/src/doctor.rs @@ -0,0 +1,3844 @@ +//! Implements the `codex doctor` diagnostic report. +//! +//! Doctor is intentionally read-mostly: checks inspect the current installation, +//! configuration, authentication, terminal, state paths, and bounded reachability +//! probes without attempting repair or starting long-lived services. Each check +//! returns a redacted, serializable row so the same data can back the human +//! summary and `--json` support report. +//! +//! A failing check should describe the problem and remediation, but it should not +//! mutate user state. That keeps the command safe to run before filing a support +//! issue or while diagnosing a broken local installation. + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; +use std::env; +use std::ffi::OsStr; +use std::future::Future; +use std::io::IsTerminal; +use std::io::Read; +use std::net::IpAddr; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use clap::Parser; +use codex_api::ApiError; +use codex_api::ResponsesWebsocketClient; +use codex_api::is_azure_responses_provider; +use codex_arg0::Arg0DispatchPaths; +use codex_config::types::McpServerConfig; +use codex_config::types::McpServerTransportConfig; +use codex_core::config::Config; +use codex_core::config::ConfigBuilder; +use codex_core::config::ConfigOverrides; +use codex_core::config::find_codex_home; +use codex_features::FEATURES; +use codex_install_context::InstallContext; +use codex_install_context::StandalonePlatform; +use codex_login::AuthDotJson; +use codex_login::AuthManager; +use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR; +use codex_login::CODEX_API_KEY_ENV_VAR; +use codex_login::CodexAuth; +use codex_login::OPENAI_API_KEY_ENV_VAR; +use codex_login::default_client::build_reqwest_client; +use codex_login::default_client::default_headers; +use codex_login::load_auth_dot_json; +use codex_model_provider::create_model_provider; +use codex_protocol::protocol::AskForApproval; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; +use codex_tui::Cli as TuiCli; +use codex_utils_cli::CliConfigOverrides; +use http::HeaderMap; +use http::HeaderValue; +use serde::Serialize; +use supports_color::Stream; + +mod background; +mod output; +mod progress; +mod runtime; +mod updates; + +use background::background_server_check; +use output::HumanOutputOptions; +use output::redact_detail; +use output::render_human_report; +use progress::DoctorProgress; +use progress::doctor_progress; +use runtime::runtime_check; +use runtime::search_check; +use updates::updates_check; + +const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; +const WEBSOCKET_IMMEDIATE_CLOSE_GRACE: Duration = Duration::from_millis(250); +const SLOW_CHECK_PROGRESS_THRESHOLD: Duration = Duration::from_secs(2); +const SLOW_CHECK_PROGRESS_INTERVAL: Duration = Duration::from_secs(1); +const PROXY_ENV_VARS: &[&str] = &[ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", +]; +const COLOR_ENV_VARS: &[&str] = &[ + "COLORTERM", + "NO_COLOR", + "CLICOLOR", + "CLICOLOR_FORCE", + "FORCE_COLOR", + "COLORFGBG", +]; +const TERMINAL_DIMENSION_ENV_VARS: &[&str] = &["COLUMNS", "LINES"]; +const TERMINFO_ENV_VARS: &[&str] = &["TERMINFO", "TERMINFO_DIRS"]; +const LOCALE_ENV_VARS: &[&str] = &["LC_ALL", "LC_CTYPE", "LANG"]; +const REMOTE_TERMINAL_ENV_VARS: &[&str] = &[ + "SSH_TTY", + "SSH_CONNECTION", + "SSH_CLIENT", + "MOSH_IP", + "WSL_DISTRO_NAME", + "WSL_INTEROP", + "VSCODE_INJECTION", + "VSCODE_IPC_HOOK_CLI", + "WAYLAND_DISPLAY", + "DISPLAY", + "WT_SESSION", +]; +const TMUX_OPTION_NAMES: &[&str] = &[ + "extended-keys", + "xterm-keys", + "allow-passthrough", + "set-clipboard", + "focus-events", +]; +const NARROW_TERMINAL_COLUMNS: u16 = 80; +const NARROW_TERMINAL_ROWS: u16 = 24; + +/// Options for building a local Codex diagnostic report. +/// +/// The command always runs the full bounded diagnostic set. Human output includes +/// detailed diagnostics by default; --summary keeps the terminal output compact. +#[derive(Debug, Parser)] +pub struct DoctorCommand { + /// Emit a redacted machine-readable report. + #[arg(long, default_value_t = false)] + json: bool, + + /// Only show grouped check rows and the final count summary. + #[arg(long, default_value_t = false)] + summary: bool, + + /// Expand long lists in detailed human output. + #[arg(long, default_value_t = false)] + all: bool, + + /// Disable ANSI color in human output. + #[arg(long, default_value_t = false)] + no_color: bool, + + /// Use ASCII status labels and separators in human output. + #[arg(long, default_value_t = false)] + ascii: bool, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)] +#[serde(rename_all = "snake_case")] +enum CheckStatus { + Ok, + Warning, + Fail, +} + +/// Machine-readable doctor output shared by human and JSON renderers. +/// +/// The schema is intentionally flat: each check carries its own category, +/// status, details, remediation, and duration so support tooling can filter or +/// redact individual rows without understanding the renderer's section layout. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorReport { + schema_version: u32, + generated_at: String, + overall_status: CheckStatus, + codex_version: String, + checks: Vec, +} + +/// One diagnostic result in the doctor report. +/// +/// Summaries are safe for compact human output. Details may include local paths +/// or command output and are redacted before rendering or JSON serialization. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorCheck { + id: String, + category: String, + status: CheckStatus, + summary: String, + details: Vec, + issues: Vec, + remediation: Option, + duration_ms: u64, +} + +/// Structured cause/remedy metadata for a non-ok doctor check. +/// +/// Human output uses issues to make warnings and failures self-explanatory: +/// the row headline says what is wrong, matching detail rows show measured vs. +/// expected values, and remedies are printed as explicit next actions. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct DoctorIssue { + severity: CheckStatus, + cause: String, + measured: Option, + expected: Option, + remedy: Option, + fields: Vec, +} + +impl DoctorIssue { + fn new(severity: CheckStatus, cause: impl Into) -> Self { + Self { + severity, + cause: cause.into(), + measured: None, + expected: None, + remedy: None, + fields: Vec::new(), + } + } + + fn measured(mut self, measured: impl Into) -> Self { + self.measured = Some(measured.into()); + self + } + + fn expected(mut self, expected: impl Into) -> Self { + self.expected = Some(expected.into()); + self + } + + fn remedy(mut self, remedy: impl Into) -> Self { + self.remedy = Some(remedy.into()); + self + } + + fn field(mut self, field: impl Into) -> Self { + self.fields.push(field.into()); + self + } +} + +impl DoctorCheck { + fn new( + id: impl Into, + category: impl Into, + status: CheckStatus, + summary: impl Into, + ) -> Self { + Self { + id: id.into(), + category: category.into(), + status, + summary: summary.into(), + details: Vec::new(), + issues: Vec::new(), + remediation: None, + duration_ms: 0, + } + } + + fn detail(mut self, detail: impl Into) -> Self { + self.details.push(detail.into()); + self + } + + fn details(mut self, details: Vec) -> Self { + self.details.extend(details); + self + } + + fn remediation(mut self, remediation: impl Into) -> Self { + self.remediation = Some(remediation.into()); + self + } + + fn issue(mut self, issue: DoctorIssue) -> Self { + self.issues.push(issue); + self + } +} + +/// Builds, renders, and exits according to the current doctor report. +/// +/// This is the CLI entry point for codex doctor. It does not repair issues; +/// failures are represented in the report and cause a non-zero process exit so +/// scripts can distinguish a clean environment from one that needs attention. +pub async fn run_doctor( + command: DoctorCommand, + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> anyhow::Result<()> { + let report = build_report(&command, root_config_overrides, interactive, arg0_paths).await; + + if command.json { + println!( + "{}", + serde_json::to_string_pretty(&redacted_json_report(&report))? + ); + } else { + print!( + "{}", + render_human_report(&report, human_output_options(&command)) + ); + } + + if report.overall_status == CheckStatus::Fail { + std::process::exit(1); + } + + Ok(()) +} + +async fn build_report( + command: &DoctorCommand, + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> DoctorReport { + let progress = doctor_progress(command.json); + let mut checks = Vec::new(); + checks.push(run_sync_check("installation", progress.clone(), || { + installation_check(!command.summary) + })); + checks.push(run_sync_check("runtime", progress.clone(), runtime_check)); + checks.push(run_sync_check("search", progress.clone(), search_check)); + + progress.begin("config"); + let config_result = load_config(root_config_overrides, interactive, arg0_paths).await; + match &config_result { + Ok(config) => { + let auth_manager = + AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await; + let reachability_plan = provider_reachability_plan(config); + let ( + config_check, + auth_check, + updates_check, + network_check, + websocket_check, + mcp_check, + sandbox_check, + terminal_check, + state_check, + background_server_check, + reachability_check, + ) = tokio::join!( + async { run_sync_check("config", progress.clone(), || config_check(config)) }, + async { run_sync_check("auth", progress.clone(), || auth_check(config)) }, + async { run_sync_check("updates", progress.clone(), || updates_check(config)) }, + async { run_sync_check("network", progress.clone(), network_check) }, + run_async_check( + "websocket", + progress.clone(), + websocket_reachability_check(config, Some(auth_manager)), + ), + run_async_check("MCP", progress.clone(), mcp_check(config)), + async { + run_sync_check("sandbox", progress.clone(), || { + sandbox_check(config, arg0_paths) + }) + }, + async { + run_sync_check("terminal", progress.clone(), || { + terminal_check(command.no_color) + }) + }, + run_async_check("state", progress.clone(), state_check(config)), + async { + run_sync_check("app-server", progress.clone(), || { + background_server_check(config) + }) + }, + run_async_check( + "provider reachability", + progress.clone(), + provider_reachability_check(reachability_plan), + ), + ); + checks.extend([ + config_check, + auth_check, + updates_check, + network_check, + websocket_check, + mcp_check, + sandbox_check, + terminal_check, + state_check, + background_server_check, + reachability_check, + ]); + } + Err(err) => { + let reachability_plan = default_reachability_plan(); + let (config_check, network_check, terminal_check, state_check, reachability_check) = tokio::join!( + async { + run_sync_check("config", progress.clone(), || { + DoctorCheck::new( + "config.load", + "config", + CheckStatus::Fail, + "config could not be loaded", + ) + .detail(err.to_string()) + .remediation("Fix the reported config error, then rerun codex doctor.") + }) + }, + async { run_sync_check("network", progress.clone(), network_check) }, + async { + run_sync_check("terminal", progress.clone(), || { + terminal_check(command.no_color) + }) + }, + async { run_sync_check("state", progress.clone(), fallback_state_check) }, + run_async_check( + "provider reachability", + progress.clone(), + provider_reachability_check(reachability_plan), + ), + ); + checks.extend([ + config_check, + network_check, + terminal_check, + state_check, + reachability_check, + ]); + } + } + + progress.settle(); + + let overall_status = overall_status(&checks); + DoctorReport { + schema_version: 1, + generated_at: generated_at(), + overall_status, + codex_version: env!("CARGO_PKG_VERSION").to_string(), + checks, + } +} + +async fn load_config( + root_config_overrides: CliConfigOverrides, + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> anyhow::Result { + let mut cli_kv_overrides = root_config_overrides + .parse_overrides() + .map_err(anyhow::Error::msg)?; + if interactive.web_search { + cli_kv_overrides.push(( + "web_search".to_string(), + toml::Value::String("live".to_string()), + )); + } + + let overrides = ConfigOverrides { + ephemeral: Some(true), + ..config_overrides_from_interactive(interactive, arg0_paths) + }; + + ConfigBuilder::default() + .cli_overrides(cli_kv_overrides) + .harness_overrides(overrides) + .build() + .await + .context("failed to load Codex config") +} + +fn config_overrides_from_interactive( + interactive: &TuiCli, + arg0_paths: &Arg0DispatchPaths, +) -> ConfigOverrides { + let approval_policy = if interactive.dangerously_bypass_approvals_and_sandbox { + Some(AskForApproval::Never) + } else { + interactive.approval_policy.map(Into::into) + }; + let sandbox_mode = if interactive.dangerously_bypass_approvals_and_sandbox { + Some(codex_protocol::config_types::SandboxMode::DangerFullAccess) + } else { + interactive.sandbox_mode.map(Into::into) + }; + ConfigOverrides { + model: interactive.model.clone(), + config_profile: interactive.config_profile.clone(), + approval_policy, + sandbox_mode, + cwd: interactive.cwd.clone(), + model_provider: interactive + .oss + .then(|| interactive.oss_provider.clone()) + .flatten(), + codex_self_exe: arg0_paths.codex_self_exe.clone(), + codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(), + show_raw_agent_reasoning: interactive.oss.then_some(true), + additional_writable_roots: interactive.add_dir.clone(), + ..Default::default() + } +} + +/// JSON support report emitted by `codex doctor --json`. +/// +/// The report is keyed by check id so support tooling can fetch paths like +/// `checks["terminal.metadata"]` without scanning arrays. Human rendering can +/// reorder or group rows independently, but this JSON shape should stay stable +/// across cosmetic output changes. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorReport { + schema_version: u32, + generated_at: String, + overall_status: CheckStatus, + codex_version: String, + checks: BTreeMap, +} + +/// One redacted check in the JSON support report. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorCheck { + id: String, + category: String, + status: CheckStatus, + summary: String, + details: BTreeMap, + #[serde(skip_serializing_if = "Vec::is_empty")] + issues: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + notes: Vec, + remediation: Option, + duration_ms: u64, +} + +/// One redacted issue in the JSON support report. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct JsonDoctorIssue { + severity: CheckStatus, + cause: String, + measured: Option, + expected: Option, + remedy: Option, + fields: Vec, +} + +/// JSON detail value that preserves repeated detail keys without inventing names. +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +enum JsonDetailValue { + One(String), + Many(Vec), +} + +impl JsonDetailValue { + fn push(&mut self, value: String) { + match self { + JsonDetailValue::One(previous) => { + *self = JsonDetailValue::Many(vec![std::mem::take(previous), value]); + } + JsonDetailValue::Many(values) => values.push(value), + } + } +} + +fn redacted_json_report(report: &DoctorReport) -> JsonDoctorReport { + let checks = report + .checks + .iter() + .map(|check| { + let json_check = redacted_json_check(check); + (check.id.clone(), json_check) + }) + .collect(); + JsonDoctorReport { + schema_version: report.schema_version, + generated_at: report.generated_at.clone(), + overall_status: report.overall_status, + codex_version: report.codex_version.clone(), + checks, + } +} + +fn redacted_json_check(check: &DoctorCheck) -> JsonDoctorCheck { + let (details, notes) = structured_json_details(&check.details); + JsonDoctorCheck { + id: check.id.clone(), + category: check.category.clone(), + status: check.status, + summary: check.summary.clone(), + details, + issues: check.issues.iter().map(redacted_json_issue).collect(), + notes, + remediation: check.remediation.as_deref().map(redact_detail), + duration_ms: check.duration_ms, + } +} + +fn redacted_json_issue(issue: &DoctorIssue) -> JsonDoctorIssue { + JsonDoctorIssue { + severity: issue.severity, + cause: redact_detail(&issue.cause), + measured: issue.measured.as_deref().map(redact_detail), + expected: issue.expected.as_deref().map(redact_detail), + remedy: issue.remedy.as_deref().map(redact_detail), + fields: issue + .fields + .iter() + .map(|field| redact_detail(field)) + .collect(), + } +} + +/// Converts redacted `label: value` detail strings into JSON key/value fields. +/// +/// Detail strings that do not follow the doctor detail convention are preserved +/// as notes instead of being dropped. Repeated labels become arrays so callers +/// can still retrieve the common scalar case directly while keeping all values. +fn structured_json_details(details: &[String]) -> (BTreeMap, Vec) { + let mut structured: BTreeMap = BTreeMap::new(); + let mut notes = Vec::new(); + for detail in details { + let redacted = redact_detail(detail); + let Some((key, value)) = redacted.split_once(": ") else { + notes.push(redacted); + continue; + }; + let key = key.trim(); + if key.is_empty() { + notes.push(redacted); + continue; + } + let value = value.to_string(); + match structured.get_mut(key) { + Some(existing) => existing.push(value), + None => { + structured.insert(key.to_string(), JsonDetailValue::One(value)); + } + } + } + (structured, notes) +} + +fn run_sync_check( + label: &'static str, + progress: Arc, + f: impl FnOnce() -> DoctorCheck, +) -> DoctorCheck { + progress.begin(label); + let start = Instant::now(); + let mut check = f(); + check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + progress.finish(label, check.status); + check +} + +async fn run_async_check( + label: &'static str, + progress: Arc, + future: Fut, +) -> DoctorCheck +where + Fut: Future, +{ + progress.begin(label); + let start = Instant::now(); + tokio::pin!(future); + let mut progress_interval = tokio::time::interval(SLOW_CHECK_PROGRESS_INTERVAL); + loop { + tokio::select! { + mut check = &mut future => { + check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX); + progress.finish(label, check.status); + return check; + } + _ = progress_interval.tick() => { + let elapsed = start.elapsed(); + if elapsed >= SLOW_CHECK_PROGRESS_THRESHOLD { + progress.heartbeat(label, elapsed); + } + } + } + } +} + +fn overall_status(checks: &[DoctorCheck]) -> CheckStatus { + if checks.iter().any(|check| check.status == CheckStatus::Fail) { + CheckStatus::Fail + } else if checks + .iter() + .any(|check| check.status == CheckStatus::Warning) + { + CheckStatus::Warning + } else { + CheckStatus::Ok + } +} + +fn generated_at() -> String { + match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(duration) => { + let seconds = duration.as_secs(); + format!("{seconds}s since unix epoch") + } + Err(_) => "unknown".to_string(), + } +} + +fn installation_check(show_details: bool) -> DoctorCheck { + let mut details = Vec::new(); + let current_exe = env::current_exe().ok(); + push_path_detail(&mut details, "current executable", current_exe.as_deref()); + let inherited_managed_env = inherited_managed_env_for_cargo_binary(current_exe.as_deref()); + let install_context = doctor_install_context(current_exe.as_deref()); + details.push(format!( + "install context: {}", + describe_install_context(&install_context) + )); + if inherited_managed_env { + details.push( + "ignored inherited package-manager launch env for cargo-built binary".to_string(), + ); + } + details.push(format!( + "managed by npm: {}", + doctor_managed_by_npm(current_exe.as_deref()) + )); + details.push(format!( + "managed by bun: {}", + env::var_os("CODEX_MANAGED_BY_BUN").is_some() + )); + push_env_path_detail( + &mut details, + "managed package root", + "CODEX_MANAGED_PACKAGE_ROOT", + ); + + let path_entries = codex_path_entries(); + let mut status = CheckStatus::Ok; + let mut summary = "installation looks consistent".to_string(); + let mut remediation = None; + + if path_entries.len() > 1 { + details.push(format!("PATH codex entries: {}", path_entries.len())); + } + if show_details || path_entries.len() > 1 { + details.extend( + path_entries + .iter() + .enumerate() + .map(|(index, path)| format!("PATH codex #{}: {path}", index + 1)), + ); + } + + if doctor_managed_by_npm(current_exe.as_deref()) { + match npm_global_root_check() { + NpmRootCheck::Match { package_root } => { + details.push(format!("npm update target: {}", package_root.display())); + } + NpmRootCheck::Mismatch { + running_package_root, + npm_package_root, + } => { + status = CheckStatus::Fail; + summary = + "npm install -g @openai/codex would update a different install".to_string(); + remediation = Some(format!( + "Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).", + running_package_root.display(), + npm_package_root.display() + )); + details.push(format!( + "running package root: {}", + running_package_root.display() + )); + details.push(format!("npm package root: {}", npm_package_root.display())); + } + NpmRootCheck::MissingPackageRoot => { + status = status.max(CheckStatus::Warning); + summary = "npm-managed launch is missing package-root provenance".to_string(); + remediation = Some( + "Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT." + .to_string(), + ); + } + NpmRootCheck::NpmUnavailable(error) => { + status = status.max(CheckStatus::Warning); + summary = "npm-managed launch could not inspect npm global root".to_string(); + details.push(format!("npm root -g failed: {error}")); + } + } + } + + let mut check = DoctorCheck::new("installation", "install", status, summary).details(details); + if let Some(remediation) = remediation { + check = check.remediation(remediation); + } + check +} + +fn doctor_install_context(current_exe: Option<&Path>) -> InstallContext { + if inherited_managed_env_for_cargo_binary(current_exe) { + InstallContext::Other + } else { + InstallContext::current().clone() + } +} + +fn doctor_managed_by_npm(current_exe: Option<&Path>) -> bool { + env::var_os("CODEX_MANAGED_BY_NPM").is_some() + && !inherited_managed_env_for_cargo_binary(current_exe) +} + +fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool { + if env::var_os("CODEX_MANAGED_BY_NPM").is_none() + && env::var_os("CODEX_MANAGED_BY_BUN").is_none() + { + return false; + } + + let Some(current_exe) = current_exe else { + return false; + }; + let components = current_exe + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>(); + components + .windows(2) + .any(|window| window[0] == "target" && matches!(window[1].as_ref(), "debug" | "release")) +} + +fn describe_install_context(context: &InstallContext) -> String { + match context { + InstallContext::Standalone { + release_dir, + resources_dir, + platform, + } => { + let platform = match platform { + StandalonePlatform::Unix => "unix", + StandalonePlatform::Windows => "windows", + }; + let resources = resources_dir + .as_ref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "none".to_string()); + format!( + "standalone ({platform}, release {}, resources {resources})", + release_dir.display() + ) + } + InstallContext::Npm => "npm".to_string(), + InstallContext::Bun => "bun".to_string(), + InstallContext::Brew => "brew".to_string(), + InstallContext::Other => "other".to_string(), + } +} + +#[derive(Debug, PartialEq, Eq)] +enum NpmRootCheck { + Match { + package_root: PathBuf, + }, + Mismatch { + running_package_root: PathBuf, + npm_package_root: PathBuf, + }, + MissingPackageRoot, + NpmUnavailable(String), +} + +fn npm_global_root_check() -> NpmRootCheck { + let Some(running_package_root) = env::var_os("CODEX_MANAGED_PACKAGE_ROOT").map(PathBuf::from) + else { + return NpmRootCheck::MissingPackageRoot; + }; + + let output = match run_command("npm", ["root", "-g"]) { + Ok(output) => output, + Err(err) => return NpmRootCheck::NpmUnavailable(err), + }; + let Some(npm_root) = output.lines().map(str::trim).find(|line| !line.is_empty()) else { + return NpmRootCheck::NpmUnavailable("empty output from npm root -g".to_string()); + }; + + compare_npm_package_roots(&running_package_root, &PathBuf::from(npm_root)) +} + +fn compare_npm_package_roots(running_package_root: &Path, npm_root: &Path) -> NpmRootCheck { + let npm_package_root = npm_root.join("@openai").join("codex"); + let running = normalize_path_for_compare(running_package_root); + let target = normalize_path_for_compare(&npm_package_root); + if running == target { + NpmRootCheck::Match { + package_root: npm_package_root, + } + } else { + NpmRootCheck::Mismatch { + running_package_root: running_package_root.to_path_buf(), + npm_package_root, + } + } +} + +fn normalize_path_for_compare(path: &Path) -> String { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let raw = canonical.to_string_lossy().replace('\\', "/"); + if cfg!(windows) { + raw.to_ascii_lowercase() + } else { + raw + } +} + +fn display_list>(items: &[T]) -> String { + if items.is_empty() { + "none".to_string() + } else { + items + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + } +} + +fn codex_path_entries() -> Vec { + #[cfg(windows)] + let result = run_command("where", ["codex"]); + #[cfg(not(windows))] + let result = run_command("which", ["-a", "codex"]); + + result + .unwrap_or_default() + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_string) + .collect() +} + +fn run_command(program: &str, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new(program) + .args(args) + .output() + .map_err(|err| err.to_string())?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + return Err(format!("exited with status {}", output.status)); + } + return Err(stderr); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn config_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + details.push(format!("CODEX_HOME: {}", config.codex_home.display())); + details.push(format!("cwd: {}", config.cwd.display())); + details.push(format!( + "model: {}", + config.model.as_deref().unwrap_or("") + )); + details.push(format!("model provider: {}", config.model_provider_id)); + details.push(format!("log dir: {}", config.log_dir.display())); + details.push(format!("sqlite home: {}", config.sqlite_home.display())); + details.push(format!("mcp servers: {}", config.mcp_servers.get().len())); + feature_flag_details(config, &mut details); + config_toml_details(config, &mut details); + + let status = if config.startup_warnings.is_empty() { + CheckStatus::Ok + } else { + details.extend( + config + .startup_warnings + .iter() + .map(|warning| format!("startup warning: {warning}")), + ); + CheckStatus::Warning + }; + + DoctorCheck::new("config.load", "config", status, "config loaded").details(details) +} + +fn feature_flag_details(config: &Config, details: &mut Vec) { + let features = config.features.get(); + let enabled_features = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id)) + .map(|spec| spec.key) + .collect::>(); + let overrides = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id) != spec.default_enabled) + .map(|spec| format!("{}={}", spec.key, features.enabled(spec.id))) + .collect::>(); + details.push(format!("feature flags enabled: {}", enabled_features.len())); + details.push(format!( + "enabled feature flags: {}", + display_list(&enabled_features) + )); + details.push(format!( + "feature flag overrides: {}", + display_list(&overrides) + )); + for usage in features.legacy_feature_usages() { + details.push(format!( + "legacy feature flag: {} -> {}", + usage.alias, + usage.feature.key() + )); + } +} + +fn config_toml_details(config: &Config, details: &mut Vec) { + let config_path = config.codex_home.join(codex_config::CONFIG_TOML_FILE); + details.push(format!("config.toml: {}", config_path.display())); + match std::fs::read_to_string(&config_path) { + Ok(contents) => match toml::from_str::(&contents) { + Ok(_) => details.push("config.toml parse: ok".to_string()), + Err(err) => details.push(format!("config.toml parse: {err}")), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push("config.toml: missing".to_string()); + } + Err(err) => details.push(format!("config.toml read: {err}")), + } +} + +fn auth_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + let auth_path = config.codex_home.join("auth.json"); + details.push(format!( + "auth storage mode: {:?}", + config.cli_auth_credentials_store_mode + )); + details.push(format!("auth file: {}", auth_path.display())); + + let env_auth_vars = [ + OPENAI_API_KEY_ENV_VAR, + CODEX_API_KEY_ENV_VAR, + CODEX_ACCESS_TOKEN_ENV_VAR, + ] + .into_iter() + .filter(|name| env_var_present(name)) + .collect::>(); + if !env_auth_vars.is_empty() { + details.push(format!( + "auth env vars present: {}", + env_auth_vars.join(", ") + )); + } + if let Some(check) = provider_specific_auth_check( + config.model_provider.requires_openai_auth, + config.model_provider.env_key.as_deref(), + config.model_provider.env_key_instructions.as_deref(), + details.clone(), + env_var_present, + ) { + return check; + } + + match load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) { + Ok(Some(auth)) => { + details.push(format!("stored auth mode: {}", stored_auth_mode(&auth))); + details.push(format!("stored API key: {}", auth.openai_api_key.is_some())); + details.push(format!("stored ChatGPT tokens: {}", auth.tokens.is_some())); + details.push(format!( + "stored agent identity: {}", + auth.agent_identity.is_some() + )); + let auth_issues = stored_auth_issues(&auth, env_var_present); + details.extend( + auth_issues + .iter() + .map(|issue| format!("stored auth issue: {issue}")), + ); + let status = if !auth_issues.is_empty() && env_auth_vars.is_empty() { + CheckStatus::Fail + } else if !auth_issues.is_empty() || env_auth_vars.len() > 1 { + CheckStatus::Warning + } else { + CheckStatus::Ok + }; + let summary = match status { + CheckStatus::Ok => "auth is configured", + CheckStatus::Warning if !auth_issues.is_empty() => { + "auth is provided by environment, but stored credentials are incomplete" + } + CheckStatus::Warning => { + "auth is configured, but multiple auth env vars are present" + } + CheckStatus::Fail => "stored credentials are incomplete", + }; + let mut check = + DoctorCheck::new("auth.credentials", "auth", status, summary).details(details); + if status == CheckStatus::Fail { + check = + check.remediation("Run codex login again or provide a supported auth env var."); + } + check + } + Ok(None) if !env_auth_vars.is_empty() => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "auth is provided by environment", + ) + .details(details), + Ok(None) => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "no Codex credentials were found", + ) + .details(details) + .remediation("Run codex login or provide an API key through a supported auth env var."), + Err(err) => DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "stored credentials could not be read", + ) + .detail(err.to_string()) + .remediation("Fix auth storage access or run codex login again."), + } +} + +fn provider_specific_auth_check( + requires_openai_auth: bool, + provider_env_key: Option<&str>, + provider_env_key_instructions: Option<&str>, + mut details: Vec, + env_var_present: impl Fn(&str) -> bool, +) -> Option { + details.push(format!( + "model provider requires OpenAI auth: {requires_openai_auth}" + )); + if requires_openai_auth { + return None; + } + + match provider_env_key { + Some(env_key) if env_var_present(env_key) => { + details.push(format!("provider auth env var: {env_key} (present)")); + Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "auth is provided by the active model provider", + ) + .details(details), + ) + } + Some(env_key) => { + details.push(format!("provider auth env var: {env_key} (missing)")); + let remediation = provider_env_key_instructions + .map(str::to_string) + .unwrap_or_else(|| format!("Set {env_key} for the active model provider.")); + Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "active model provider auth env var is missing", + ) + .details(details) + .remediation(remediation), + ) + } + None => Some( + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Ok, + "OpenAI auth is not required for the active model provider", + ) + .details(details), + ), + } +} + +fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str { + match stored_auth_mode_value(auth) { + codex_app_server_protocol::AuthMode::ApiKey => "api_key", + codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + } +} + +fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::AuthMode { + if let Some(mode) = auth.auth_mode { + return mode; + } + if auth.openai_api_key.is_some() { + codex_app_server_protocol::AuthMode::ApiKey + } else { + codex_app_server_protocol::AuthMode::Chatgpt + } +} + +fn stored_auth_issues( + auth: &AuthDotJson, + env_var_present: impl Fn(&str) -> bool, +) -> Vec<&'static str> { + let mut issues = Vec::new(); + match stored_auth_mode_value(auth) { + codex_app_server_protocol::AuthMode::ApiKey => { + let stored_key_present = auth + .openai_api_key + .as_deref() + .is_some_and(|key| !key.trim().is_empty()); + let env_key_present = + env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR); + if !stored_key_present && !env_key_present { + issues.push("API key auth is missing an API key"); + } + } + codex_app_server_protocol::AuthMode::Chatgpt => { + match auth.tokens.as_ref() { + Some(tokens) => { + if tokens.access_token.trim().is_empty() { + issues.push("ChatGPT auth is missing an access token"); + } + if tokens.refresh_token.trim().is_empty() { + issues.push("ChatGPT auth is missing a refresh token"); + } + } + None => issues.push("ChatGPT auth is missing token data"), + } + if auth.last_refresh.is_none() { + issues.push("ChatGPT auth is missing refresh metadata"); + } + } + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => { + match auth.tokens.as_ref() { + Some(tokens) => { + if tokens.access_token.trim().is_empty() { + issues.push("external ChatGPT auth is missing an access token"); + } + if tokens.account_id.is_none() && tokens.id_token.chatgpt_account_id.is_none() { + issues.push("external ChatGPT auth is missing a ChatGPT account id"); + } + } + None => issues.push("external ChatGPT auth is missing token data"), + } + if auth.last_refresh.is_none() { + issues.push("external ChatGPT auth is missing refresh metadata"); + } + } + codex_app_server_protocol::AuthMode::AgentIdentity => { + if auth + .agent_identity + .as_deref() + .is_none_or(|token| token.trim().is_empty()) + { + issues.push("agent identity auth is missing an agent identity token"); + } + } + } + issues +} + +fn network_check() -> DoctorCheck { + let mut details = Vec::new(); + push_proxy_env_details(&mut details); + + let mut status = CheckStatus::Ok; + let mut summary = "network-related environment looks readable".to_string(); + for name in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] { + if let Some(raw) = env::var_os(name) { + let path = PathBuf::from(raw); + match std::fs::metadata(&path) { + Ok(metadata) if metadata.is_file() => { + if let Err(err) = read_probe_file(&path) { + status = CheckStatus::Warning; + summary = "custom CA env var points at an unreadable file".to_string(); + details.push(format!("{name}: {} ({err})", path.display())); + } else { + details.push(format!("{name}: readable file {}", path.display())); + } + } + Ok(_) => { + status = CheckStatus::Warning; + summary = "custom CA env var does not point at a file".to_string(); + details.push(format!("{name}: not a file {}", path.display())); + } + Err(err) => { + status = CheckStatus::Warning; + summary = "custom CA env var points at an unreadable path".to_string(); + details.push(format!("{name}: {} ({err})", path.display())); + } + } + } + } + + DoctorCheck::new("network.env", "network", status, summary).details(details) +} + +fn push_proxy_env_details(details: &mut Vec) { + let present_proxy_vars = PROXY_ENV_VARS + .iter() + .copied() + .filter(|name| env_var_present(name)) + .collect::>(); + if present_proxy_vars.is_empty() { + details.push("proxy env vars: none".to_string()); + } else { + details.push(format!( + "proxy env vars present: {}", + present_proxy_vars.join(", ") + )); + } +} + +fn read_probe_file(path: &Path) -> std::io::Result<()> { + let mut file = std::fs::File::open(path)?; + let mut buffer = [0_u8; 1]; + let _ = file.read(&mut buffer)?; + Ok(()) +} + +async fn mcp_check(config: &Config) -> DoctorCheck { + mcp_check_from_servers(config.mcp_servers.get()).await +} + +async fn mcp_check_from_servers(servers: &HashMap) -> DoctorCheck { + if servers.is_empty() { + return DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Ok, + "no MCP servers configured", + ); + } + + let mut details = Vec::new(); + let mut transport_counts: BTreeMap<&'static str, usize> = BTreeMap::new(); + let mut disabled = 0usize; + let mut missing_env = Vec::new(); + let mut unreachable_required_http = Vec::new(); + let mut unreachable_optional_http = Vec::new(); + + for (name, server) in servers { + let disabled_server = !server.enabled || server.disabled_reason.is_some(); + if disabled_server { + disabled += 1; + } + match &server.transport { + McpServerTransportConfig::Stdio { + command, + env, + env_vars, + cwd, + .. + } => { + *transport_counts.entry("stdio").or_default() += 1; + if disabled_server { + continue; + } + if let Some(cwd) = cwd + && !cwd.exists() + { + missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display())); + } + if command.trim().is_empty() { + missing_env.push(format!("{name}: stdio command is empty")); + } else if let Err(err) = + stdio_command_resolves(command, cwd.as_deref(), env.as_ref()) + { + missing_env.push(format!( + "{name}: stdio command {command:?} is not resolvable ({err})" + )); + } + if let Some(env) = env { + for key in env.keys().filter(|key| key.trim().is_empty()) { + missing_env.push(format!("{name}: empty env key {key}")); + } + } + for env_var in env_vars { + if env_var.is_remote_source() { + missing_env.push(format!( + "{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio", + env_var.name() + )); + } else if !env_var_present(env_var.name()) { + missing_env.push(format!("{name}: env var {} is not set", env_var.name())); + } + } + } + McpServerTransportConfig::StreamableHttp { + url, + bearer_token_env_var, + env_http_headers, + .. + } => { + *transport_counts.entry("streamable_http").or_default() += 1; + if disabled_server { + continue; + } + if let Some(env_var) = bearer_token_env_var + && !env_var_present(env_var) + { + missing_env.push(format!("{name}: bearer token env var {env_var} is not set")); + } + if let Some(headers) = env_http_headers { + for env_var in headers.values() { + if !env_var_present(env_var) { + missing_env + .push(format!("{name}: header env var {env_var} is not set")); + } + } + } + if let Err(err) = mcp_http_probe_url(url).await { + let detail = format!("{name}: {url} ({err})"); + if server.required { + unreachable_required_http.push(detail); + } else { + unreachable_optional_http.push(detail); + } + } + } + } + } + + details.push(format!("configured servers: {}", servers.len())); + details.push(format!("disabled servers: {disabled}")); + for (transport, count) in transport_counts { + details.push(format!("{transport} servers: {count}")); + } + details.extend(missing_env.iter().cloned()); + details.extend( + unreachable_required_http + .iter() + .map(|detail| format!("required reachability failed: {detail}")), + ); + details.extend( + unreachable_optional_http + .iter() + .map(|detail| format!("optional reachability failed: {detail}")), + ); + + let required_missing = servers.iter().any(|(name, server)| { + server.required + && missing_env + .iter() + .any(|missing| missing.starts_with(&format!("{name}:"))) + }); + let status = if required_missing || !unreachable_required_http.is_empty() { + CheckStatus::Fail + } else if !missing_env.is_empty() || !unreachable_optional_http.is_empty() { + CheckStatus::Warning + } else { + CheckStatus::Ok + }; + let summary = match status { + CheckStatus::Ok => "MCP configuration is locally consistent", + CheckStatus::Warning => "MCP configuration has optional issues", + CheckStatus::Fail => "MCP configuration has failing required inputs or reachability", + }; + + let mut check = DoctorCheck::new("mcp.config", "mcp", status, summary).details(details); + if status != CheckStatus::Ok { + check = check.remediation("Set the missing MCP env vars or disable the affected server."); + } + check +} + +fn sandbox_check(config: &Config, arg0_paths: &Arg0DispatchPaths) -> DoctorCheck { + let mut details = Vec::new(); + details.push(format!( + "approval policy: {:?}", + config.permissions.approval_policy.value() + )); + let file_system_sandbox = config.permissions.file_system_sandbox_policy(); + details.push(format!("filesystem sandbox: {}", file_system_sandbox.kind)); + details.push(format!( + "network sandbox: {}", + config.permissions.network_sandbox_policy() + )); + push_path_detail( + &mut details, + "codex-linux-sandbox helper", + arg0_paths.codex_linux_sandbox_exe.as_deref(), + ); + push_path_detail( + &mut details, + "execve wrapper helper", + arg0_paths.main_execve_wrapper_exe.as_deref(), + ); + + let mut status = CheckStatus::Ok; + let mut summary = "sandbox configuration is readable".to_string(); + if let Some(helper) = arg0_paths.codex_linux_sandbox_exe.as_deref() + && !helper.exists() + { + status = CheckStatus::Warning; + summary = "Linux sandbox helper path does not exist".to_string(); + } + + DoctorCheck::new("sandbox.helpers", "sandbox", status, summary).details(details) +} + +#[derive(Clone, Debug)] +struct TerminalCheckInputs { + info: TerminalInfo, + env: BTreeMap, + present_env: BTreeSet, + no_color_flag: bool, + stdin_is_terminal: bool, + stdout_is_terminal: bool, + stderr_is_terminal: bool, + stream_supports_color: bool, + terminal_size: Result<(u16, u16), String>, + tmux_details: Vec, +} + +impl TerminalCheckInputs { + fn detect(no_color_flag: bool) -> Self { + let names = terminal_env_names(); + let (env, present_env) = collect_env_snapshot(&names); + let terminal_size = crossterm::terminal::size().map_err(|err| err.to_string()); + let info = terminal_info(); + let tmux_details = if matches!(info.multiplexer, Some(Multiplexer::Tmux { .. })) { + tmux_diagnostic_details() + } else { + Vec::new() + }; + Self { + info, + env, + present_env, + no_color_flag, + stdin_is_terminal: std::io::stdin().is_terminal(), + stdout_is_terminal: std::io::stdout().is_terminal(), + stderr_is_terminal: std::io::stderr().is_terminal(), + stream_supports_color: supports_color::on(Stream::Stdout).is_some(), + terminal_size, + tmux_details, + } + } + + fn env_value(&self, name: &str) -> Option<&str> { + self.env.get(name).map(String::as_str) + } + + fn env_present(&self, name: &str) -> bool { + self.present_env.contains(name) + } +} + +fn terminal_check(no_color_flag: bool) -> DoctorCheck { + terminal_check_from_inputs(TerminalCheckInputs::detect(no_color_flag)) +} + +fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck { + let info = &inputs.info; + let name = info.name; + let mut details = vec![format!("terminal: {}", terminal_name(info))]; + if let Some(term_program) = info.term_program.as_deref() { + details.push(format!("TERM_PROGRAM: {term_program}")); + } + if let Some(version) = info.version.as_deref() { + details.push(format!("terminal version: {version}")); + } + if let Some(term) = info.term.as_deref() { + details.push(format!("TERM: {term}")); + } + if let Some(multiplexer) = info.multiplexer.as_ref() { + details.push(format!("multiplexer: {}", multiplexer_name(multiplexer))); + } + details.push(format!("stdin is terminal: {}", inputs.stdin_is_terminal)); + details.push(format!("stdout is terminal: {}", inputs.stdout_is_terminal)); + details.push(format!("stderr is terminal: {}", inputs.stderr_is_terminal)); + match &inputs.terminal_size { + Ok((columns, rows)) => details.push(format!("terminal size: {columns}x{rows}")), + Err(err) => details.push(format!("terminal size: unavailable ({err})")), + } + push_terminal_env_values(&mut details, &inputs, TERMINAL_DIMENSION_ENV_VARS); + details.push(format!("color output: {}", color_output_summary(&inputs))); + push_terminal_env_values(&mut details, &inputs, COLOR_ENV_VARS); + let terminfo_warning = push_terminfo_details(&mut details, &inputs); + let locale = effective_locale(&inputs); + if let Some(locale) = locale.as_ref() { + details.push(format!("effective locale: {locale}")); + } + push_presence_env_values(&mut details, &inputs, REMOTE_TERMINAL_ENV_VARS); + details.extend(inputs.tmux_details.iter().cloned()); + + let locale_warning = locale.as_deref().is_some_and(is_non_utf8_locale); + let mut issues = Vec::new(); + if matches!(name, TerminalName::Dumb) { + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "TERM=dumb - colors and cursor control are disabled", + ) + .measured("TERM=dumb") + .expected("TERM=xterm-256color or another real terminal type") + .remedy("set TERM to a real value, for example xterm-256color") + .field("TERM"), + ); + } + if locale_warning { + let measured = locale.unwrap_or_else(|| "unknown".to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + "locale is not UTF-8 - unicode glyphs may render incorrectly", + ) + .measured(measured) + .expected("UTF-8 locale, for example en_US.UTF-8") + .remedy("export LANG=en_US.UTF-8 or another UTF-8 locale") + .field("effective locale"), + ); + } + if terminfo_warning { + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "TERMINFO unreadable - terminal capabilities are unknown", + ) + .expected("readable terminfo file or directory") + .remedy("check that $TERMINFO points to a readable directory") + .field("TERMINFO") + .field("TERMINFO_DIRS entry"), + ); + } + issues.extend(terminal_size_issues(&inputs)); + + let status = issues + .iter() + .map(|issue| issue.severity) + .max() + .unwrap_or(CheckStatus::Ok); + let summary = issues + .first() + .map(|issue| issue.cause.as_str()) + .unwrap_or("terminal metadata was detected"); + let mut check = DoctorCheck::new("terminal.env", "terminal", status, summary).details(details); + for issue in issues { + check = check.issue(issue); + } + check +} + +fn terminal_name(info: &TerminalInfo) -> &'static str { + match info.name { + TerminalName::AppleTerminal => "Apple Terminal", + TerminalName::Ghostty => "Ghostty", + TerminalName::Iterm2 => "iTerm2", + TerminalName::WarpTerminal => "Warp", + TerminalName::VsCode => "VS Code", + TerminalName::WezTerm => "WezTerm", + TerminalName::Kitty => "kitty", + TerminalName::Alacritty => "Alacritty", + TerminalName::Konsole => "Konsole", + TerminalName::GnomeTerminal => "GNOME Terminal", + TerminalName::Vte => "VTE", + TerminalName::WindowsTerminal => "Windows Terminal", + TerminalName::Dumb => "dumb", + TerminalName::Unknown => "unknown", + } +} + +fn multiplexer_name(multiplexer: &Multiplexer) -> String { + match multiplexer { + Multiplexer::Tmux { version } => match version { + Some(version) => format!("tmux {version}"), + None => "tmux".to_string(), + }, + Multiplexer::Zellij { version } => match version { + Some(version) => format!("zellij {version}"), + None => "zellij".to_string(), + }, + } +} + +fn terminal_env_names() -> BTreeSet<&'static str> { + let mut names = BTreeSet::from(["TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION"]); + names.extend(COLOR_ENV_VARS.iter().copied()); + names.extend(TERMINAL_DIMENSION_ENV_VARS.iter().copied()); + names.extend(TERMINFO_ENV_VARS.iter().copied()); + names.extend(LOCALE_ENV_VARS.iter().copied()); + names.extend(REMOTE_TERMINAL_ENV_VARS.iter().copied()); + names +} + +fn collect_env_snapshot( + names: &BTreeSet<&'static str>, +) -> (BTreeMap, BTreeSet) { + let mut values = BTreeMap::new(); + let mut present = BTreeSet::new(); + for name in names { + if let Some(raw) = env::var_os(name) { + present.insert((*name).to_string()); + let value = raw.to_string_lossy().trim().to_string(); + if !value.is_empty() { + values.insert((*name).to_string(), value); + } + } + } + (values, present) +} + +fn push_terminal_env_values( + details: &mut Vec, + inputs: &TerminalCheckInputs, + names: &[&str], +) { + for name in names { + if let Some(value) = inputs.env_value(name) { + details.push(format!("{name}: {value}")); + } else if inputs.env_present(name) { + details.push(format!("{name}: present")); + } + } +} + +fn push_presence_env_values( + details: &mut Vec, + inputs: &TerminalCheckInputs, + names: &[&str], +) { + for name in names { + if inputs.env_present(name) { + details.push(format!("{name}: present")); + } + } +} + +fn color_output_summary(inputs: &TerminalCheckInputs) -> String { + if should_enable_color( + inputs.no_color_flag, + inputs.env_present("NO_COLOR"), + inputs.env_value("TERM"), + inputs.stdout_is_terminal, + inputs.stream_supports_color, + ) { + return "enabled".to_string(); + } + + let reason = if inputs.no_color_flag { + "--no-color" + } else if inputs.env_present("NO_COLOR") { + "NO_COLOR" + } else if inputs.env_value("TERM") == Some("dumb") { + "TERM=dumb" + } else if !inputs.stdout_is_terminal { + "stdout is not a terminal" + } else if !inputs.stream_supports_color { + "terminal color support not detected" + } else { + "disabled" + }; + format!("disabled ({reason})") +} + +fn push_terminfo_details(details: &mut Vec, inputs: &TerminalCheckInputs) -> bool { + let mut has_warning = false; + if let Some(raw) = inputs.env_value("TERMINFO") { + let path = PathBuf::from(raw); + let (status, warning) = terminal_path_readiness(&path); + details.push(format!("TERMINFO: {} ({status})", path.display())); + has_warning |= warning; + } + if let Some(raw) = inputs.env_value("TERMINFO_DIRS") { + for path in env::split_paths(raw).filter(|path| !path.as_os_str().is_empty()) { + let (status, warning) = terminal_path_readiness(&path); + details.push(format!( + "TERMINFO_DIRS entry: {} ({status})", + path.display() + )); + has_warning |= warning; + } + } else if inputs.env_present("TERMINFO_DIRS") { + details.push("TERMINFO_DIRS: present".to_string()); + } + has_warning +} + +fn terminal_path_readiness(path: &Path) -> (String, bool) { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_dir() => match std::fs::read_dir(path) { + Ok(_) => ("dir".to_string(), false), + Err(err) => (format!("dir unreadable: {err}"), true), + }, + Ok(metadata) if metadata.is_file() => match read_probe_file(path) { + Ok(_) => ("file".to_string(), false), + Err(err) => (format!("file unreadable: {err}"), true), + }, + Ok(_) => ("not a file or directory".to_string(), true), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => ("missing".to_string(), true), + Err(err) => (err.to_string(), true), + } +} + +fn effective_locale(inputs: &TerminalCheckInputs) -> Option { + LOCALE_ENV_VARS + .iter() + .find_map(|name| inputs.env_value(name).map(ToString::to_string)) +} + +fn is_non_utf8_locale(locale: &str) -> bool { + let locale = locale.to_ascii_lowercase(); + !(locale.contains("utf-8") || locale.contains("utf8")) +} + +fn terminal_size_issues(inputs: &TerminalCheckInputs) -> Vec { + let mut issues = Vec::new(); + if let Ok((columns, rows)) = inputs.terminal_size { + if columns > 0 && columns < NARROW_TERMINAL_COLUMNS { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("width {columns} cols - output may wrap (recommended >=80)"), + ) + .measured(format!("{columns} x {rows}")) + .expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns")) + .remedy("resize the window to at least 80 columns") + .field("terminal size"), + ); + } + if rows > 0 && rows < NARROW_TERMINAL_ROWS { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("height {rows} rows - content may scroll off (recommended >=24)"), + ) + .measured(format!("{columns} x {rows}")) + .expected(format!(">= {NARROW_TERMINAL_ROWS} rows")) + .remedy("resize the window to at least 24 rows") + .field("terminal size"), + ); + } + } + + if let Some(columns) = inputs + .env_value("COLUMNS") + .and_then(|columns| columns.parse::().ok()) + && columns > 0 + && columns < NARROW_TERMINAL_COLUMNS + { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("COLUMNS={columns} - output may wrap (recommended >=80)"), + ) + .measured(format!("{columns} columns")) + .expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns")) + .remedy("resize the window to at least 80 columns") + .field("COLUMNS"), + ); + } + if let Some(rows) = inputs + .env_value("LINES") + .and_then(|rows| rows.parse::().ok()) + && rows > 0 + && rows < NARROW_TERMINAL_ROWS + { + issues.push( + DoctorIssue::new( + CheckStatus::Warning, + format!("LINES={rows} - content may scroll off (recommended >=24)"), + ) + .measured(format!("{rows} rows")) + .expected(format!(">= {NARROW_TERMINAL_ROWS} rows")) + .remedy("resize the window to at least 24 rows") + .field("LINES"), + ); + } + + issues +} + +fn tmux_diagnostic_details() -> Vec { + let mut details = Vec::new(); + push_tmux_display_detail(&mut details, "tmux client termtype", "#{client_termtype}"); + push_tmux_display_detail(&mut details, "tmux client termname", "#{client_termname}"); + for option in TMUX_OPTION_NAMES { + let value = tmux_option_value(option).unwrap_or_else(|| "unavailable".to_string()); + details.push(format!("tmux {option}: {value}")); + } + details +} + +fn push_tmux_display_detail(details: &mut Vec, label: &str, format: &str) { + if let Some(value) = tmux_display_message(format) { + details.push(format!("{label}: {value}")); + } +} + +fn tmux_option_value(option: &str) -> Option { + let output = Command::new("tmux") + .args(["show-options", "-gqv", option]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + non_empty_trimmed(String::from_utf8(output.stdout).ok()?) +} + +fn tmux_display_message(format: &str) -> Option { + let output = Command::new("tmux") + .args(["display-message", "-p", format]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + non_empty_trimmed(String::from_utf8(output.stdout).ok()?) +} + +fn non_empty_trimmed(value: String) -> Option { + let value = value.trim().to_string(); + if value.is_empty() { None } else { Some(value) } +} + +async fn state_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + path_readiness(&mut details, "CODEX_HOME", &config.codex_home); + path_readiness(&mut details, "log dir", &config.log_dir); + path_readiness(&mut details, "sqlite home", &config.sqlite_home); + let state_db = codex_state::state_db_path(&config.sqlite_home); + let log_db = codex_state::logs_db_path(&config.sqlite_home); + path_readiness(&mut details, "state DB", &state_db); + path_readiness(&mut details, "log DB", &log_db); + let mut integrity_failures = Vec::new(); + sqlite_integrity_detail(&mut details, &mut integrity_failures, "state DB", &state_db).await; + sqlite_integrity_detail(&mut details, &mut integrity_failures, "log DB", &log_db).await; + rollout_stats_details(&mut details, &config.codex_home); + standalone_release_cache_details(&mut details); + + let status = if integrity_failures.is_empty() { + CheckStatus::Ok + } else { + CheckStatus::Fail + }; + let summary = if status == CheckStatus::Ok { + "state paths and databases are inspectable" + } else { + "state database integrity check failed" + }; + let mut check = DoctorCheck::new("state.paths", "state", status, summary).details(details); + if status == CheckStatus::Fail { + check = check + .remediation("Back up CODEX_HOME, then remove or repair the affected SQLite database."); + } + check +} + +async fn sqlite_integrity_detail( + details: &mut Vec, + integrity_failures: &mut Vec, + label: &str, + path: &Path, +) { + if !path.is_file() { + details.push(format!("{label} integrity: skipped (missing)")); + return; + } + + match codex_state::sqlite_integrity_check(path).await { + Ok(rows) if rows.iter().all(|row| row == "ok") => { + details.push(format!("{label} integrity: ok")); + } + Ok(rows) => { + let message = format!("{label} integrity: {}", rows.join("; ")); + integrity_failures.push(message.clone()); + details.push(message); + } + Err(err) => { + let message = format!("{label} integrity: {err}"); + integrity_failures.push(message.clone()); + details.push(message); + } + } +} + +fn rollout_stats_details(details: &mut Vec, codex_home: &Path) { + let active = collect_rollout_stats(&codex_home.join("sessions")); + let archived = collect_rollout_stats(&codex_home.join("archived_sessions")); + push_rollout_stats_detail(details, "active rollout files", active); + push_rollout_stats_detail(details, "archived rollout files", archived); +} + +fn push_rollout_stats_detail(details: &mut Vec, label: &str, stats: RolloutStats) { + match stats.error { + Some(error) => details.push(format!("{label}: scan failed ({error})")), + None => details.push(format!( + "{label}: {} files, {} total bytes, {} average bytes", + stats.files, + stats.total_bytes, + stats.average_bytes() + )), + } +} + +#[derive(Default)] +struct RolloutStats { + files: u64, + total_bytes: u64, + error: Option, +} + +impl RolloutStats { + fn average_bytes(&self) -> u64 { + if self.files == 0 { + 0 + } else { + self.total_bytes / self.files + } + } +} + +fn collect_rollout_stats(root: &Path) -> RolloutStats { + let mut stats = RolloutStats::default(); + collect_rollout_stats_inner(root, &mut stats); + stats +} + +fn collect_rollout_stats_inner(path: &Path, stats: &mut RolloutStats) { + if stats.error.is_some() { + return; + } + let entries = match std::fs::read_dir(path) { + Ok(entries) => entries, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + + for entry in entries { + let entry = match entry { + Ok(entry) => entry, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + let path = entry.path(); + let metadata = match entry.metadata() { + Ok(metadata) => metadata, + Err(err) => { + stats.error = Some(err.to_string()); + return; + } + }; + if metadata.is_dir() { + collect_rollout_stats_inner(&path, stats); + } else if metadata.is_file() && is_rollout_file(&path) { + stats.files += 1; + stats.total_bytes = stats.total_bytes.saturating_add(metadata.len()); + } + } +} + +fn is_rollout_file(path: &Path) -> bool { + path.extension() == Some(OsStr::new("jsonl")) + && path + .file_name() + .and_then(OsStr::to_str) + .is_some_and(|name| name.starts_with("rollout-")) +} + +async fn websocket_reachability_check( + config: &Config, + auth_manager: Option>, +) -> DoctorCheck { + let provider = &config.model_provider; + let mut details = vec![ + format!("model provider: {}", config.model_provider_id), + format!("provider name: {}", provider.name), + format!("wire API: {}", provider.wire_api), + format!("supports websockets: {}", provider.supports_websockets), + ]; + push_proxy_env_details(&mut details); + + if !provider.supports_websockets { + return DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket is not enabled for the active provider", + ) + .details(details); + } + + details.push(format!( + "connect timeout: {} ms", + provider.websocket_connect_timeout().as_millis() + )); + + let runtime_provider = create_model_provider(provider.clone(), auth_manager); + let auth = runtime_provider.auth().await; + details.push(format!( + "auth mode: {}", + auth.as_ref().map(auth_mode_name).unwrap_or("none") + )); + + let api_provider = match runtime_provider.api_provider().await { + Ok(api_provider) => api_provider, + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket provider setup failed", + details, + format!("provider setup failed: {err}"), + ); + } + }; + match api_provider.websocket_url_for_path("responses") { + Ok(url) => { + details.push(format!("endpoint: {url}")); + if let Some(host) = url.host_str() + && let Some(port) = url.port_or_known_default() + { + details.extend(dns_address_family_details(host, port).await); + } + } + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket endpoint could not be built", + details, + format!("endpoint build failed: {err}"), + ); + } + } + + let api_auth = match runtime_provider.api_auth().await { + Ok(api_auth) => api_auth, + Err(err) => { + return websocket_probe_warning( + "Responses WebSocket auth could not be resolved", + details, + format!("auth resolution failed: {err}"), + ); + } + }; + + let mut extra_headers = HeaderMap::new(); + extra_headers.insert( + OPENAI_BETA_HEADER, + HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), + ); + let client = ResponsesWebsocketClient::new(api_provider, api_auth); + match tokio::time::timeout( + provider.websocket_connect_timeout(), + client.probe_handshake( + extra_headers, + default_headers(), + WEBSOCKET_IMMEDIATE_CLOSE_GRACE, + ), + ) + .await + { + Ok(Ok(probe)) => { + details.push(format!("handshake result: HTTP {}", probe.status)); + details.push(format!("reasoning header: {}", probe.reasoning_included)); + details.push(format!( + "models etag present: {}", + probe.models_etag_present + )); + details.push(format!( + "server model present: {}", + probe.server_model_present + )); + if let Some(close) = probe.immediate_close { + details.push(format!("immediate close code: {}", close.code)); + details.push(format!("immediate close reason: {}", close.reason)); + return DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Warning, + "Responses WebSocket closed immediately after handshake", + ) + .details(details) + .remediation( + "Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.", + ); + } + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ) + .details(details) + } + Ok(Err(err)) => websocket_probe_warning( + "Responses WebSocket failed; HTTPS fallback may still work", + details, + websocket_error_detail(&err), + ), + Err(_) => websocket_probe_warning( + "Responses WebSocket timed out; HTTPS fallback may still work", + details, + "handshake timed out".to_string(), + ), + } +} + +fn websocket_probe_warning( + summary: &'static str, + mut details: Vec, + error_detail: String, +) -> DoctorCheck { + details.push(error_detail); + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Warning, + summary, + ) + .details(details) + .remediation("Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.") +} + +fn websocket_error_detail(err: &ApiError) -> String { + match err { + ApiError::Transport(transport) => format!("handshake transport error: {transport}"), + ApiError::Api { status, message } => { + format!("handshake API error: {status} {message}") + } + ApiError::Stream(message) => format!("handshake stream error: {message}"), + ApiError::ContextWindowExceeded + | ApiError::QuotaExceeded + | ApiError::UsageNotIncluded + | ApiError::Retryable { .. } + | ApiError::RateLimit(_) + | ApiError::InvalidRequest { .. } + | ApiError::CyberPolicy { .. } + | ApiError::ServerOverloaded => format!("handshake error: {err}"), + } +} + +fn auth_mode_name(auth: &CodexAuth) -> &'static str { + match auth.auth_mode() { + codex_app_server_protocol::AuthMode::ApiKey => "api_key", + codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt", + codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens", + codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity", + } +} + +async fn dns_address_family_details(host: &str, port: u16) -> Vec { + match tokio::net::lookup_host((host, port)).await { + Ok(addresses) => { + let addresses = addresses.collect::>(); + let ipv4_count = addresses + .iter() + .filter(|address| matches!(address.ip(), IpAddr::V4(_))) + .count(); + let ipv6_count = addresses + .iter() + .filter(|address| matches!(address.ip(), IpAddr::V6(_))) + .count(); + let first_family = addresses + .first() + .map(|address| match address.ip() { + IpAddr::V4(_) => "IPv4", + IpAddr::V6(_) => "IPv6", + }) + .unwrap_or("none"); + vec![format!( + "DNS: {ipv4_count} IPv4, {ipv6_count} IPv6, first {first_family}" + )] + } + Err(err) => vec![format!("DNS: lookup failed ({err})")], + } +} + +fn fallback_state_check() -> DoctorCheck { + let codex_home = find_codex_home(); + match codex_home { + Ok(path) => DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "CODEX_HOME was resolved without config", + ) + .detail(format!("CODEX_HOME: {}", path.display())), + Err(err) => DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Warning, + "CODEX_HOME could not be resolved", + ) + .detail(err.to_string()), + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReachabilityPlan { + description: String, + endpoints: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReachabilityEndpoint { + label: String, + url: String, + required: bool, + route_probe_url: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ProviderAuthReachabilityMode { + NotRequired, + ApiKey, + Chatgpt, +} + +impl ProviderAuthReachabilityMode { + fn description(self) -> &'static str { + match self { + Self::NotRequired => "provider auth", + Self::ApiKey => "API key auth", + Self::Chatgpt => "ChatGPT auth", + } + } +} + +fn provider_reachability_plan(config: &Config) -> ReachabilityPlan { + let stored_auth = + load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) + .ok() + .flatten(); + let mode = provider_auth_reachability_mode_from_auth( + config.model_provider.requires_openai_auth, + env_var_present, + stored_auth.as_ref(), + ); + provider_reachability_plan_from_parts( + mode, + &config.model_provider_id, + &config.model_provider.name, + config.model_provider.base_url.as_deref(), + config.model_provider.query_params.as_ref(), + config.model_provider.is_amazon_bedrock(), + &config.chatgpt_base_url, + ) +} + +fn default_reachability_plan() -> ReachabilityPlan { + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::Chatgpt, + "openai", + "OpenAI", + /*provider_base_url*/ None, + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ) +} + +fn provider_auth_reachability_mode_from_auth( + requires_openai_auth: bool, + env_var_present: impl Fn(&str) -> bool, + stored_auth: Option<&AuthDotJson>, +) -> ProviderAuthReachabilityMode { + if !requires_openai_auth { + return ProviderAuthReachabilityMode::NotRequired; + } + if env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR) { + return ProviderAuthReachabilityMode::ApiKey; + } + if env_var_present(CODEX_ACCESS_TOKEN_ENV_VAR) { + return ProviderAuthReachabilityMode::Chatgpt; + } + match stored_auth.map(stored_auth_mode_value) { + Some(codex_app_server_protocol::AuthMode::ApiKey) => ProviderAuthReachabilityMode::ApiKey, + Some( + codex_app_server_protocol::AuthMode::Chatgpt + | codex_app_server_protocol::AuthMode::ChatgptAuthTokens + | codex_app_server_protocol::AuthMode::AgentIdentity, + ) + | None => ProviderAuthReachabilityMode::Chatgpt, + } +} + +fn provider_reachability_plan_from_parts( + mode: ProviderAuthReachabilityMode, + provider_id: &str, + provider_name: &str, + provider_base_url: Option<&str>, + provider_query_params: Option<&HashMap>, + is_amazon_bedrock: bool, + chatgpt_base_url: &str, +) -> ReachabilityPlan { + let provider_route_probe_url = provider_base_url + .or_else(|| { + (mode == ProviderAuthReachabilityMode::ApiKey).then_some("https://api.openai.com/v1") + }) + .and_then(|url| { + should_probe_models_route(provider_name, url, is_amazon_bedrock) + .then(|| provider_url_for_path(url, "models", provider_query_params)) + }); + let endpoints = match mode { + ProviderAuthReachabilityMode::ApiKey => vec![ReachabilityEndpoint { + label: format!("{provider_id} API"), + url: provider_base_url + .unwrap_or("https://api.openai.com/v1") + .to_string(), + required: true, + route_probe_url: provider_route_probe_url, + }], + ProviderAuthReachabilityMode::Chatgpt => vec![ReachabilityEndpoint { + label: "ChatGPT".to_string(), + url: chatgpt_base_url.to_string(), + required: true, + route_probe_url: None, + }], + ProviderAuthReachabilityMode::NotRequired => provider_base_url + .map(|url| { + vec![ReachabilityEndpoint { + label: format!("{provider_id} API"), + url: url.to_string(), + required: true, + route_probe_url: provider_route_probe_url, + }] + }) + .unwrap_or_default(), + }; + ReachabilityPlan { + description: mode.description().to_string(), + endpoints, + } +} + +fn should_probe_models_route(provider_name: &str, base_url: &str, is_amazon_bedrock: bool) -> bool { + !is_amazon_bedrock && !is_azure_responses_provider(provider_name, Some(base_url)) +} + +fn provider_url_for_path( + base_url: &str, + path: &str, + query_params: Option<&HashMap>, +) -> String { + let base = base_url.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + let mut url = if path.is_empty() { + base.to_string() + } else { + format!("{base}/{path}") + }; + + if let Some(params) = query_params + && !params.is_empty() + { + let separator = if url.contains('?') { '&' } else { '?' }; + url.push(separator); + url.push_str( + ¶ms + .iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&"), + ); + } + + url +} + +async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck { + let mut details = vec![format!("reachability mode: {}", plan.description)]; + if plan.endpoints.is_empty() { + details.push("active provider endpoint: none configured".to_string()); + return DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider has no HTTP endpoint to probe", + ) + .details(details); + } + + let mut failures = Vec::new(); + let mut optional_failures = Vec::new(); + let mut route_failures = Vec::new(); + let mut route_warnings = Vec::new(); + let mut issues = Vec::new(); + for endpoint in plan.endpoints { + match http_probe_url(&endpoint.url).await { + Ok(status) => details.push(format!( + "{} base URL: {} reachable ({status})", + endpoint.label, endpoint.url + )), + Err(err) => { + let requirement = if endpoint.required { + "required" + } else { + "optional" + }; + details.push(format!( + "{} base URL: {} {err} ({requirement})", + endpoint.label, endpoint.url + )); + if endpoint.required { + failures.push(endpoint.url); + } else { + optional_failures.push(endpoint.url); + } + continue; + } + } + + let Some(route_probe_url) = endpoint.route_probe_url.as_deref() else { + continue; + }; + match provider_route_probe_url(route_probe_url).await { + RouteProbeOutcome::Ok(status) => { + details.push(format!( + "{} route probe: {route_probe_url} route exists ({status})", + endpoint.label, + )); + } + RouteProbeOutcome::Warning(status) => { + details.push(format!( + "{} route probe: {route_probe_url} returned {status} (warning)", + endpoint.label, + )); + route_warnings.push(route_probe_url.to_string()); + } + RouteProbeOutcome::Fail(status) => { + details.push(format!( + "{} route probe: {route_probe_url} returned {status} (required)", + endpoint.label, + )); + route_failures.push(route_probe_url.to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "provider base URL route returned 404 - verify the configured API prefix", + ) + .measured(format!("{route_probe_url} returned {status}")) + .expected("GET /models returns 2xx, 401, or 403") + .remedy("Set base_url to the provider API root, for example https://api.openai.com/v1") + .field("route probe"), + ); + } + RouteProbeOutcome::TransportError(err) => { + details.push(format!( + "{} route probe: {route_probe_url} {err} (required)", + endpoint.label, + )); + route_failures.push(route_probe_url.to_string()); + issues.push( + DoctorIssue::new( + CheckStatus::Fail, + "provider route probe could not connect - verify network access to the provider API", + ) + .measured(format!("{route_probe_url} {err}")) + .expected("GET /models completes") + .remedy("Check proxy, VPN, firewall, DNS, and custom CA configuration.") + .field("route probe"), + ); + } + } + } + + let (status, summary) = provider_reachability_outcome( + failures.len() + route_failures.len(), + optional_failures.len() + route_warnings.len(), + ); + let mut check = DoctorCheck::new( + "network.provider_reachability", + "reachability", + status, + summary, + ) + .details(details); + for issue in issues { + check = check.issue(issue); + } + if status != CheckStatus::Ok { + check = check.remediation("Check proxy, VPN, firewall, DNS, and custom CA configuration."); + } + check +} + +enum RouteProbeOutcome { + Ok(String), + Warning(String), + Fail(String), + TransportError(String), +} + +async fn provider_route_probe_url(url: &str) -> RouteProbeOutcome { + match http_get_probe_status_with_timeout(url, Duration::from_secs(3)).await { + Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => { + RouteProbeOutcome::Ok(format!("HTTP {status}")) + } + Ok(404) => RouteProbeOutcome::Fail("HTTP 404".to_string()), + Ok(status) => RouteProbeOutcome::Warning(format!("HTTP {status}")), + Err(err) => RouteProbeOutcome::TransportError(err), + } +} + +fn provider_reachability_outcome( + required_failures: usize, + warnings: usize, +) -> (CheckStatus, &'static str) { + match (required_failures, warnings) { + (0, 0) => ( + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ), + (0, _) => ( + CheckStatus::Warning, + "provider endpoint checks returned warnings", + ), + (_, _) => ( + CheckStatus::Fail, + "one or more required provider endpoints are unreachable over HTTP", + ), + } +} + +async fn http_probe_url(url: &str) -> Result { + http_probe_url_with_timeout(url, Duration::from_secs(3)).await +} + +async fn mcp_http_probe_url(url: &str) -> Result { + mcp_http_probe_url_with_timeout(url, Duration::from_secs(3)).await +} + +async fn mcp_http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + match http_probe_url_with_timeout(url, timeout).await { + Ok(status) => Ok(status), + Err(head_err) => match http_get_probe_url_with_timeout(url, timeout).await { + Ok(status) => Ok(status), + Err(get_err) => Err(format!("HEAD {head_err}; GET {get_err}")), + }, + } +} + +async fn http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + let response = build_reqwest_client() + .head(url) + .timeout(timeout) + .send() + .await + .map_err(|err| { + if err.is_timeout() { + "request timed out".to_string() + } else if err.is_connect() { + "connect failed".to_string() + } else if err.is_builder() { + "request could not be built".to_string() + } else { + err.to_string() + } + })?; + Ok(format!("HTTP {}", response.status().as_u16())) +} + +async fn http_get_probe_url_with_timeout(url: &str, timeout: Duration) -> Result { + http_get_probe_status_with_timeout(url, timeout) + .await + .map(|status| format!("HTTP {status}")) +} + +async fn http_get_probe_status_with_timeout(url: &str, timeout: Duration) -> Result { + let response = build_reqwest_client() + .get(url) + .timeout(timeout) + .send() + .await + .map_err(|err| { + if err.is_timeout() { + "request timed out".to_string() + } else if err.is_connect() { + "connect failed".to_string() + } else if err.is_builder() { + "request could not be built".to_string() + } else { + err.to_string() + } + })?; + Ok(response.status().as_u16()) +} + +fn stdio_command_resolves( + command: &str, + cwd: Option<&Path>, + server_env: Option<&HashMap>, +) -> Result<(), String> { + let command_path = Path::new(command); + if command_path.is_absolute() { + return executable_path_exists(command_path); + } + + if command_path.components().count() > 1 { + let base = cwd + .map(Path::to_path_buf) + .or_else(|| env::current_dir().ok()) + .unwrap_or_else(|| PathBuf::from(".")); + return executable_path_exists(&base.join(command_path)); + } + + let Some(path_env) = server_env + .and_then(|env| env.get("PATH").map(String::as_str)) + .map(std::ffi::OsString::from) + .or_else(|| env::var_os("PATH")) + else { + return Err("PATH is not set".to_string()); + }; + + for dir in env::split_paths(&path_env) { + let candidate = dir.join(command); + if executable_path_exists(&candidate).is_ok() { + return Ok(()); + } + #[cfg(windows)] + { + let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string()); + for extension in pathext.split(';').filter(|extension| !extension.is_empty()) { + let candidate = dir.join(format!("{command}{extension}")); + if executable_path_exists(&candidate).is_ok() { + return Ok(()); + } + } + } + } + Err("not found on PATH".to_string()) +} + +fn executable_path_exists(path: &Path) -> Result<(), String> { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => executable_file_permission(path, &metadata), + Ok(_) => Err("path is not a file".to_string()), + Err(err) => Err(err.to_string()), + } +} + +#[cfg(unix)] +fn executable_file_permission(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + + if metadata.permissions().mode() & 0o111 == 0 { + Err(format!("{} is not executable", path.display())) + } else { + Ok(()) + } +} + +#[cfg(not(unix))] +fn executable_file_permission(_path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> { + Ok(()) +} + +fn path_readiness(details: &mut Vec, label: &str, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) => { + let kind = if metadata.is_dir() { + "dir" + } else if metadata.is_file() { + "file" + } else { + "other" + }; + details.push(format!("{label}: {} ({kind})", path.display())); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push(format!("{label}: {} (missing)", path.display())); + } + Err(err) => details.push(format!("{label}: {} ({err})", path.display())), + } +} + +fn standalone_release_cache_details(details: &mut Vec) { + let InstallContext::Standalone { release_dir, .. } = InstallContext::current() else { + return; + }; + let Some(releases_dir) = release_dir.parent() else { + return; + }; + let Ok(entries) = std::fs::read_dir(releases_dir) else { + return; + }; + let release_count = entries.filter_map(Result::ok).count(); + details.push(format!( + "standalone release cache: {release_count} entries in {}", + releases_dir.display() + )); +} + +fn push_path_detail(details: &mut Vec, label: &str, path: Option<&Path>) { + match path { + Some(path) => details.push(format!("{label}: {}", path.display())), + None => details.push(format!("{label}: none")), + } +} + +fn push_env_path_detail(details: &mut Vec, label: &str, name: &str) { + match env::var_os(name) { + Some(path) => details.push(format!("{label}: {}", PathBuf::from(path).display())), + None => details.push(format!("{label}: not set")), + } +} + +fn env_var_present(name: &str) -> bool { + env::var_os(name).is_some_and(|value| !value.is_empty()) +} + +fn human_output_options(command: &DoctorCommand) -> HumanOutputOptions { + let term = env::var("TERM").ok(); + let color_enabled = should_enable_color( + command.no_color, + env::var_os("NO_COLOR").is_some(), + term.as_deref(), + std::io::stdout().is_terminal(), + supports_color::on(Stream::Stdout).is_some(), + ); + HumanOutputOptions { + show_details: !command.summary, + show_all: command.all, + ascii: command.ascii, + color_enabled, + } +} + +fn should_enable_color( + no_color_flag: bool, + no_color_env: bool, + term: Option<&str>, + stdout_is_tty: bool, + stream_supports_color: bool, +) -> bool { + !no_color_flag + && !no_color_env + && term != Some("dumb") + && stdout_is_tty + && stream_supports_color +} + +#[cfg(test)] +mod tests { + use std::io::Read; + use std::io::Write; + use std::net::TcpListener; + use std::sync::Mutex; + + use clap::Parser; + use codex_protocol::config_types::SandboxMode; + use pretty_assertions::assert_eq; + + use super::*; + + #[derive(Default)] + struct RecordingProgress { + events: Mutex>, + } + + impl RecordingProgress { + fn events(&self) -> Vec { + self.events.lock().expect("events lock").clone() + } + } + + impl DoctorProgress for RecordingProgress { + fn begin(&self, label: &'static str) { + self.events + .lock() + .expect("events lock") + .push(format!("begin {label}")); + } + + fn heartbeat(&self, label: &'static str, elapsed: Duration) { + self.events + .lock() + .expect("events lock") + .push(format!("heartbeat {label} {}", elapsed.as_secs())); + } + + fn finish(&self, label: &'static str, status: CheckStatus) { + self.events + .lock() + .expect("events lock") + .push(format!("finish {label} {status:?}")); + } + + fn settle(&self) { + self.events + .lock() + .expect("events lock") + .push("settle".to_string()); + } + } + + fn respond_once(listener: &TcpListener, response: &[u8]) { + let (mut stream, _) = listener.accept().expect("accept probe request"); + let mut request = [0; 1024]; + let _ = stream.read(&mut request); + stream.write_all(response).expect("write response"); + } + + #[test] + fn overall_status_prefers_fail() { + let checks = vec![ + DoctorCheck::new("a", "config", CheckStatus::Warning, "warning"), + DoctorCheck::new("b", "auth", CheckStatus::Fail, "fail"), + ]; + assert_eq!(overall_status(&checks), CheckStatus::Fail); + } + + #[test] + fn run_sync_check_notifies_progress() { + let progress_impl = Arc::new(RecordingProgress::default()); + let progress: Arc = progress_impl.clone(); + + let check = run_sync_check("test", progress, || { + DoctorCheck::new("test", "test", CheckStatus::Ok, "ok") + }); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!( + progress_impl.events(), + vec!["begin test".to_string(), "finish test Ok".to_string()] + ); + } + + #[tokio::test] + async fn run_async_check_notifies_progress() { + let progress_impl = Arc::new(RecordingProgress::default()); + let progress: Arc = progress_impl.clone(); + + let check = run_async_check("test", progress, async { + DoctorCheck::new("test", "test", CheckStatus::Warning, "warning") + }) + .await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + progress_impl.events(), + vec!["begin test".to_string(), "finish test Warning".to_string()] + ); + } + + #[test] + fn compare_npm_package_roots_detects_match() { + let running = PathBuf::from("/prefix/lib/node_modules/@openai/codex"); + let npm_root = PathBuf::from("/prefix/lib/node_modules"); + assert_eq!( + compare_npm_package_roots(&running, &npm_root), + NpmRootCheck::Match { + package_root: npm_root.join("@openai").join("codex") + } + ); + } + + #[test] + fn compare_npm_package_roots_detects_mismatch() { + let running = PathBuf::from("/old/lib/node_modules/@openai/codex"); + let npm_root = PathBuf::from("/new/lib/node_modules"); + assert_eq!( + compare_npm_package_roots(&running, &npm_root), + NpmRootCheck::Mismatch { + running_package_root: running, + npm_package_root: npm_root.join("@openai").join("codex"), + } + ); + } + + #[test] + fn config_overrides_from_interactive_preserves_global_options() { + let interactive = TuiCli::parse_from([ + "codex", + "--oss", + "--local-provider", + "ollama", + "--model", + "llama3.2", + "--cd", + "/tmp", + "--sandbox", + "danger-full-access", + "--ask-for-approval", + "never", + "--add-dir", + "/var/tmp", + ]); + let arg0_paths = Arg0DispatchPaths { + codex_self_exe: Some(PathBuf::from("/bin/codex")), + codex_linux_sandbox_exe: Some(PathBuf::from("/bin/codex-linux-sandbox")), + main_execve_wrapper_exe: Some(PathBuf::from("/bin/codex-execve-wrapper")), + }; + + let overrides = config_overrides_from_interactive(&interactive, &arg0_paths); + + assert_eq!(overrides.model.as_deref(), Some("llama3.2")); + assert_eq!(overrides.model_provider.as_deref(), Some("ollama")); + assert_eq!(overrides.cwd.as_deref(), Some(Path::new("/tmp"))); + assert_eq!(overrides.approval_policy, Some(AskForApproval::Never)); + assert_eq!(overrides.sandbox_mode, Some(SandboxMode::DangerFullAccess)); + assert_eq!(overrides.show_raw_agent_reasoning, Some(true)); + assert_eq!( + overrides.additional_writable_roots, + vec![PathBuf::from("/var/tmp")] + ); + assert_eq!(overrides.codex_self_exe, arg0_paths.codex_self_exe); + assert_eq!( + overrides.codex_linux_sandbox_exe, + arg0_paths.codex_linux_sandbox_exe + ); + assert_eq!( + overrides.main_execve_wrapper_exe, + arg0_paths.main_execve_wrapper_exe + ); + } + + #[test] + fn redacted_json_report_structures_and_sanitizes_details() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Warning, + "MCP configuration has optional issues", + ) + .detail( + "optional reachability failed: remote: https://user:pass@example.com/mcp?x=abc (connect failed)", + ) + .detail("OPENAI_API_KEY: sk-live-secret") + .detail("duplicate: one") + .detail("duplicate: two") + .detail("freeform note") + .issue( + DoctorIssue::new( + CheckStatus::Warning, + "remote https://user:pass@example.com/mcp?x=abc is unreachable", + ) + .measured("https://user:pass@example.com/mcp?x=abc") + .expected("reachable MCP endpoint") + .remedy("Check https://user:pass@example.com/help?x=abc.") + .field("optional reachability failed"), + ) + .remediation("Open https://user:pass@example.com/help?x=abc."), + ], + }; + + let redacted_report = redacted_json_report(&report); + let redacted = serde_json::to_string(&redacted_report).expect("serialize report"); + let json = serde_json::to_value(redacted_report).expect("report should serialize"); + + assert!(!redacted.contains("user:pass")); + assert!(!redacted.contains("x=abc")); + assert!(!redacted.contains("sk-live-secret")); + assert!(redacted.contains("https://example.com/mcp")); + assert_eq!(json["checks"].is_object(), true); + assert_eq!(json["checks"]["mcp.config"]["id"], "mcp.config"); + assert_eq!( + json["checks"]["mcp.config"]["details"]["OPENAI_API_KEY"], + "" + ); + assert_eq!( + json["checks"]["mcp.config"]["details"]["duplicate"], + serde_json::json!(["one", "two"]) + ); + assert_eq!( + json["checks"]["mcp.config"]["notes"], + serde_json::json!(["freeform note"]) + ); + assert_eq!( + json["checks"]["mcp.config"]["issues"][0]["measured"], + "https://example.com/mcp" + ); + assert_eq!( + json["checks"]["mcp.config"]["issues"][0]["remedy"], + "Check https://example.com/help." + ); + } + + #[tokio::test] + async fn mcp_check_ignores_disabled_servers() { + let disabled_server: McpServerConfig = toml::from_str( + r#" + url = "http://127.0.0.1:9/mcp" + enabled = false + required = true + bearer_token_env_var = "CODEX_DOCTOR_DISABLED_MCP_TOKEN" + "#, + ) + .expect("should deserialize disabled MCP config"); + let servers = HashMap::from([("disabled".to_string(), disabled_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "MCP configuration is locally consistent"); + assert!(check.details.contains(&"disabled servers: 1".to_string())); + assert!( + check + .details + .iter() + .all(|detail| !detail.contains("CODEX_DOCTOR_DISABLED_MCP_TOKEN")) + ); + assert!( + check + .details + .iter() + .all(|detail| !detail.contains("reachability failed")) + ); + } + + #[tokio::test] + async fn mcp_check_warns_for_optional_http_reachability() { + let optional_server: McpServerConfig = toml::from_str( + r#" + url = "http://127.0.0.1:9/mcp" + "#, + ) + .expect("should deserialize optional MCP config"); + let servers = HashMap::from([("optional".to_string(), optional_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!(check.summary, "MCP configuration has optional issues"); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("optional reachability failed: optional:")) + ); + } + + #[tokio::test] + async fn mcp_check_fails_required_remote_stdio_env_var() { + let command = toml::Value::String( + std::env::current_exe() + .expect("current exe") + .to_string_lossy() + .into_owned(), + ); + let required_server: McpServerConfig = toml::from_str(&format!( + r#" + command = {command} + required = true + env_vars = [{{ name = "REMOTE_ONLY_TOKEN", source = "remote" }}] + "#, + )) + .expect("should deserialize required MCP config"); + let servers = HashMap::from([("required".to_string(), required_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert!(check.details.iter().any(|detail| { + detail.contains( + "required: env_vars entry `REMOTE_ONLY_TOKEN` uses source `remote`, which requires remote MCP stdio", + ) + })); + } + + #[test] + fn provider_specific_auth_allows_non_openai_provider_without_env_key() { + let check = provider_specific_auth_check( + /*requires_openai_auth*/ false, + /*provider_env_key*/ None, + /*provider_env_key_instructions*/ None, + Vec::new(), + |_| false, + ) + .expect("non-OpenAI provider should produce a provider-specific check"); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!( + check.summary, + "OpenAI auth is not required for the active model provider" + ); + } + + #[test] + fn provider_specific_auth_fails_when_provider_env_key_is_missing() { + let check = provider_specific_auth_check( + /*requires_openai_auth*/ false, + Some("PROVIDER_API_KEY"), + Some("Set PROVIDER_API_KEY before running Codex."), + Vec::new(), + |_| false, + ) + .expect("non-OpenAI provider should produce a provider-specific check"); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "active model provider auth env var is missing" + ); + assert_eq!( + check.remediation, + Some("Set PROVIDER_API_KEY before running Codex.".to_string()) + ); + } + + #[test] + fn stored_auth_validation_rejects_missing_api_key() { + let auth = AuthDotJson { + auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec!["API key auth is missing an API key"] + ); + assert!(stored_auth_issues(&auth, |name| name == OPENAI_API_KEY_ENV_VAR).is_empty()); + } + + #[test] + fn stored_auth_validation_rejects_missing_chatgpt_tokens() { + let auth = AuthDotJson { + auth_mode: None, + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + stored_auth_issues(&auth, |_| false), + vec![ + "ChatGPT auth is missing token data", + "ChatGPT auth is missing refresh metadata", + ] + ); + } + + #[test] + fn provider_reachability_mode_uses_api_key_auth() { + let api_key_auth = AuthDotJson { + auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey), + openai_api_key: Some("sk-test".to_string()), + tokens: None, + last_refresh: None, + agent_identity: None, + }; + + assert_eq!( + provider_auth_reachability_mode_from_auth( + /*requires_openai_auth*/ true, + |_| false, + Some(&api_key_auth), + ), + ProviderAuthReachabilityMode::ApiKey + ); + assert_eq!( + provider_auth_reachability_mode_from_auth( + /*requires_openai_auth*/ true, + |name| name == OPENAI_API_KEY_ENV_VAR, + /*stored_auth*/ None, + ), + ProviderAuthReachabilityMode::ApiKey + ); + } + + #[test] + fn provider_reachability_uses_active_provider_endpoint() { + assert_eq!( + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "azure", + "azure", + Some("https://example.openai.azure.com/openai/v1"), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ), + ReachabilityPlan { + description: "provider auth".to_string(), + endpoints: vec![ReachabilityEndpoint { + label: "azure API".to_string(), + url: "https://example.openai.azure.com/openai/v1".to_string(), + required: true, + route_probe_url: None, + }], + } + ); + } + + #[test] + fn provider_reachability_adds_models_route_probe_for_openai_compatible_base_urls() { + let query_params = HashMap::from([("api-version".to_string(), "2026-01-01".to_string())]); + + assert_eq!( + provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "custom", + "Custom", + Some("https://example.com/openai/v1/"), + Some(&query_params), + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ), + ReachabilityPlan { + description: "provider auth".to_string(), + endpoints: vec![ReachabilityEndpoint { + label: "custom API".to_string(), + url: "https://example.com/openai/v1/".to_string(), + required: true, + route_probe_url: Some( + "https://example.com/openai/v1/models?api-version=2026-01-01".to_string() + ), + }], + } + ); + } + + #[test] + fn provider_reachability_skips_route_probe_for_bedrock() { + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::NotRequired, + "amazon-bedrock", + "Amazon Bedrock", + Some("https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1"), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ true, + "https://chatgpt.com/backend-api/", + ); + + assert_eq!(plan.endpoints[0].route_probe_url, None); + } + + #[test] + fn provider_reachability_api_key_does_not_require_chatgpt() { + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + /*provider_base_url*/ None, + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + assert_eq!( + plan.endpoints, + vec![ReachabilityEndpoint { + label: "openai API".to_string(), + url: "https://api.openai.com/v1".to_string(), + required: true, + route_probe_url: Some("https://api.openai.com/v1/models".to_string()), + }] + ); + } + + #[test] + fn provider_reachability_outcome_reports_required_failures() { + assert_eq!( + provider_reachability_outcome(/*required_failures*/ 0, /*warnings*/ 1,), + ( + CheckStatus::Warning, + "provider endpoint checks returned warnings", + ) + ); + assert_eq!( + provider_reachability_outcome(/*required_failures*/ 1, /*warnings*/ 0,), + ( + CheckStatus::Fail, + "one or more required provider endpoints are unreachable over HTTP", + ) + ); + } + + #[tokio::test] + async fn provider_reachability_route_404_fails_bad_base_url_path() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + Some(&format!("http://{addr}/xxxx")), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + let check = provider_reachability_check(plan).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(check.status, CheckStatus::Fail); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("route probe:") && detail.contains("HTTP 404")) + ); + assert_eq!(check.issues.len(), 1); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("Set base_url to the provider API root, for example https://api.openai.com/v1") + ); + } + + #[tokio::test] + async fn provider_reachability_route_401_keeps_reachability_ok() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + respond_once( + &listener, + b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + respond_once( + &listener, + b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + }); + let plan = provider_reachability_plan_from_parts( + ProviderAuthReachabilityMode::ApiKey, + "openai", + "OpenAI", + Some(&format!("http://{addr}/v1")), + /*provider_query_params*/ None, + /*is_amazon_bedrock*/ false, + "https://chatgpt.com/backend-api/", + ); + + let check = provider_reachability_check(plan).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(check.status, CheckStatus::Ok); + assert!( + check + .details + .iter() + .any(|detail| detail.contains("route exists (HTTP 401)")) + ); + } + + #[test] + fn collect_rollout_stats_counts_nested_rollout_files() { + let temp = tempfile::tempdir().expect("create temp dir"); + let nested = temp + .path() + .join("sessions") + .join("2026") + .join("05") + .join("13"); + std::fs::create_dir_all(&nested).expect("create nested rollout dir"); + std::fs::write( + nested.join("rollout-2026-05-13T00-00-00-test.jsonl"), + "12345", + ) + .expect("write rollout file"); + std::fs::write(nested.join("not-a-rollout.jsonl"), "ignored").expect("write ignored jsonl"); + + let stats = collect_rollout_stats(&temp.path().join("sessions")); + + assert_eq!(stats.files, 1); + assert_eq!(stats.total_bytes, 5); + assert_eq!(stats.average_bytes(), 5); + assert_eq!(stats.error, None); + } + + #[tokio::test] + async fn http_probe_treats_http_status_as_reachable() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("accept probe request"); + let mut request = [0; 1024]; + let _ = stream.read(&mut request); + stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write response"); + }); + + let status = http_probe_url(&format!("http://{addr}/mcp")).await; + server.join().expect("probe server thread should finish"); + + assert_eq!(status, Ok("HTTP 405".to_string())); + } + + #[tokio::test] + async fn mcp_http_probe_falls_back_to_get_when_head_times_out() { + let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener"); + let addr = listener.local_addr().expect("listener address"); + let server = std::thread::spawn(move || { + let (mut head_stream, _) = listener.accept().expect("accept HEAD probe request"); + let head = std::thread::spawn(move || { + let mut request = [0; 1024]; + let _ = head_stream.read(&mut request); + std::thread::sleep(Duration::from_millis(50)); + }); + + let (mut get_stream, _) = listener.accept().expect("accept GET probe request"); + let mut request = [0; 1024]; + let _ = get_stream.read(&mut request); + get_stream + .write_all( + b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .expect("write response"); + head.join().expect("HEAD holder should finish"); + }); + + let status = mcp_http_probe_url_with_timeout( + &format!("http://{addr}/mcp"), + Duration::from_millis(10), + ) + .await; + server.join().expect("probe server thread should finish"); + + assert_eq!(status, Ok("HTTP 405".to_string())); + } + + #[tokio::test] + async fn mcp_check_fails_required_missing_stdio_command() { + let required_server: McpServerConfig = toml::from_str( + r#" + command = "definitely-missing-codex-doctor-mcp" + required = true + "#, + ) + .expect("should deserialize required MCP config"); + let servers = HashMap::from([("required".to_string(), required_server)]); + + let check = mcp_check_from_servers(&servers).await; + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "MCP configuration has failing required inputs or reachability" + ); + assert!(check.details.iter().any(|detail| { + detail.contains( + "required: stdio command \"definitely-missing-codex-doctor-mcp\" is not resolvable", + ) + })); + } + + #[cfg(unix)] + #[test] + fn read_probe_file_rejects_unreadable_file() { + use std::os::unix::fs::PermissionsExt; + + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "cert").expect("write temp file"); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o000); + std::fs::set_permissions(file.path(), permissions).expect("remove read permissions"); + + let result = read_probe_file(file.path()); + + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(file.path(), permissions).expect("restore read permissions"); + assert!(result.is_err()); + } + + #[cfg(unix)] + #[test] + fn executable_path_exists_rejects_non_executable_file() { + use std::os::unix::fs::PermissionsExt; + + let file = tempfile::NamedTempFile::new().expect("create temp file"); + std::fs::write(file.path(), "#!/bin/sh\n").expect("write temp file"); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o600); + std::fs::set_permissions(file.path(), permissions).expect("set non-executable mode"); + + let result = executable_path_exists(file.path()); + + assert!(result.is_err()); + let mut permissions = std::fs::metadata(file.path()) + .expect("metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(file.path(), permissions).expect("set executable mode"); + assert_eq!(executable_path_exists(file.path()), Ok(())); + } + + #[test] + fn should_enable_color_respects_terminal_inputs() { + assert!(should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ true, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ true, + Some("xterm-256color"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("dumb"), + /*stdout_is_tty*/ true, + /*stream_supports_color*/ true, + )); + assert!(!should_enable_color( + /*no_color_flag*/ false, + /*no_color_env*/ false, + Some("xterm-256color"), + /*stdout_is_tty*/ false, + /*stream_supports_color*/ true, + )); + } + + fn terminal_inputs() -> TerminalCheckInputs { + TerminalCheckInputs { + info: TerminalInfo { + name: TerminalName::Unknown, + term_program: None, + version: None, + term: Some("xterm-256color".to_string()), + multiplexer: None, + }, + env: BTreeMap::from([("TERM".to_string(), "xterm-256color".to_string())]), + present_env: BTreeSet::from(["TERM".to_string()]), + no_color_flag: false, + stdin_is_terminal: true, + stdout_is_terminal: true, + stderr_is_terminal: true, + stream_supports_color: true, + terminal_size: Ok((120, 40)), + tmux_details: Vec::new(), + } + } + + fn set_terminal_env(inputs: &mut TerminalCheckInputs, name: &str, value: &str) { + inputs.present_env.insert(name.to_string()); + if value.is_empty() { + inputs.env.remove(name); + } else { + inputs.env.insert(name.to_string(), value.to_string()); + } + } + + #[test] + fn terminal_check_warns_for_dumb_terminal() { + let mut inputs = terminal_inputs(); + inputs.info.name = TerminalName::Dumb; + inputs.info.term = Some("dumb".to_string()); + set_terminal_env(&mut inputs, "TERM", "dumb"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "TERM=dumb - colors and cursor control are disabled" + ); + assert_eq!(check.issues.len(), 1); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("set TERM to a real value, for example xterm-256color") + ); + } + + #[test] + fn terminal_check_warns_for_narrow_terminal() { + let mut inputs = terminal_inputs(); + inputs.terminal_size = Ok((79, 24)); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "width 79 cols - output may wrap (recommended >=80)" + ); + assert_eq!(check.issues[0].expected.as_deref(), Some(">= 80 columns")); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("resize the window to at least 80 columns") + ); + } + + #[test] + fn terminal_check_warns_for_declared_narrow_terminal() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "COLUMNS", "60"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "COLUMNS=60 - output may wrap (recommended >=80)" + ); + assert!(check.details.contains(&"COLUMNS: 60".to_string())); + assert_eq!(check.issues[0].fields, vec!["COLUMNS".to_string()]); + } + + #[test] + fn terminal_check_warns_for_non_utf8_locale() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "LANG", "C"); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Warning); + assert_eq!( + check.summary, + "locale is not UTF-8 - unicode glyphs may render incorrectly" + ); + assert!(check.details.contains(&"effective locale: C".to_string())); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("export LANG=en_US.UTF-8 or another UTF-8 locale") + ); + } + + #[test] + fn terminal_check_warns_for_unreadable_terminfo_path() { + let tempdir = tempfile::tempdir().expect("create tempdir"); + let missing = tempdir.path().join("missing-terminfo"); + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "TERMINFO", &missing.to_string_lossy()); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Fail); + assert_eq!( + check.summary, + "TERMINFO unreadable - terminal capabilities are unknown" + ); + assert!( + check + .details + .iter() + .any(|detail| detail.starts_with("TERMINFO: ") && detail.ends_with(" (missing)")) + ); + assert_eq!( + check.issues[0].remedy.as_deref(), + Some("check that $TERMINFO points to a readable directory") + ); + } + + #[test] + fn terminal_check_reports_remote_indicators_as_present_only() { + let mut inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "SSH_CONNECTION", "10.0.0.1 1 10.0.0.2 22"); + + let check = terminal_check_from_inputs(inputs); + + assert!( + check + .details + .contains(&"SSH_CONNECTION: present".to_string()) + ); + assert!( + !check + .details + .iter() + .any(|detail| detail.contains("10.0.0.1")) + ); + } + + #[test] + fn terminal_check_keeps_tmux_probe_failures_non_fatal() { + let mut inputs = terminal_inputs(); + inputs.info.multiplexer = Some(Multiplexer::Tmux { version: None }); + + let check = terminal_check_from_inputs(inputs); + + assert_eq!(check.status, CheckStatus::Ok); + assert_eq!(check.summary, "terminal metadata was detected"); + } + + #[test] + fn color_output_summary_reports_disabled_reasons() { + let mut inputs = terminal_inputs(); + inputs.no_color_flag = true; + assert_eq!(color_output_summary(&inputs), "disabled (--no-color)"); + + inputs = terminal_inputs(); + set_terminal_env(&mut inputs, "NO_COLOR", ""); + assert_eq!(color_output_summary(&inputs), "disabled (NO_COLOR)"); + + inputs = terminal_inputs(); + inputs.info.term = Some("dumb".to_string()); + set_terminal_env(&mut inputs, "TERM", "dumb"); + assert_eq!(color_output_summary(&inputs), "disabled (TERM=dumb)"); + + inputs = terminal_inputs(); + inputs.stdout_is_terminal = false; + assert_eq!( + color_output_summary(&inputs), + "disabled (stdout is not a terminal)" + ); + } +} diff --git a/codex-rs/cli/src/doctor/background.rs b/codex-rs/cli/src/doctor/background.rs new file mode 100644 index 0000000000..0ec5132cd3 --- /dev/null +++ b/codex-rs/cli/src/doctor/background.rs @@ -0,0 +1,150 @@ +//! Reports app-server daemon state without starting or stopping the daemon. +//! +//! The background-server check is deliberately passive. It reads the daemon +//! state directory, PID files, settings file, and control socket path, then +//! attempts only a local socket connection when a socket already exists. That +//! keeps doctor safe to run while the user is debugging startup or update-loop +//! issues. + +use std::path::Path; + +use codex_core::config::Config; + +use super::CheckStatus; +use super::DoctorCheck; + +const STATE_DIR_NAME: &str = "app-server-daemon"; +const SETTINGS_FILE_NAME: &str = "settings.json"; +const PID_FILE_NAME: &str = "app-server.pid"; +const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid"; + +/// Builds the app-server status row from existing daemon state. +/// +/// Missing files are expected for the ephemeral/not-running case and should not +/// be treated as failures. A stale socket is a warning because it can explain +/// client connection problems without proving the daemon itself is broken. +pub(super) fn background_server_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + let state_dir = config.codex_home.join(STATE_DIR_NAME); + details.push(format!("daemon state dir: {}", state_dir.display())); + push_file_detail( + &mut details, + "settings", + &state_dir.join(SETTINGS_FILE_NAME), + ); + push_file_detail(&mut details, "pid file", &state_dir.join(PID_FILE_NAME)); + push_file_detail( + &mut details, + "update-loop pid file", + &state_dir.join(UPDATE_PID_FILE_NAME), + ); + + let socket_path = match codex_app_server::app_server_control_socket_path(&config.codex_home) { + Ok(socket_path) => socket_path, + Err(err) => { + return DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Warning, + "background server socket path could not be resolved", + ) + .details(details) + .detail(err.to_string()); + } + }; + + details.push(format!("control socket: {}", socket_path.display())); + let status = socket_status(socket_path.as_path()); + details.push(format!("status: {}", status.detail_label())); + details.push(format!("mode: {}", server_mode(&state_dir))); + + let mut check = DoctorCheck::new( + "app_server.status", + "app-server", + status.check_status(), + status.summary(), + ) + .details(details); + if status.check_status() == CheckStatus::Warning { + check = check.remediation("Run codex app-server daemon version for more details."); + } + check +} + +fn push_file_detail(details: &mut Vec, label: &str, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => { + details.push(format!("{label}: {} (file)", path.display())); + } + Ok(_) => { + details.push(format!("{label}: {} (not a file)", path.display())); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push(format!("{label}: {} (missing)", path.display())); + } + Err(err) => details.push(format!("{label}: {} ({err})", path.display())), + } +} + +fn server_mode(state_dir: &Path) -> &'static str { + if state_dir.join(SETTINGS_FILE_NAME).is_file() { + "persistent" + } else { + "ephemeral" + } +} + +#[derive(Clone, Copy)] +enum SocketStatus { + NotRunning, + Running, + #[cfg(unix)] + StaleOrUnreachable, +} + +impl SocketStatus { + fn check_status(self) -> CheckStatus { + match self { + Self::NotRunning | Self::Running => CheckStatus::Ok, + #[cfg(unix)] + Self::StaleOrUnreachable => CheckStatus::Warning, + } + } + + fn summary(self) -> &'static str { + match self { + Self::NotRunning => "background server is not running", + Self::Running => "background server is running", + #[cfg(unix)] + Self::StaleOrUnreachable => "background server socket is stale or unreachable", + } + } + + fn detail_label(self) -> &'static str { + match self { + Self::NotRunning => "not running", + Self::Running => "running", + #[cfg(unix)] + Self::StaleOrUnreachable => "stale or unreachable", + } + } +} + +fn socket_status(socket_path: &Path) -> SocketStatus { + if !socket_path.exists() { + return SocketStatus::NotRunning; + } + + #[cfg(unix)] + { + match std::os::unix::net::UnixStream::connect(socket_path) { + Ok(_) => SocketStatus::Running, + Err(_) => SocketStatus::StaleOrUnreachable, + } + } + + #[cfg(not(unix))] + { + SocketStatus::Running + } +} diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs new file mode 100644 index 0000000000..da5fd20ce8 --- /dev/null +++ b/codex-rs/cli/src/doctor/output.rs @@ -0,0 +1,1555 @@ +//! Renders doctor reports for terminal users. +//! +//! The renderer is intentionally separate from check construction so the JSON +//! report can stay stable while the human view optimizes for scanability. It +//! groups checks by concern, colors only status/actionable tokens, and redacts +//! sensitive detail lines before showing them in detailed output. + +mod detail; + +use std::fmt::Write as _; + +use detail::HumanDetail; +use detail::detail_lines; +use owo_colors::OwoColorize; +use owo_colors::XtermColors; + +use super::CheckStatus; +use super::DoctorCheck; +use super::DoctorReport; + +const NAME_WIDTH: usize = 12; +const DETAIL_LABEL_WIDTH: usize = 24; +const SEPARATOR_WIDTH: usize = 61; + +const GROUPS: &[OutputGroup] = &[ + OutputGroup { + title: "Environment", + keys: &["runtime", "install", "search", "terminal", "state"], + }, + OutputGroup { + title: "Configuration", + keys: &["config", "auth", "mcp", "sandbox"], + }, + OutputGroup { + title: "Updates", + keys: &["updates"], + }, + OutputGroup { + title: "Connectivity", + keys: &["network", "websocket", "reachability"], + }, + OutputGroup { + title: "Background Server", + keys: &["app-server"], + }, +]; + +struct OutputGroup { + title: &'static str, + keys: &'static [&'static str], +} + +/// Rendering controls for human doctor output. +/// +/// These options affect presentation only. They must not change which checks +/// run or which fields are present in the underlying JSON report. +#[derive(Clone, Copy, Debug)] +pub(super) struct HumanOutputOptions { + pub(super) show_details: bool, + pub(super) show_all: bool, + pub(super) ascii: bool, + pub(super) color_enabled: bool, +} + +/// Formats a doctor report into the grouped terminal layout. +/// +/// The renderer expects checks to carry stable categories, but it owns their +/// display order. Adding a new category without adding it to GROUPS keeps JSON +/// output intact but hides that row from the human view. +pub(super) fn render_human_report(report: &DoctorReport, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "{} {}", + bold("Codex Doctor", options), + dim(&header_suffix(report), options) + ); + out.push('\n'); + + let notes = notes_for_report(report); + if !notes.is_empty() { + let _ = writeln!(out, "{}", bold("Notes", options)); + for note in ¬es { + write_note_row(&mut out, note, options); + } + let _ = writeln!(out, "{}", dim(&separator(options), options)); + out.push('\n'); + } + + let mut wrote_group = false; + for group in GROUPS { + let group_checks = checks_for_group(report, group); + if group_checks.is_empty() { + continue; + } + + if wrote_group { + out.push('\n'); + } + wrote_group = true; + + let _ = writeln!(out, "{}", bold(group.title, options)); + for check in group_checks { + write_check_row(&mut out, check, options); + } + } + + out.push('\n'); + let _ = writeln!(out, "{}", dim(&separator(options), options)); + let _ = writeln!(out, "{}", summary_line(report, options)); + out.push('\n'); + write_footer(&mut out, options); + out +} + +fn checks_for_group<'a>(report: &'a DoctorReport, group: &OutputGroup) -> Vec<&'a DoctorCheck> { + group + .keys + .iter() + .flat_map(|key| { + report + .checks + .iter() + .filter(move |check| check.category == *key) + }) + .collect() +} + +fn write_check_row(out: &mut String, check: &DoctorCheck, options: HumanOutputOptions) { + let description = row_description(check, options); + let status = display_status(check); + let _ = writeln!( + out, + " {}{} {}", + status_marker_slot(status, options), + format_args!("{: { + let is_issue = expected.is_some(); + let label = format!("{label: { + let spacer = " ".repeat(DETAIL_LABEL_WIDTH); + let _ = writeln!( + out, + " {} {}", + detail_label(&spacer, options), + detail_value(&value, options) + ); + } + HumanDetail::Bullet(value) => { + let _ = writeln!( + out, + " {} {}", + very_dim(if options.ascii { "-" } else { "·" }, options), + dim(&highlight_actions(&value, options), options) + ); + } + HumanDetail::Remedy(value) => { + let marker = if options.ascii { "->" } else { "→" }; + let _ = writeln!( + out, + " {} {}", + orange(marker, options), + highlight_actions(&value, options) + ); + } + } +} + +fn row_description(check: &DoctorCheck, options: HumanOutputOptions) -> String { + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) && !check.issues.is_empty() + { + return issue_summary(check); + } + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) + && let Some(remediation) = &check.remediation + { + let dash = if options.ascii { " - " } else { " — " }; + let summary = &check.summary; + return format!("{summary}{dash}{remediation}"); + } + + display_summary(check, options) +} + +fn issue_summary(check: &DoctorCheck) -> String { + match check.issues.as_slice() { + [] => check.summary.clone(), + [issue] => issue.cause.clone(), + issues => format!( + "{} issues - {}", + issues.len(), + issues + .iter() + .take(2) + .map(|issue| issue.cause.as_str()) + .collect::>() + .join("; ") + ), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayStatus { + Ok, + Update, + Note, + Warning, + Fail, + Idle, +} + +struct DoctorNote { + status: DisplayStatus, + name: String, + summary: String, +} + +fn display_status(check: &DoctorCheck) -> DisplayStatus { + if check.category == "app-server" + && check.status == CheckStatus::Ok + && check + .details + .iter() + .any(|detail| detail == "status: not running") + { + return DisplayStatus::Idle; + } + + match check.status { + CheckStatus::Ok => DisplayStatus::Ok, + CheckStatus::Warning => DisplayStatus::Warning, + CheckStatus::Fail => DisplayStatus::Fail, + } +} + +fn status_marker(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = if options.ascii { + match status { + DisplayStatus::Ok => "[ok]", + DisplayStatus::Update => "[up]", + DisplayStatus::Note | DisplayStatus::Warning => "[!!]", + DisplayStatus::Fail => "[XX]", + DisplayStatus::Idle => "[--]", + } + } else { + match status { + DisplayStatus::Ok => "✓", + DisplayStatus::Update => "↑", + DisplayStatus::Note | DisplayStatus::Warning => "⚠", + DisplayStatus::Fail => "✗", + DisplayStatus::Idle => "○", + } + }; + + match status { + DisplayStatus::Ok => green(marker, options), + DisplayStatus::Update => amber(marker, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(marker, options), + DisplayStatus::Fail => red(marker, options), + DisplayStatus::Idle => dim(marker, options), + } +} + +fn status_marker_slot(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = status_marker(status, options); + format!("{marker} ") +} + +fn style_description( + description: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let highlighted = highlight_actions(description, options); + match status { + DisplayStatus::Ok | DisplayStatus::Idle => dim(&highlighted, options), + DisplayStatus::Update => amber(&highlighted, options), + DisplayStatus::Note | DisplayStatus::Warning | DisplayStatus::Fail => highlighted, + } +} + +fn detail_marker(is_issue: bool, options: HumanOutputOptions) -> String { + if !is_issue { + return " ".to_string(); + } + orange(if options.ascii { ">" } else { "▸" }, options) +} + +fn style_note_summary(note: &DoctorNote, options: HumanOutputOptions) -> String { + if note.status == DisplayStatus::Update { + return style_update_note_summary(¬e.summary, options); + } + style_description(¬e.summary, note.status, options) +} + +fn style_update_note_summary(summary: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return summary.to_string(); + } + + let Some((version, rest)) = summary.split_once(" available") else { + return amber(summary, options); + }; + let Some((action, parenthetical)) = rest.split_once(" (") else { + return format!( + "{}{}", + amber(&format!("{version} available"), options), + amber(rest, options) + ); + }; + format!( + "{}{} {}", + amber(&format!("{version} available"), options), + amber(action, options), + dim(&format!("({parenthetical}"), options) + ) +} + +fn summary_line(report: &DoctorReport, options: HumanOutputOptions) -> String { + let notes = notes_for_report(report); + let counts = StatusCounts::from_report(report, notes.len()); + let separator = dim(if options.ascii { " | " } else { " · " }, options); + let status = overall_status_label(report.overall_status); + let mut parts = vec![count_label(counts.ok, "ok", DisplayStatus::Ok, options)]; + if counts.idle > 0 { + parts.push(count_label( + counts.idle, + "idle", + DisplayStatus::Idle, + options, + )); + } + if counts.notes > 0 { + parts.push(count_label( + counts.notes, + "notes", + DisplayStatus::Note, + options, + )); + } + parts.push(count_label( + counts.warning, + "warn", + DisplayStatus::Warning, + options, + )); + parts.push(count_label( + counts.fail, + "fail", + DisplayStatus::Fail, + options, + )); + format!( + "{} {}", + parts.join(&separator), + styled_overall_status(status, report.overall_status, options) + ) +} + +fn count_label( + count: usize, + label: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let count = dim(&count.to_string(), options); + let label = match status { + DisplayStatus::Ok => green(label, options), + DisplayStatus::Update => amber(label, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(label, options), + DisplayStatus::Fail => red(label, options), + DisplayStatus::Idle => dim(label, options), + }; + format!("{count} {label}") +} + +fn overall_status_label(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Ok => "ok", + CheckStatus::Warning => "degraded", + CheckStatus::Fail => "failed", + } +} + +fn styled_overall_status(label: &str, status: CheckStatus, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return label.to_string(); + } + + match status { + CheckStatus::Ok => label.green().bold().to_string(), + CheckStatus::Warning => label.yellow().bold().to_string(), + CheckStatus::Fail => label.red().bold().to_string(), + } +} + +fn write_footer(out: &mut String, options: HumanOutputOptions) { + if options.show_details { + let _ = writeln!( + out, + "{} {:<24} {} {}", + cyan("--summary", options), + dim("compact output", options), + cyan("--all", options), + dim("expand truncated lists", options) + ); + } else { + let _ = writeln!( + out, + "{}", + dim( + "Run codex doctor without --summary for detailed diagnostics.", + options + ) + ); + let _ = writeln!( + out, + "{} {:<28} {} {}", + cyan("--all", options), + dim("expand truncated lists", options), + cyan("--json", options), + dim("redacted report", options) + ); + return; + } + let _ = writeln!( + out, + "{} {}", + cyan("--json", options), + dim("redacted report", options) + ); +} + +fn header_suffix(report: &DoctorReport) -> String { + let version = format!("v{}", report.codex_version); + report + .checks + .iter() + .find(|check| check.category == "runtime") + .and_then(|check| detail::detail_value(check, "platform")) + .map_or(version.clone(), |platform| { + format!("{version} · {platform}") + }) +} + +fn notes_for_report(report: &DoctorReport) -> Vec { + let mut notes = Vec::new(); + if let Some(check) = find_check(report, "updates") { + update_note(check, report) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "state") { + rollout_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "sandbox") { + sandbox_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + non_ok_notes(report) + .into_iter() + .for_each(|note| notes.push(note)); + auth_reachability_note(report) + .into_iter() + .for_each(|note| notes.push(note)); + notes +} + +fn find_check<'a>(report: &'a DoctorReport, category: &str) -> Option<&'a DoctorCheck> { + report + .checks + .iter() + .find(|check| check.category == category) +} + +fn update_note(check: &DoctorCheck, report: &DoctorReport) -> Option { + let status = detail::detail_value(check, "latest version status")?; + if !status.contains("newer version is available") { + return None; + } + let latest = detail::detail_value(check, "latest version") + .or_else(|| detail::detail_value(check, "cached latest version")) + .unwrap_or_else(|| "newer version".to_string()); + let dismissed = detail::detail_value(check, "dismissed version"); + let mut parenthetical = format!("current {}", report.codex_version); + if let Some(dismissed) = dismissed + && !detail::is_falsy(&dismissed) + { + parenthetical.push_str(&format!(", dismissed {dismissed}")); + } + Some(DoctorNote { + status: DisplayStatus::Update, + name: "updates".to_string(), + summary: format!("{latest} available ({parenthetical})"), + }) +} + +fn rollout_note(check: &DoctorCheck) -> Option { + let active = detail::detail_value(check, "active rollout files")?; + let (files, bytes) = detail::rollout_files_and_bytes(&active)?; + if files < 1000 && bytes < 1024 * 1024 * 1024 { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "rollouts".to_string(), + summary: format!( + "{} active files · {} on disk", + detail::format_count(files), + detail::format_bytes(bytes) + ), + }) +} + +fn sandbox_note(check: &DoctorCheck) -> Option { + let filesystem = detail::detail_value(check, "filesystem sandbox")?; + let network = detail::detail_value(check, "network sandbox")?; + if filesystem == "restricted" && network == "restricted" { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "sandbox".to_string(), + summary: format!("filesystem {filesystem} · network {network}"), + }) +} + +fn non_ok_notes(report: &DoctorReport) -> Vec { + report + .checks + .iter() + .filter(|check| matches!(check.status, CheckStatus::Warning | CheckStatus::Fail)) + .map(|check| DoctorNote { + status: display_status(check), + name: check.category.clone(), + summary: actionable_note_summary(check), + }) + .collect() +} + +fn actionable_note_summary(check: &DoctorCheck) -> String { + if !check.issues.is_empty() { + return issue_summary(check); + } + if let Some(remediation) = &check.remediation { + return format!("{} - {remediation}", check.summary); + } + check.summary.clone() +} + +fn auth_reachability_note(report: &DoctorReport) -> Option { + let websocket = find_check(report, "websocket")?; + let reachability = find_check(report, "reachability")?; + let auth_mode = detail::detail_value(websocket, "auth mode")?; + let reachability_mode = detail::detail_value(reachability, "reachability mode")?; + let auth_mode_lower = auth_mode.to_ascii_lowercase(); + let reachability_mode_lower = reachability_mode.to_ascii_lowercase(); + if auth_mode_lower.contains("chatgpt") && reachability_mode_lower.contains("api key") { + return Some(DoctorNote { + status: DisplayStatus::Warning, + name: "auth".to_string(), + summary: "mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode".to_string(), + }); + } + None +} + +fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String { + match check.category.as_str() { + "runtime" => runtime_summary(check), + "install" if check.status == CheckStatus::Ok => "consistent".to_string(), + "search" => search_summary(check), + "terminal" => terminal_summary(check), + "state" => state_summary(check), + "config" if check.status == CheckStatus::Ok => "loaded".to_string(), + "mcp" => mcp_summary(check), + "sandbox" => sandbox_summary(check), + "network" => network_summary(check), + "websocket" => websocket_summary(check), + "app-server" => app_server_summary(check), + _ => check.summary.clone(), + } +} + +fn runtime_summary(check: &DoctorCheck) -> String { + if detail::detail_value(check, "current executable") + .is_some_and(|path| path.contains("/target/debug/")) + { + return "local debug build".to_string(); + } + detail::detail_value(check, "install method").unwrap_or_else(|| check.summary.clone()) +} + +fn search_summary(check: &DoctorCheck) -> String { + let provider = detail::detail_value(check, "search provider"); + let command = detail::detail_value(check, "search command"); + let readiness = detail::detail_value(check, "search command readiness"); + match (readiness, provider, command) { + (Some(readiness), Some(provider), Some(command)) if check.status == CheckStatus::Ok => { + format!("{readiness} ({provider}, `{command}`)") + } + _ => check.summary.clone(), + } +} + +fn terminal_summary(check: &DoctorCheck) -> String { + let mut parts = Vec::new(); + if let Some(terminal) = detail::detail_value(check, "terminal") { + let version = detail::detail_value(check, "terminal version"); + parts.push(version.map_or(terminal.clone(), |version| format!("{terminal} {version}"))); + } + if let Some(multiplexer) = detail::detail_value(check, "multiplexer") { + parts.push(multiplexer); + } + if let Some(term) = detail::detail_value(check, "TERM") { + parts.push(format!("TERM={term}")); + } + if parts.is_empty() { + check.summary.clone() + } else { + parts.join(" · ") + } +} + +fn state_summary(check: &DoctorCheck) -> String { + let state_ok = + detail::detail_value(check, "state DB integrity").is_some_and(|value| value == "ok"); + let log_ok = detail::detail_value(check, "log DB integrity").is_some_and(|value| value == "ok"); + if state_ok && log_ok { + "databases healthy".to_string() + } else { + check.summary.clone() + } +} + +fn mcp_summary(check: &DoctorCheck) -> String { + let Some(count) = detail::detail_value(check, "configured servers") else { + return check.summary.clone(); + }; + let disabled = + detail::detail_value(check, "disabled servers").unwrap_or_else(|| "0".to_string()); + let transports = check + .details + .iter() + .filter_map(|detail| detail.split_once(" servers: ")) + .filter(|(transport, _)| *transport != "configured" && *transport != "disabled") + .map(|(transport, count)| format!("{count} {transport}")) + .collect::>(); + if transports.is_empty() { + format!("{count} servers · {disabled} disabled") + } else { + format!( + "{} server ({}) · {} disabled", + count, + transports.join(", "), + disabled + ) + } +} + +fn sandbox_summary(check: &DoctorCheck) -> String { + let approval = detail::detail_value(check, "approval policy"); + let filesystem = detail::detail_value(check, "filesystem sandbox"); + let network = detail::detail_value(check, "network sandbox"); + match (approval, filesystem, network) { + (Some(approval), Some(filesystem), Some(network)) => { + format!("{filesystem} fs + {network} network · approval {approval}") + } + _ => check.summary.clone(), + } +} + +fn network_summary(check: &DoctorCheck) -> String { + detail::detail_value(check, "proxy env vars") + .map(|value| { + if value == "none" { + "no proxy env vars".to_string() + } else { + "proxy env vars present".to_string() + } + }) + .unwrap_or_else(|| check.summary.clone()) +} + +fn websocket_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "handshake result") + .or_else(|| detail::detail_value(check, "handshake status")); + let timeout = detail::detail_value(check, "connect timeout") + .map(|value| value.replace("000 ms", "s").replace(" ms", "ms")); + match (status, timeout) { + (Some(status), Some(timeout)) => format!("connected ({status}) · {timeout} timeout"), + _ => check.summary.clone(), + } +} + +fn app_server_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "status"); + let mode = detail::detail_value(check, "mode"); + match (status, mode) { + (Some(status), Some(mode)) => format!("{status} ({mode} mode)"), + _ => check.summary.clone(), + } +} + +fn separator(options: HumanOutputOptions) -> String { + if options.ascii { + "-".repeat(SEPARATOR_WIDTH) + } else { + "─".repeat(SEPARATOR_WIDTH) + } +} + +fn highlight_actions(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&highlight_flags(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&highlight_flags(part, options)); + } + in_code = !in_code; + } + out +} + +fn highlight_flags(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + if bare.starts_with("--") { + let highlighted = cyan(bare, options); + format!("{highlighted}{punctuation}{suffix}") + } else { + token.to_string() + } + }) + .collect() +} + +pub(super) fn redact_detail(detail: &str) -> String { + let lower = detail.to_ascii_lowercase(); + let label = lower.split(':').next().unwrap_or_default(); + if label.contains("env var") { + return redact_urls(detail); + } + if detail + .split_once(": ") + .is_some_and(|(_, value)| is_safe_presence_value(value)) + { + return redact_urls(detail); + } + + let secret_keys = [ + "openai_api_key", + "codex_api_key", + "codex_access_token", + "authorization", + "bearer_token", + "token", + "secret", + ]; + if secret_keys.iter().any(|key| lower.contains(key)) { + let name = detail.split(':').next().unwrap_or(detail); + format!("{name}: ") + } else { + redact_urls(detail) + } +} + +fn is_safe_presence_value(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "false" | "yes" | "no" | "present" | "absent" | "missing" | "not set" + ) +} + +fn redact_urls(detail: &str) -> String { + detail + .split_inclusive(char::is_whitespace) + .map(redact_url_token) + .collect() +} + +fn redact_url_token(token: &str) -> String { + let Some(scheme_end) = token.find("://") else { + return token.to_string(); + }; + let mut suffix_start = token.len(); + while suffix_start > scheme_end + 3 + && matches!( + token.as_bytes()[suffix_start - 1], + b' ' | b'\t' | b'\n' | b'\r' | b'.' | b',' | b';' | b':' | b')' | b']' + ) + { + suffix_start -= 1; + } + + let (body, suffix) = token.split_at(suffix_start); + let scheme_prefix_end = scheme_end + 3; + let rest = &body[scheme_prefix_end..]; + let authority_end = rest + .find(['/', '?', '#']) + .map(|index| scheme_prefix_end + index) + .unwrap_or(body.len()); + let authority = &body[scheme_prefix_end..authority_end]; + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let path = &body[authority_end..]; + let path = path + .find(['?', '#']) + .map(|index| &path[..index]) + .unwrap_or(path); + let path = redact_url_path(path); + format!( + "{}{}{}{}", + &body[..scheme_prefix_end], + authority, + path, + suffix + ) +} + +fn redact_url_path(path: &str) -> String { + let mut segments = path.split('/').filter(|segment| !segment.is_empty()); + let Some(first_segment) = segments.next() else { + return path.to_string(); + }; + if segments.next().is_some() { + format!("/{first_segment}/") + } else { + path.to_string() + } +} + +#[derive(Default)] +struct StatusCounts { + ok: usize, + idle: usize, + notes: usize, + warning: usize, + fail: usize, +} + +impl StatusCounts { + fn from_report(report: &DoctorReport, notes: usize) -> Self { + let mut counts = Self { + notes, + ..Self::default() + }; + for check in &report.checks { + match display_status(check) { + DisplayStatus::Ok => counts.ok += 1, + DisplayStatus::Idle => counts.idle += 1, + DisplayStatus::Warning => counts.warning += 1, + DisplayStatus::Fail => counts.fail += 1, + DisplayStatus::Update | DisplayStatus::Note => {} + } + } + counts + } +} + +fn bold(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.bold().to_string() + } else { + text.to_string() + } +} + +fn dim(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.dimmed().to_string() + } else { + text.to_string() + } +} + +fn very_dim(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 238, options) +} + +fn detail_label(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 240, options) +} + +fn detail_value(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + style_detail_text(text, options) +} + +fn style_detail_text(text: &str, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&style_detail_plain_text(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&style_detail_plain_text(part, options)); + } + in_code = !in_code; + } + out +} + +fn style_detail_plain_text(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| style_detail_token(token, options)) + .collect() +} + +fn style_detail_token(token: &str, options: HumanOutputOptions) -> String { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + let styled = style_detail_bare_token(bare, options); + format!("{styled}{punctuation}{suffix}") +} + +fn style_detail_bare_token(bare: &str, options: HumanOutputOptions) -> String { + if bare.is_empty() { + return String::new(); + } + if bare == "" { + return color256(&bare.italic().to_string(), /*code*/ 244, options); + } + if bare.contains("(missing)") || detail::is_falsy(bare) { + return color256(bare, /*code*/ 240, options); + } + if let Some((label, value)) = bare.split_once(':') + && detail::is_falsy(value) + { + return format!("{label}:{}", color256(value, /*code*/ 240, options)); + } + if bare == "ok" { + return green(bare, options); + } + if bare.starts_with("--") || looks_copyable(bare) { + return cyan(bare, options); + } + if matches!(bare, "B" | "KB" | "MB" | "GB" | "TB" | "files" | "file") { + return dim(bare, options); + } + bare.to_string() +} + +fn green(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 10, options) +} + +fn amber(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 220, options) +} + +fn orange(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 214, options) +} + +fn red(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 196, options) +} + +fn cyan(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 117, options) +} + +fn color256(text: &str, code: u8, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.color(XtermColors::from(code)).to_string() + } else { + text.to_string() + } +} + +fn looks_copyable(text: &str) -> bool { + text.starts_with("http://") + || text.starts_with("https://") + || text.starts_with("wss://") + || text.starts_with("~/") + || text.starts_with('/') + || text.starts_with("./") + || text.starts_with("../") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn detailed_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn summary_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn detailed_all_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: true, + ascii: false, + color_enabled: false, + } + } + + fn detailed_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: true, + } + } + + fn sample_report() -> DoctorReport { + let checks = vec![ + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + "running local build on darwin-arm64", + ), + DoctorCheck::new( + "installation", + "install", + CheckStatus::Ok, + "installation looks consistent", + ), + DoctorCheck::new( + "runtime.search", + "search", + CheckStatus::Ok, + "search is OK (bundled)", + ), + DoctorCheck::new( + "terminal.env", + "terminal", + CheckStatus::Warning, + "narrow terminal", + ), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ), + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "token expired", + ) + .detail("OPENAI_API_KEY: present") + .remediation("Run `codex login`."), + DoctorCheck::new( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ), + DoctorCheck::new( + "network.env", + "network", + CheckStatus::Ok, + "network environment readable", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ), + ]; + DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Fail, + codex_version: "0.0.0".to_string(), + checks, + } + } + + #[test] + fn render_human_report_includes_details_by_default_without_color() { + let rendered = render_human_report(&sample_report(), detailed_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + managed by npm: no · bun: no · package root — + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + OPENAI_API_KEY present + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +--summary compact output --all expand truncated lists +--json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_summary_output_without_color() { + let rendered = render_human_report(&sample_report(), summary_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_ascii_output() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: true, + color_enabled: false, + }, + ); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + [!!] terminal narrow terminal + [XX] auth token expired - Run `codex login`. +------------------------------------------------------------- + +Environment + [ok] runtime running local build on darwin-arm64 + [ok] install consistent + [ok] search search is OK (bundled) + [!!] terminal narrow terminal + [ok] state state paths inspectable + +Configuration + [XX] auth token expired - Run `codex login`. + +Updates + [ok] updates update configuration is locally consistent + +Connectivity + [ok] network network environment readable + [ok] websocket Responses WebSocket handshake succeeded + [ok] reachability active provider endpoints are reachable over HTTP + +Background Server + [ok] app-server background server is not running + +{} +9 ok | 2 notes | 1 warn | 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "-".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_includes_redacted_details() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + }, + ); + assert!(rendered.contains(" OPENAI_API_KEY present")); + } + + #[test] + fn render_human_report_explains_terminal_warning_issue() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "terminal.env", + "terminal", + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .detail("terminal: Ghostty") + .detail("terminal version: 1.3.1") + .detail("terminal size: 79x26") + .issue( + super::super::DoctorIssue::new( + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .expected(">= 80 columns") + .remedy("resize the window to at least 80 columns") + .field("terminal size"), + ), + ], + }; + + let rendered = render_human_report(&report, detailed_no_color_unicode_options()); + + assert!( + rendered.contains("⚠ terminal width 79 cols - output may wrap (recommended >=80)") + ); + assert!(rendered.contains("▸ terminal size 79x26 (expected >= 80 columns)")); + assert!(rendered.contains("→ resize the window to at least 80 columns")); + assert!(!rendered.contains("⚠ terminal Ghostty 1.3.1")); + } + + #[test] + fn render_human_report_promotes_notes_without_changing_statuses() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Warning, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ) + .detail("latest version status: newer version is available") + .detail("latest version: 0.130.0") + .detail("dismissed version: 0.128.0"), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ) + .detail("active rollout files: 1515 files, 2702146365 total bytes, 1783594 average bytes"), + DoctorCheck::new( + "sandbox.helpers", + "sandbox", + CheckStatus::Ok, + "sandbox configuration is readable", + ) + .detail("filesystem sandbox: danger-full-access") + .detail("network sandbox: restricted") + .detail("approval policy: Never"), + DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Warning, + "MCP configuration has optional issues", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ) + .detail("auth mode: chatgpt"), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ) + .detail("reachability mode: API key auth"), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ) + .detail("status: not running") + .detail("mode: ephemeral"), + ], + }; + + let rendered = render_human_report(&report, summary_no_color_unicode_options()); + + assert!(rendered.contains("Notes\n ↑ updates")); + assert!(rendered.contains("0.130.0 available (current 0.0.0, dismissed 0.128.0)")); + assert!(rendered.contains("⚠ rollouts")); + assert!(rendered.contains("⚠ sandbox")); + assert!(rendered.contains("⚠ mcp")); + assert!(rendered.contains( + "⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode" + )); + assert!(rendered.contains("○ app-server not running (ephemeral mode)")); + assert!(rendered.contains("5 ok · 1 idle · 5 notes · 1 warn · 0 fail degraded")); + } + + #[test] + fn render_human_report_expands_feature_flags_with_all() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Ok, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new("config.load", "config", CheckStatus::Ok, "config loaded") + .detail("model: gpt-5.5") + .detail("model provider: openai") + .detail("feature flags enabled: 3") + .detail("enabled feature flags: shell_tool, memories, goals") + .detail("feature flag overrides: memories=true"), + ], + }; + + let compact = render_human_report(&report, detailed_no_color_unicode_options()); + let expanded = render_human_report(&report, detailed_all_no_color_unicode_options()); + + assert!(!compact.contains("enabled flags")); + assert!( + compact.contains( + "feature flags 3 enabled · 1 overridden (full list with --all)" + ) + ); + assert!(expanded.contains("enabled flags shell_tool, memories, goals")); + } + + #[test] + fn detail_value_colors_inline_statuses_and_low_signal_values() { + let rendered = detail_value( + "npm: no · commit unknown · integrity ok · ~/code/codex/target/debug/codex · ", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("npm: \u{1b}[38;5;240mno")); + assert!(rendered.contains("\u{1b}[38;5;240munknown")); + assert!(rendered.contains("\u{1b}[38;5;10mok")); + assert!(rendered.contains("\u{1b}[38;5;117m~/code/codex/target/debug/codex")); + assert!(rendered.contains("\u{1b}[38;5;244m")); + } + + #[test] + fn update_note_emphasizes_available_version_and_dims_context() { + let rendered = style_update_note_summary( + "0.130.0 available (current 0.0.0, dismissed 0.128.0)", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("\u{1b}[38;5;220m0.130.0 available")); + assert!(rendered.contains("\u{1b}[2m(current 0.0.0, dismissed 0.128.0)")); + } + + #[test] + fn redact_detail_sanitizes_urls() { + let redacted = redact_detail( + "reachability failed: https://user:pass@example.com/mcp?x=abc#frag (connect failed)", + ); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp (connect failed)" + ); + } + + #[test] + fn redact_detail_sanitizes_secret_url_path_segments() { + let redacted = redact_detail("reachability failed: https://example.com/mcp/abc123xyz"); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp/" + ); + } + + #[test] + fn redact_detail_preserves_env_var_names() { + assert_eq!( + redact_detail("auth env vars present: OPENAI_API_KEY, CODEX_API_KEY"), + "auth env vars present: OPENAI_API_KEY, CODEX_API_KEY" + ); + } + + #[test] + fn redact_detail_preserves_secret_presence_booleans() { + assert_eq!( + redact_detail("stored ChatGPT tokens: true"), + "stored ChatGPT tokens: true" + ); + assert_eq!( + redact_detail("stored ChatGPT tokens: false"), + "stored ChatGPT tokens: false" + ); + } + + #[test] + fn render_human_report_can_emit_color() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: true, + }, + ); + assert!(rendered.contains("\u{1b}[")); + } +} diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs new file mode 100644 index 0000000000..4b5f49991b --- /dev/null +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -0,0 +1,648 @@ +//! Converts raw doctor detail strings into human-oriented rows. +//! +//! Checks intentionally store details as simple redacted `label: value` strings +//! so JSON serialization and human rendering share the same source data. This +//! module owns the presentation-only transformations: collapsing noisy booleans, +//! truncating long paths for terminal output, grouping repeated values, and +//! keeping the `--all` expansion behavior out of check construction. + +use std::collections::BTreeSet; +use std::env; + +use super::DoctorCheck; +use super::HumanOutputOptions; +use super::redact_detail; + +const LIST_LIMIT: usize = 7; +const PATH_LIMIT: usize = 48; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum HumanDetail { + Row { + label: String, + value: String, + expected: Option, + }, + Continuation(String), + Bullet(String), + Remedy(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ParsedDetail { + label: String, + value: String, +} + +pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec { + let parsed = parsed_details(check); + let details = match check.category.as_str() { + "runtime" => runtime_details(&parsed), + "install" => install_details(&parsed, options), + "config" => config_details(&parsed, options), + "state" => state_details(&parsed), + _ => generic_details(&parsed), + }; + let mut details = details + .into_iter() + .map(|detail| attach_issue_metadata(detail, check)) + .map(|detail| humanize_detail(detail, options)) + .collect::>(); + details.extend(issue_remedies(check)); + details +} + +pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option { + parsed_details(check) + .into_iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value) +} + +pub(super) fn rollout_summary(value: &str) -> Option { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, rest) = rest.split_once(" total bytes, ")?; + let (average_bytes, _) = rest.split_once(" average bytes")?; + let files = files.trim().parse::().ok()?; + let total_bytes = total_bytes.trim().parse::().ok()?; + let average_bytes = average_bytes.trim().parse::().ok()?; + Some(format!( + "{} files · {} (avg {})", + format_count(files), + format_bytes(total_bytes), + format_bytes(average_bytes) + )) +} + +pub(super) fn rollout_files_and_bytes(value: &str) -> Option<(u64, u64)> { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, _) = rest.split_once(" total bytes, ")?; + Some(( + files.trim().parse::().ok()?, + total_bytes.trim().parse::().ok()?, + )) +} + +pub(super) fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + + let bytes = bytes as f64; + if bytes >= GIB { + format!("{:.2} GB", bytes / GIB) + } else if bytes >= MIB { + format!("{:.2} MB", bytes / MIB) + } else if bytes >= KIB { + format!("{:.2} KB", bytes / KIB) + } else { + format!("{} B", bytes as u64) + } +} + +pub(super) fn format_count(count: u64) -> String { + let mut digits = count.to_string(); + let mut out = String::new(); + while digits.len() > 3 { + let tail = digits.split_off(digits.len() - 3); + if out.is_empty() { + out = tail; + } else { + out = format!("{tail},{out}"); + } + } + if out.is_empty() { + digits + } else { + format!("{digits},{out}") + } +} + +fn parsed_details(check: &DoctorCheck) -> Vec { + check + .details + .iter() + .map(|detail| redact_detail(detail)) + .map(|detail| { + detail + .split_once(": ") + .map(|(label, value)| ParsedDetail { + label: label.to_string(), + value: value.to_string(), + }) + .unwrap_or_else(|| ParsedDetail { + label: String::new(), + value: detail, + }) + }) + .collect() +} + +fn runtime_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "version", "version"); + push_row_if_present(&mut out, parsed, "install method", "install method"); + push_row_if_present(&mut out, parsed, "commit", "commit"); + push_row_if_present(&mut out, parsed, "current executable", "executable"); + push_remaining( + &mut out, + parsed, + &[ + "version", + "platform", + "install method", + "commit", + "current executable", + ], + &[], + ); + out +} + +fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "install context", "context"); + if parsed.iter().any(|detail| { + detail.value == "ignored inherited package-manager launch env for cargo-built binary" + }) { + out.push(HumanDetail::Bullet( + "ignored inherited package-manager launch env for cargo-built binary".to_string(), + )); + } + + let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); + let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); + let package_root = value(parsed, "managed package root").unwrap_or("not set"); + out.push(HumanDetail::Row { + label: "managed by".to_string(), + value: format!( + "npm: {} · bun: {} · package root {}", + yes_no(managed_by_npm), + yes_no(managed_by_bun), + if is_falsy(package_root) { + "—".to_string() + } else { + package_root.to_string() + } + ), + expected: None, + }); + + let path_entries = numbered_values(parsed, "PATH codex #"); + if !path_entries.is_empty() { + let total = path_entries.len(); + let shown = if options.show_all { + total + } else { + total.min(3) + }; + out.push(HumanDetail::Row { + label: format!("PATH entries ({total})"), + value: path_entries[0].clone(), + expected: None, + }); + out.extend( + path_entries + .iter() + .skip(1) + .take(shown.saturating_sub(1)) + .cloned() + .map(HumanDetail::Continuation), + ); + if shown < total { + out.push(HumanDetail::Continuation( + "… (full list with --all)".to_string(), + )); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "current executable", + "install context", + "managed by npm", + "managed by bun", + "managed package root", + "PATH codex entries", + ], + &["PATH codex #"], + ); + out +} + +fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + if let Some(model) = value(parsed, "model") { + let value = value(parsed, "model provider").map_or_else( + || model.to_string(), + |provider| format!("{model} · {provider}"), + ); + out.push(HumanDetail::Row { + label: "model".to_string(), + value, + expected: None, + }); + } + push_row_if_present(&mut out, parsed, "cwd", "cwd"); + push_row_if_present(&mut out, parsed, "config.toml", "config.toml"); + push_row_if_present(&mut out, parsed, "config.toml parse", "config.toml parse"); + push_row_if_present(&mut out, parsed, "config.toml read", "config.toml read"); + push_row_if_present(&mut out, parsed, "mcp servers", "MCP servers"); + push_feature_flags(&mut out, parsed, options); + + for detail in parsed + .iter() + .filter(|detail| detail.label == "legacy feature flag") + { + out.push(HumanDetail::Row { + label: "legacy alias".to_string(), + value: detail.value.clone(), + expected: None, + }); + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "cwd", + "model", + "model provider", + "log dir", + "sqlite home", + "mcp servers", + "feature flags enabled", + "enabled feature flags", + "feature flag overrides", + "legacy feature flag", + "config.toml", + "config.toml parse", + "config.toml read", + ], + &[], + ); + out +} + +fn state_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "CODEX_HOME", "CODEX_HOME"); + push_row_if_present(&mut out, parsed, "log dir", "log dir"); + push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home"); + push_database_row(&mut out, parsed, "state DB"); + push_database_row(&mut out, parsed, "log DB"); + + for (source, label) in [ + ("active rollout files", "active rollouts"), + ("archived rollout files", "archived rollouts"), + ] { + if let Some(value) = value(parsed, source) { + out.push(HumanDetail::Row { + label: label.to_string(), + value: rollout_summary(value).unwrap_or_else(|| value.to_string()), + expected: None, + }); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "log dir", + "sqlite home", + "state DB", + "log DB", + "state DB integrity", + "log DB integrity", + "active rollout files", + "archived rollout files", + ], + &[], + ); + out +} + +fn generic_details(parsed: &[ParsedDetail]) -> Vec { + parsed + .iter() + .map(|detail| { + if detail.label.is_empty() { + HumanDetail::Bullet(detail.value.clone()) + } else { + HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + } + } + }) + .collect() +} + +fn push_feature_flags( + out: &mut Vec, + parsed: &[ParsedDetail], + options: HumanOutputOptions, +) { + let enabled_count = value(parsed, "feature flags enabled") + .and_then(|value| value.parse::().ok()) + .unwrap_or_default(); + let overrides = list_items(value(parsed, "feature flag overrides").unwrap_or("none")); + let override_count = overrides.len(); + let hint = if !options.show_all && enabled_count > 0 { + " (full list with --all)" + } else { + "" + }; + out.push(HumanDetail::Row { + label: "feature flags".to_string(), + value: format!("{enabled_count} enabled · {override_count} overridden{hint}"), + expected: None, + }); + + if !overrides.is_empty() { + push_list_row(out, "overrides", &override_names(&overrides), options); + } + if options.show_all { + let enabled = list_items(value(parsed, "enabled feature flags").unwrap_or("none")); + if !enabled.is_empty() { + push_list_row(out, "enabled flags", &enabled, options); + } + } +} + +fn push_list_row( + out: &mut Vec, + label: &str, + items: &[String], + options: HumanOutputOptions, +) { + let limit = if options.show_all { + items.len() + } else { + items.len().min(LIST_LIMIT) + }; + let mut value = items + .iter() + .take(limit) + .cloned() + .collect::>() + .join(", "); + if limit < items.len() { + value.push_str(", … (full list with --all)"); + } + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_database_row(out: &mut Vec, parsed: &[ParsedDetail], label: &str) { + let Some(path) = value(parsed, label) else { + return; + }; + let integrity = value(parsed, &format!("{label} integrity")); + let value = integrity.map_or_else( + || path.to_string(), + |integrity| format!("{path} · integrity {integrity}"), + ); + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_row_if_present( + out: &mut Vec, + parsed: &[ParsedDetail], + source_label: &str, + display_label: &str, +) { + if let Some(value) = value(parsed, source_label) { + out.push(HumanDetail::Row { + label: display_label.to_string(), + value: value.to_string(), + expected: None, + }); + } +} + +fn push_remaining( + out: &mut Vec, + parsed: &[ParsedDetail], + consumed_labels: &[&str], + consumed_prefixes: &[&str], +) { + for detail in parsed { + if detail.value == "ignored inherited package-manager launch env for cargo-built binary" { + continue; + } + if consumed_labels.contains(&detail.label.as_str()) + || consumed_prefixes + .iter() + .any(|prefix| detail.label.starts_with(prefix)) + { + continue; + } + if detail.label.is_empty() { + out.push(HumanDetail::Bullet(detail.value.clone())); + } else { + out.push(HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + }); + } + } +} + +fn humanize_detail(detail: HumanDetail, options: HumanOutputOptions) -> HumanDetail { + match detail { + HumanDetail::Row { + label, + value, + expected, + } => HumanDetail::Row { + label, + value: humanize_value(&value, options), + expected, + }, + HumanDetail::Continuation(value) => { + HumanDetail::Continuation(humanize_value(&value, options)) + } + HumanDetail::Bullet(value) => HumanDetail::Bullet(humanize_value(&value, options)), + HumanDetail::Remedy(value) => HumanDetail::Remedy(value), + } +} + +fn attach_issue_metadata(detail: HumanDetail, check: &DoctorCheck) -> HumanDetail { + let HumanDetail::Row { + label, + value, + expected, + } = detail + else { + return detail; + }; + let expected = expected.or_else(|| issue_expected_for_label(check, &label)); + HumanDetail::Row { + label, + value, + expected, + } +} + +fn issue_expected_for_label(check: &DoctorCheck, label: &str) -> Option { + check + .issues + .iter() + .find(|issue| { + issue + .fields + .iter() + .any(|field| display_label(field) == label || field == label) + }) + .and_then(|issue| issue.expected.clone()) +} + +fn issue_remedies(check: &DoctorCheck) -> Vec { + let mut seen = BTreeSet::new(); + check + .issues + .iter() + .filter_map(|issue| issue.remedy.as_ref()) + .filter(|remedy| seen.insert((*remedy).clone())) + .cloned() + .map(HumanDetail::Remedy) + .collect() +} + +fn humanize_value(value: &str, _options: HumanOutputOptions) -> String { + if looks_like_path(value) { + return shorten_path_prefix(value); + } + if let Some(timestamp) = humanize_timestamp(value) { + return timestamp; + } + value.to_string() +} + +fn humanize_timestamp(value: &str) -> Option { + if value.len() < 17 || !value.ends_with('Z') { + return None; + } + let (date, time) = value.split_once('T')?; + let hour_minute = time.get(..5)?; + Some(format!("{date} {hour_minute} UTC")) +} + +fn shorten_path_prefix(value: &str) -> String { + let (path, suffix) = value.split_once(" (").map_or_else( + || (value, String::new()), + |(path, suffix)| (path, format!(" ({suffix}")), + ); + let home_shortened = home_shortened_path(path); + let shortened = middle_truncate(&home_shortened, PATH_LIMIT); + format!("{shortened}{suffix}") +} + +fn home_shortened_path(path: &str) -> String { + let Some(home) = env::var_os("HOME").and_then(|home| home.into_string().ok()) else { + return path.to_string(); + }; + if path == home { + "~".to_string() + } else { + path.strip_prefix(&format!("{home}/")) + .map_or_else(|| path.to_string(), |tail| format!("~/{tail}")) + } +} + +fn middle_truncate(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + let head_len = max_chars / 2; + let tail_len = max_chars.saturating_sub(head_len + 1); + let head = value.chars().take(head_len).collect::(); + let tail = value + .chars() + .rev() + .take(tail_len) + .collect::() + .chars() + .rev() + .collect::(); + format!("{head}…{tail}") +} + +fn looks_like_path(value: &str) -> bool { + value.starts_with('/') + || value.starts_with("~/") + || value.starts_with("./") + || value.starts_with("../") +} + +fn numbered_values(parsed: &[ParsedDetail], prefix: &str) -> Vec { + parsed + .iter() + .filter(|detail| detail.label.starts_with(prefix)) + .map(|detail| detail.value.clone()) + .collect() +} + +fn value<'a>(parsed: &'a [ParsedDetail], label: &str) -> Option<&'a str> { + parsed + .iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value.as_str()) +} + +fn display_label(label: &str) -> String { + match label { + "codex-linux-sandbox helper" => "linux helper", + "optional reachability failed" => "optional reachability", + "check for update on startup" => "startup update check", + other => other, + } + .to_string() +} + +fn list_items(value: &str) -> Vec { + if is_falsy(value) { + return Vec::new(); + } + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +fn override_names(items: &[String]) -> Vec { + items + .iter() + .map(|item| item.split_once('=').map_or(item.as_str(), |(name, _)| name)) + .map(str::to_string) + .collect() +} + +fn yes_no(value: &str) -> &'static str { + if value == "true" { "yes" } else { "no" } +} + +pub(super) fn is_falsy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "false" | "none" | "not set" | "unknown" | "missing" | "absent" | "no" | "—" | "-" + ) +} diff --git a/codex-rs/cli/src/doctor/progress.rs b/codex-rs/cli/src/doctor/progress.rs new file mode 100644 index 0000000000..ba8d65a357 --- /dev/null +++ b/codex-rs/cli/src/doctor/progress.rs @@ -0,0 +1,139 @@ +use std::io; +use std::io::IsTerminal; +use std::io::Write; +use std::sync::Mutex; +use std::time::Duration; + +use super::CheckStatus; + +/// Receives check lifecycle events while doctor builds the final report. +/// +/// Progress implementations must not write to stdout. The final report owns +/// stdout so JSON and redirected human reports stay clean. +pub(super) trait DoctorProgress: Send + Sync { + fn begin(&self, label: &'static str); + fn heartbeat(&self, label: &'static str, elapsed: Duration); + fn finish(&self, label: &'static str, status: CheckStatus); + fn settle(&self); +} + +/// Selects the progress implementation for the current output mode. +/// +/// JSON output is always quiet so stdout remains valid JSON. Human output uses a +/// transient stderr line only for interactive terminals, then clears it before +/// the final report is printed. +pub(super) fn doctor_progress(json: bool) -> std::sync::Arc { + if should_show_progress( + json, + std::env::var("TERM").ok().as_deref(), + io::stderr().is_terminal(), + ) { + std::sync::Arc::new(StderrProgress::default()) + } else { + std::sync::Arc::new(QuietProgress) + } +} + +fn should_show_progress(json: bool, term: Option<&str>, stderr_is_tty: bool) -> bool { + !json && stderr_is_tty && term != Some("dumb") +} + +struct QuietProgress; + +impl DoctorProgress for QuietProgress { + fn begin(&self, _label: &'static str) {} + + fn heartbeat(&self, _label: &'static str, _elapsed: Duration) {} + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) {} +} + +#[derive(Default)] +struct StderrProgress { + state: Mutex, +} + +#[derive(Default)] +struct StderrProgressState { + wrote_line: bool, +} + +impl StderrProgress { + fn render(&self, message: String) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K{message}"); + let _ = stderr.flush(); + state.wrote_line = true; + } +} + +impl DoctorProgress for StderrProgress { + fn begin(&self, label: &'static str) { + self.render(format!("Checking {label}...")); + } + + fn heartbeat(&self, label: &'static str, elapsed: Duration) { + self.render(format!("Still checking {label}... {}s", elapsed.as_secs())); + } + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if !state.wrote_line { + return; + } + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K"); + let _ = stderr.flush(); + state.wrote_line = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_is_quiet_for_json() { + assert!(!should_show_progress( + /*json*/ true, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_quiet_for_non_tty() { + assert!(!should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ false, + )); + } + + #[test] + fn progress_is_quiet_for_dumb_terminal() { + assert!(!should_show_progress( + /*json*/ false, + Some("dumb"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_shown_for_human_tty_output() { + assert!(should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } +} diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs new file mode 100644 index 0000000000..96e806762c --- /dev/null +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -0,0 +1,141 @@ +//! Captures how this Codex process was launched. +//! +//! Runtime diagnostics answer provenance questions that are hard to infer from +//! user reports: which binary is running, which install channel it resembles, +//! which platform it targets, and whether the search command comes from bundled +//! standalone resources or from PATH. + +use std::env; +use std::process::Command; + +use codex_install_context::InstallContext; + +use super::CheckStatus; +use super::DoctorCheck; +use super::describe_install_context; +use super::doctor_install_context; +use super::push_path_detail; + +/// Builds the process provenance row for the current Codex executable. +/// +/// This check is informational and should not fail on its own; inconsistent +/// install state is reported by the installation and update checks instead. +pub(super) fn runtime_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let os = env::consts::OS; + let arch = env::consts::ARCH; + let platform = format!("{os}-{arch}"); + let install_method = install_method_name(&install_context); + let mut details = vec![ + format!("version: {}", env!("CARGO_PKG_VERSION")), + format!("platform: {platform}"), + format!( + "install method: {}", + describe_install_context(&install_context) + ), + format!("commit: {}", build_commit()), + ]; + push_path_detail(&mut details, "current executable", current_exe.as_deref()); + + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + format!("running {install_method} on {platform}"), + ) + .details(details) +} + +/// Verifies that the search command selected by the install context is usable. +/// +/// Standalone installs should point at a bundled ripgrep binary, while local or +/// package-managed installs usually resolve rg from PATH. A warning here means +/// features that depend on file search may degrade even when the CLI launches. +pub(super) fn search_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let rg_command = install_context.rg_command(); + let provider = search_provider(&install_context); + let mut details = vec![ + format!("search command: {}", rg_command.display()), + format!("search provider: {provider}"), + ]; + + let status = if rg_command.components().count() > 1 { + match std::fs::metadata(&rg_command) { + Ok(metadata) if metadata.is_file() => { + details.push("search command readiness: file exists".to_string()); + CheckStatus::Ok + } + Ok(_) => { + details.push("search command readiness: path is not a file".to_string()); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + } else { + match Command::new(&rg_command).arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("rg version unknown") + .to_string(); + details.push(format!("search command readiness: {version}")); + CheckStatus::Ok + } + Ok(output) => { + details.push(format!( + "search command readiness: exited with status {}", + output.status + )); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + }; + + let summary = match status { + CheckStatus::Ok => format!("search is OK ({provider})"), + CheckStatus::Warning => "search command could not be verified".to_string(), + CheckStatus::Fail => unreachable!(), + }; + let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); + if status != CheckStatus::Ok { + check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + } + check +} + +fn install_method_name(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { .. } => "standalone", + InstallContext::Npm => "npm", + InstallContext::Bun => "bun", + InstallContext::Brew => "brew", + InstallContext::Other => "local build", + } +} + +fn search_provider(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { + resources_dir: Some(resources_dir), + .. + } if context.rg_command().starts_with(resources_dir) => "bundled", + _ => "system", + } +} + +fn build_commit() -> &'static str { + option_env!("CODEX_BUILD_COMMIT") + .or(option_env!("GIT_COMMIT")) + .unwrap_or("unknown") +} diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs new file mode 100644 index 0000000000..3d728fc07b --- /dev/null +++ b/codex-rs/cli/src/doctor/updates.rs @@ -0,0 +1,227 @@ +//! Diagnoses whether Codex update paths target the running installation. +//! +//! Update diagnostics combine cached version metadata, install-channel hints, +//! and bounded latest-version probes. For npm-managed launches, this module also +//! verifies that npm install -g would update the package root that launched the +//! current process, which catches PATH and prefix mismatches before the user runs +//! an update command. + +use std::path::Path; + +use codex_core::config::Config; +use codex_install_context::InstallContext; +use serde::Deserialize; + +use super::CheckStatus; +use super::DoctorCheck; +use super::NpmRootCheck; +use super::doctor_install_context; +use super::doctor_managed_by_npm; +use super::npm_global_root_check; +use super::run_command; + +const VERSION_FILE_NAME: &str = "version.json"; +const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; +const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json"; + +/// Builds the update-health row for the current installation. +/// +/// Network failures while fetching latest-version metadata degrade the row to a +/// warning instead of failing doctor outright; update freshness is useful +/// support context but should not mask more direct install/config failures. +pub(super) fn updates_check(config: &Config) -> DoctorCheck { + let current_exe = std::env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let mut details = vec![ + format!( + "check for update on startup: {}", + config.check_for_update_on_startup + ), + format!("update action: {}", update_action_label(&install_context)), + ]; + let version_file = config.codex_home.join(VERSION_FILE_NAME); + push_cached_version_details(&mut details, &version_file); + + let mut status = CheckStatus::Ok; + let mut summary = "update configuration is locally consistent".to_string(); + let mut remediation = None; + + if doctor_managed_by_npm(current_exe.as_deref()) { + match npm_global_root_check() { + NpmRootCheck::Match { package_root } => { + details.push(format!("npm update target: {}", package_root.display())); + } + NpmRootCheck::Mismatch { + running_package_root, + npm_package_root, + } => { + status = CheckStatus::Fail; + summary = "update would target a different npm install".to_string(); + details.push(format!( + "running package root: {}", + running_package_root.display() + )); + details.push(format!("npm package root: {}", npm_package_root.display())); + remediation = Some(format!( + "Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).", + running_package_root.display(), + npm_package_root.display() + )); + } + NpmRootCheck::MissingPackageRoot => { + status = status.max(CheckStatus::Warning); + summary = "npm update target could not be proven".to_string(); + remediation = Some( + "Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT." + .to_string(), + ); + } + NpmRootCheck::NpmUnavailable(error) => { + status = status.max(CheckStatus::Warning); + summary = "npm update target could not be inspected".to_string(); + details.push(format!("npm root -g failed: {error}")); + } + } + } + + match fetch_latest_version(&install_context) { + Ok(latest_version) => { + details.push(format!("latest version: {latest_version}")); + if is_newer(&latest_version, env!("CARGO_PKG_VERSION")) == Some(true) { + details.push("latest version status: newer version is available".to_string()); + } else { + details.push("latest version status: current version is not older".to_string()); + } + } + Err(err) => { + status = status.max(CheckStatus::Warning); + details.push(format!("latest version probe: {err}")); + } + } + + let mut check = DoctorCheck::new("updates.status", "updates", status, summary).details(details); + if let Some(remediation) = remediation { + check = check.remediation(remediation); + } + check +} + +fn push_cached_version_details(details: &mut Vec, version_file: &Path) { + details.push(format!("version cache: {}", version_file.display())); + match std::fs::read_to_string(version_file) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(info) => { + details.push(format!("cached latest version: {}", info.latest_version)); + if let Some(last_checked_at) = info.last_checked_at { + details.push(format!("last checked at: {last_checked_at}")); + } + if let Some(dismissed_version) = info.dismissed_version { + details.push(format!("dismissed version: {dismissed_version}")); + } + } + Err(err) => details.push(format!("version cache parse: {err}")), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push("version cache: missing".to_string()); + } + Err(err) => details.push(format!("version cache read: {err}")), + } +} + +fn update_action_label(context: &InstallContext) -> &'static str { + match context { + InstallContext::Npm => "npm install -g @openai/codex", + InstallContext::Bun => "bun install -g @openai/codex", + InstallContext::Brew => "brew upgrade --cask codex", + InstallContext::Standalone { .. } => "standalone installer", + InstallContext::Other => "manual or unknown", + } +} + +fn fetch_latest_version(context: &InstallContext) -> Result { + match context { + InstallContext::Brew => fetch_homebrew_cask_version(), + InstallContext::Npm + | InstallContext::Bun + | InstallContext::Standalone { .. } + | InstallContext::Other => fetch_latest_github_release_version(), + } +} + +fn fetch_latest_github_release_version() -> Result { + #[derive(Deserialize)] + struct ReleaseInfo { + tag_name: String, + } + + let info = http_get_json::(GITHUB_LATEST_RELEASE_URL)?; + info.tag_name + .strip_prefix("rust-v") + .map(str::to_string) + .ok_or_else(|| format!("failed to parse latest tag {}", info.tag_name)) +} + +fn fetch_homebrew_cask_version() -> Result { + #[derive(Deserialize)] + struct HomebrewCaskInfo { + version: String, + } + + http_get_json::(HOMEBREW_CASK_API_URL).map(|info| info.version) +} + +fn http_get_json(url: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let body = run_command("curl", ["-fsSL", "--max-time", "5", url])?; + serde_json::from_str::(&body).map_err(|err| err.to_string()) +} + +fn is_newer(latest: &str, current: &str) -> Option { + match (parse_version(latest), parse_version(current)) { + (Some(latest), Some(current)) => Some(latest > current), + (Some(_), None) | (None, Some(_)) | (None, None) => None, + } +} + +fn parse_version(value: &str) -> Option<(u64, u64, u64)> { + let mut parts = value.trim().split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + Some((major, minor, patch)) +} + +#[derive(Deserialize)] +struct VersionInfo { + latest_version: String, + #[serde(default)] + last_checked_at: Option, + #[serde(default)] + dismissed_version: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_compares_plain_semver() { + assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true)); + assert_eq!(is_newer("1.2.3", "1.2.4"), Some(false)); + assert_eq!(is_newer("1.2.3-beta.1", "1.2.2"), None); + } + + #[test] + fn update_action_labels_install_contexts() { + assert_eq!( + update_action_label(&InstallContext::Npm), + "npm install -g @openai/codex" + ); + assert_eq!( + update_action_label(&InstallContext::Other), + "manual or unknown" + ); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ef54aa52cd..00c798517c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -46,6 +46,7 @@ use supports_color::Stream; mod app_cmd; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; +mod doctor; mod marketplace_cmd; mod mcp_cmd; #[cfg(not(windows))] @@ -53,6 +54,7 @@ mod wsl_paths; use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; +use doctor::DoctorCommand; use codex_core::build_models_manager; use codex_core::config::ConfigBuilder; @@ -142,6 +144,9 @@ enum Subcommand { /// Update Codex to the latest version. Update, + /// Diagnose local Codex installation, config, auth, and runtime health. + Doctor(DoctorCommand), + /// Run commands within a Codex-provided sandbox. Sandbox(SandboxArgs), @@ -1163,6 +1168,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; run_update_command()?; } + Some(Subcommand::Doctor(doctor_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "doctor", + )?; + doctor::run_doctor( + doctor_cli, + root_config_overrides.clone(), + &interactive, + &arg0_paths, + ) + .await?; + } Some(Subcommand::Cloud(mut cloud_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1688,7 +1707,8 @@ fn unsupported_subcommand_name_for_strict_config( | Some(Subcommand::Review(_)) | Some(Subcommand::McpServer(_)) | Some(Subcommand::Resume(_)) - | Some(Subcommand::Fork(_)) => None, + | Some(Subcommand::Fork(_)) + | Some(Subcommand::Doctor(_)) => None, Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, Some(Subcommand::AppServer(app_server)) => { Some(app_server_subcommand_name(app_server.subcommand.as_ref())) diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index c16687ff28..70c52b9580 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -24,4 +24,6 @@ pub use realtime_websocket::session_update_session_json; pub use responses::ResponsesClient; pub use responses::ResponsesOptions; pub use responses_websocket::ResponsesWebsocketClient; +pub use responses_websocket::ResponsesWebsocketClose; pub use responses_websocket::ResponsesWebsocketConnection; +pub use responses_websocket::ResponsesWebsocketProbe; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index c5a682b328..f0a0019817 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -35,6 +35,7 @@ use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tracing::Instrument; use tracing::Span; use tracing::debug; @@ -321,12 +322,40 @@ impl ResponsesWebsocketConnection { } } +/// Client for connecting to the Responses WebSocket endpoint for one provider. pub struct ResponsesWebsocketClient { provider: Provider, auth: SharedAuthProvider, } +/// Close frame information captured by a handshake probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketClose { + /// WebSocket close code returned by the server. + pub code: String, + /// Human-readable close reason returned by the server. + pub reason: String, +} + +/// Result of a handshake-only Responses WebSocket probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketProbe { + /// Redacted by callers before displaying or serializing support reports. + pub url: String, + /// HTTP status returned by the successful WebSocket upgrade. + pub status: StatusCode, + /// Whether the server reported reasoning support in the upgrade response. + pub reasoning_included: bool, + /// Whether the server returned a model catalog ETag in the upgrade response. + pub models_etag_present: bool, + /// Whether the server returned a server-selected model in the upgrade response. + pub server_model_present: bool, + /// Close frame received immediately after upgrade, when one arrives quickly. + pub immediate_close: Option, +} + impl ResponsesWebsocketClient { + /// Creates a Responses WebSocket client for an already-resolved provider and auth source. pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self { Self { provider, auth } } @@ -353,7 +382,7 @@ impl ResponsesWebsocketClient { merge_request_headers(&self.provider.headers, extra_headers, default_headers); self.auth.add_auth_headers(&mut headers); - let (stream, server_reasoning_included, models_etag, server_model) = + let (stream, _status, server_reasoning_included, models_etag, server_model) = connect_websocket(ws_url, headers, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, @@ -364,6 +393,64 @@ impl ResponsesWebsocketClient { telemetry, )) } + + /// Opens a WebSocket connection long enough to validate the upgrade response. + /// + /// The probe uses the same URL construction, headers, authentication, TLS, + /// and custom-CA path as a real Responses WebSocket connection, but it does + /// not send a request frame. After the HTTP 101 upgrade succeeds, it waits + /// briefly for an immediate server close frame so diagnostics can distinguish + /// a usable connection from a policy rejection that closes right away. + pub async fn probe_handshake( + &self, + extra_headers: HeaderMap, + default_headers: HeaderMap, + immediate_close_timeout: Duration, + ) -> Result { + let ws_url = self + .provider + .websocket_url_for_path("responses") + .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; + + let mut headers = + merge_request_headers(&self.provider.headers, extra_headers, default_headers); + self.auth.add_auth_headers(&mut headers); + + let (mut stream, status, reasoning_included, models_etag, server_model) = + connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?; + let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next()) + .await + .ok() + .flatten() + .transpose() + .map_err(|err| { + ApiError::Stream(format!("failed to read websocket probe event: {err}")) + })? + .and_then(immediate_close_from_message); + + Ok(ResponsesWebsocketProbe { + url: ws_url.to_string(), + status, + reasoning_included, + models_etag_present: models_etag.is_some(), + server_model_present: server_model.is_some(), + immediate_close, + }) + } +} + +fn immediate_close_from_message(message: Message) -> Option { + let Message::Close(frame) = message else { + return None; + }; + frame.map(close_frame_to_probe) +} + +fn close_frame_to_probe(frame: CloseFrame) -> ResponsesWebsocketClose { + ResponsesWebsocketClose { + code: frame.code.to_string(), + reason: frame.reason.to_string(), + } } fn merge_request_headers( @@ -385,7 +472,7 @@ async fn connect_websocket( url: Url, headers: HeaderMap, turn_state: Option>>, -) -> Result<(WsStream, bool, Option, Option), ApiError> { +) -> Result<(WsStream, StatusCode, bool, Option, Option), ApiError> { ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); @@ -445,6 +532,7 @@ async fn connect_websocket( } Ok(( WsStream::new(stream), + response.status(), reasoning_included, models_etag, server_model, diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index d7017c8d3f..99470cac59 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -55,7 +55,9 @@ pub use crate::endpoint::RealtimeWebsocketWriter; pub use crate::endpoint::ResponsesClient; pub use crate::endpoint::ResponsesOptions; pub use crate::endpoint::ResponsesWebsocketClient; +pub use crate::endpoint::ResponsesWebsocketClose; pub use crate::endpoint::ResponsesWebsocketConnection; +pub use crate::endpoint::ResponsesWebsocketProbe; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 0adf864d64..9a8caf489a 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -27,6 +27,8 @@ pub use feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; pub use feedback_diagnostics::FeedbackDiagnostic; pub use feedback_diagnostics::FeedbackDiagnostics; +/// Filename used for the redacted `codex doctor --json` feedback attachment. +pub const DOCTOR_REPORT_ATTACHMENT_FILENAME: &str = "codex-doctor-report.json"; const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB const SENTRY_DSN: &str = "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; @@ -344,11 +346,37 @@ pub struct FeedbackAttachmentPath { pub attachment_filename_override: Option, } +/// In-memory attachment to include in a feedback upload. +/// +/// Use this for generated diagnostics that should not be materialized on disk, +/// such as the redacted doctor report. File-backed artifacts should use +/// `FeedbackAttachmentPath` so upload-time read failures can be logged and +/// skipped independently. +pub struct FeedbackAttachment { + /// Attachment filename shown in Sentry and in the feedback consent UI. + pub filename: String, + /// Optional MIME type for consumers that render or classify attachments. + pub content_type: Option, + /// Attachment bytes captured before the upload starts. + pub buffer: Vec, +} + +/// Inputs that control one feedback upload to Sentry. +/// +/// The caller is responsible for applying any user-consent gate before setting +/// `include_logs` or passing diagnostic attachments. This type only describes +/// what to upload once that decision has been made. pub struct FeedbackUploadOptions<'a> { pub classification: &'a str, pub reason: Option<&'a str>, pub tags: Option<&'a BTreeMap>, pub include_logs: bool, + /// Generated attachments that are already buffered and safe to upload. + /// + /// These are included after `codex-logs.log` and before path-backed rollout + /// attachments. They are only passed by the caller after any user consent + /// gate has decided logs and diagnostics should be uploaded. + pub extra_attachments: &'a [FeedbackAttachment], pub extra_attachment_paths: &'a [FeedbackAttachmentPath], pub session_source: Option, pub logs_override: Option>, @@ -444,6 +472,7 @@ impl FeedbackSnapshot { for attachment in self.feedback_attachments( options.include_logs, + options.extra_attachments, options.extra_attachment_paths, options.logs_override, ) { @@ -507,6 +536,7 @@ impl FeedbackSnapshot { fn feedback_attachments( &self, include_logs: bool, + extra_attachments: &[FeedbackAttachment], extra_attachment_paths: &[FeedbackAttachmentPath], logs_override: Option>, ) -> Vec { @@ -523,6 +553,13 @@ impl FeedbackSnapshot { }); } + attachments.extend(extra_attachments.iter().map(|attachment| Attachment { + buffer: attachment.buffer.clone(), + filename: attachment.filename.clone(), + content_type: attachment.content_type.clone(), + ty: None, + })); + if let Some(text) = self.feedback_diagnostics_attachment_text(include_logs) { attachments.push(Attachment { buffer: text.into_bytes(), @@ -704,6 +741,11 @@ mod tests { let attachments_with_diagnostics = snapshot_with_diagnostics.feedback_attachments( /*include_logs*/ true, + &[FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: b"{\"overallStatus\":\"ok\"}".to_vec(), + }], std::slice::from_ref(&extra_attachment_path), Some(vec![1]), ); @@ -715,6 +757,7 @@ mod tests { .collect::>(), vec![ "codex-logs.log", + DOCTOR_REPORT_ATTACHMENT_FILENAME, FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME, extra_filename.as_str() ] @@ -722,17 +765,21 @@ mod tests { assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]); assert_eq!( attachments_with_diagnostics[1].buffer, + b"{\"overallStatus\":\"ok\"}".to_vec() + ); + assert_eq!( + attachments_with_diagnostics[2].buffer, b"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - HTTPS_PROXY = https://example.com:443".to_vec() ); - assert_eq!(attachments_with_diagnostics[2].buffer, b"rollout".to_vec()); + assert_eq!(attachments_with_diagnostics[3].buffer, b"rollout".to_vec()); assert_eq!( - OsStr::new(attachments_with_diagnostics[2].filename.as_str()), + OsStr::new(attachments_with_diagnostics[3].filename.as_str()), OsStr::new(extra_filename.as_str()) ); let attachments_without_diagnostics = CodexFeedback::new() .snapshot(/*session_id*/ None) .with_feedback_diagnostics(FeedbackDiagnostics::default()) - .feedback_attachments(/*include_logs*/ true, &[], Some(vec![1])); + .feedback_attachments(/*include_logs*/ true, &[], &[], Some(vec![1])); assert_eq!( attachments_without_diagnostics diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 3c98a77978..43c02faae2 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -55,6 +55,7 @@ pub use runtime::ThreadGoalAccountingOutcome; pub use runtime::ThreadGoalUpdate; pub use runtime::logs_db_filename; pub use runtime::logs_db_path; +pub use runtime::sqlite_integrity_check; pub use runtime::state_db_filename; pub use runtime::state_db_path; pub use telemetry::DbTelemetry; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index a5748da9eb..d6ed7bfcc0 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -300,11 +300,30 @@ pub fn logs_db_path(codex_home: &Path) -> PathBuf { codex_home.join(logs_db_filename()) } +/// Run SQLite's built-in integrity check against an existing database file. +pub async fn sqlite_integrity_check(path: &Path) -> anyhow::Result> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .read_only(true) + .log_statements(LevelFilter::Off); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + let rows = sqlx::query_scalar::<_, String>("PRAGMA integrity_check") + .fetch_all(&pool) + .await?; + pool.close().await; + Ok(rows) +} + #[cfg(test)] mod tests { use super::StateRuntime; use super::open_state_sqlite; use super::runtime_state_migrator; + use super::sqlite_integrity_check; use super::state_db_path; use super::test_support::unique_temp_dir; use crate::DB_INIT_METRIC; @@ -380,6 +399,34 @@ mod tests { .expect("open sqlite pool") } + #[tokio::test] + async fn sqlite_integrity_check_reports_ok_for_valid_db() { + let codex_home = unique_temp_dir(); + tokio::fs::create_dir_all(&codex_home) + .await + .expect("create codex home"); + let path = state_db_path(codex_home.as_path()); + let pool = SqlitePool::connect_with( + SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .expect("open sqlite db"); + sqlx::query("CREATE TABLE sample (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create sample table"); + pool.close().await; + + let result = sqlite_integrity_check(&path) + .await + .expect("integrity check should run"); + + assert_eq!(result, vec!["ok".to_string()]); + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + #[tokio::test] async fn open_state_sqlite_tolerates_newer_applied_migrations() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/terminal-detection/src/lib.rs b/codex-rs/terminal-detection/src/lib.rs index 6474c189a5..c42733380d 100644 --- a/codex-rs/terminal-detection/src/lib.rs +++ b/codex-rs/terminal-detection/src/lib.rs @@ -64,7 +64,10 @@ pub enum Multiplexer { version: Option, }, /// zellij terminal multiplexer. - Zellij {}, + Zellij { + /// Zellij version string when ZELLIJ_VERSION is available. + version: Option, + }, } /// tmux client terminal identification captured via `tmux display-message`. @@ -207,7 +210,7 @@ impl TerminalInfo { /// Returns whether the active terminal multiplexer is Zellij. pub fn is_zellij(&self) -> bool { - matches!(self.multiplexer, Some(Multiplexer::Zellij {})) + matches!(self.multiplexer, Some(Multiplexer::Zellij { .. })) } } @@ -237,6 +240,11 @@ trait Environment { /// Returns tmux client details when available. fn tmux_client_info(&self) -> TmuxClientInfo; + + /// Returns Zellij version details when available. + fn zellij_version(&self) -> Option { + self.var_non_empty("ZELLIJ_VERSION") + } } /// Reads environment variables from the running process. @@ -257,6 +265,11 @@ impl Environment for ProcessEnvironment { fn tmux_client_info(&self) -> TmuxClientInfo { tmux_client_info() } + + fn zellij_version(&self) -> Option { + self.var_non_empty("ZELLIJ_VERSION") + .or_else(zellij_version_from_command) + } } /// Returns a sanitized terminal identifier for User-Agent strings. @@ -385,7 +398,9 @@ fn detect_multiplexer(env: &dyn Environment) -> Option { || env.has_non_empty("ZELLIJ_SESSION_NAME") || env.has_non_empty("ZELLIJ_VERSION") { - return Some(Multiplexer::Zellij {}); + return Some(Multiplexer::Zellij { + version: env.zellij_version(), + }); } None @@ -456,6 +471,32 @@ fn tmux_display_message(format: &str) -> Option { none_if_whitespace(value.trim().to_string()) } +fn zellij_version_from_command() -> Option { + // Best-effort fallback: missing or broken zellij binaries should not affect + // terminal detection. + let output = std::process::Command::new("zellij") + .arg("--version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_zellij_version(stdout.trim()) +} + +fn parse_zellij_version(value: &str) -> Option { + let value = none_if_whitespace(value.to_string())?; + let mut parts = value.split_whitespace(); + match (parts.next(), parts.next()) { + (Some(command), Some(version)) if command.eq_ignore_ascii_case("zellij") => { + Some(version.to_string()) + } + _ => Some(value), + } +} + /// Sanitizes a terminal token for use in User-Agent headers. /// /// Invalid header characters are replaced with underscores. diff --git a/codex-rs/terminal-detection/src/terminal_tests.rs b/codex-rs/terminal-detection/src/terminal_tests.rs index ec2a3b1166..54f0a7a5a9 100644 --- a/codex-rs/terminal-detection/src/terminal_tests.rs +++ b/codex-rs/terminal-detection/src/terminal_tests.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; struct FakeEnvironment { vars: HashMap, tmux_client_info: TmuxClientInfo, + zellij_version: Option, } impl FakeEnvironment { @@ -12,6 +13,7 @@ impl FakeEnvironment { Self { vars: HashMap::new(), tmux_client_info: TmuxClientInfo::default(), + zellij_version: None, } } @@ -27,6 +29,11 @@ impl FakeEnvironment { }; self } + + fn with_zellij_version(mut self, version: &str) -> Self { + self.zellij_version = Some(version.to_string()); + self + } } impl Environment for FakeEnvironment { @@ -37,6 +44,12 @@ impl Environment for FakeEnvironment { fn tmux_client_info(&self) -> TmuxClientInfo { self.tmux_client_info.clone() } + + fn zellij_version(&self) -> Option { + self.zellij_version + .clone() + .or_else(|| self.var_non_empty("ZELLIJ_VERSION")) + } } fn terminal_info( @@ -129,7 +142,7 @@ fn terminal_info_reports_is_zellij() { /*term_program*/ None, /*version*/ None, /*term*/ None, - Some(Multiplexer::Zellij {}), + Some(Multiplexer::Zellij { version: None }), ); assert!(zellij.is_zellij()); @@ -312,12 +325,62 @@ fn detects_zellij_multiplexer() { term_program: None, version: None, term: None, - multiplexer: Some(Multiplexer::Zellij {}), + multiplexer: Some(Multiplexer::Zellij { version: None }), }, "zellij_multiplexer" ); } +#[test] +fn detects_zellij_multiplexer_version() { + let env = FakeEnvironment::new().with_var("ZELLIJ_VERSION", "0.43.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Zellij { + version: Some("0.43.1".to_string()), + }), + ), + "zellij_multiplexer_version" + ); +} + +#[test] +fn detects_zellij_multiplexer_command_version() { + let env = FakeEnvironment::new() + .with_var("ZELLIJ", "1") + .with_zellij_version("0.44.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Zellij { + version: Some("0.44.1".to_string()), + }), + ), + "zellij_multiplexer_command_version" + ); +} + +#[test] +fn parses_zellij_version_output() { + assert_eq!( + parse_zellij_version("zellij 0.44.1"), + Some("0.44.1".to_string()) + ); + assert_eq!(parse_zellij_version("0.44.1"), Some("0.44.1".to_string())); + assert_eq!(parse_zellij_version(""), None); +} + #[test] fn detects_tmux_client_termtype() { let env = FakeEnvironment::new() diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 59e30aba7b..04bcbd0b00 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,3 +1,4 @@ +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; use codex_feedback::FeedbackDiagnostics; use crossterm::event::KeyCode; @@ -502,6 +503,11 @@ pub(crate) fn feedback_upload_consent_params( Line::from("").into(), Line::from("The following files will be sent:".dim()).into(), Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), + Line::from(vec![ + " • ".into(), + DOCTOR_REPORT_ATTACHMENT_FILENAME.into(), + ]) + .into(), ]; if let Some(path) = rollout_path.as_deref() && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) @@ -538,7 +544,7 @@ pub(crate) fn feedback_upload_consent_params( super::SelectionItem { name: "Yes".to_string(), description: Some( - "Share the current Codex session logs with the team for troubleshooting." + "Share the current Codex session logs and diagnostics with the team for troubleshooting." .to_string(), ), actions: vec![yes_action], @@ -572,7 +578,18 @@ mod tests { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); view.render(area, &mut buf); + render_buffer(area, &buf) + } + fn render_renderable(renderable: &dyn Renderable, width: u16) -> String { + let height = renderable.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + renderable.render(area, &mut buf); + render_buffer(area, &buf) + } + + fn render_buffer(area: Rect, buf: &Buffer) -> String { let mut lines: Vec = (0..area.height) .map(|row| { let mut line = String::new(); @@ -670,6 +687,23 @@ mod tests { insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered); } + #[test] + fn feedback_upload_consent_lists_doctor_report() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = feedback_upload_consent_params( + tx, + FeedbackCategory::Bug, + Some(std::path::PathBuf::from("rollout.jsonl")), + Some("auto-review-rollout.jsonl".to_string()), + &FeedbackDiagnostics::default(), + ); + + let rendered = render_renderable(params.header.as_ref(), /*width*/ 60); + + insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered); + } + #[test] fn submit_feedback_emits_submit_event_with_trimmed_note() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap new file mode 100644 index 0000000000..6cca54542d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 704 +expression: rendered +--- +Upload logs? + +The following files will be sent: + • codex-logs.log + • codex-doctor-report.json + • rollout.jsonl + • auto-review-rollout.jsonl diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap index bafa94b09d..98fabc8444 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -11,7 +11,7 @@ expression: rendered -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No 3. Cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap index 2ba27d409f..cdeedc7b78 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap @@ -6,11 +6,12 @@ expression: popup The following files will be sent: • codex-logs.log + • codex-doctor-report.json • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index b027333ad7..354e473a18 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -6,6 +6,7 @@ expression: popup The following files will be sent: • codex-logs.log + • codex-doctor-report.json • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt @@ -13,8 +14,8 @@ expression: popup - Proxy environment variables are set and may affect connectivity. - HTTPS_PROXY = hello -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index 25a20c3528..cf241aee9f 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -132,7 +132,7 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { Some(Multiplexer::Tmux { .. }) => { return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux); } - Some(Multiplexer::Zellij {}) => { + Some(Multiplexer::Zellij { .. }) => { return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij); } None => {} @@ -389,7 +389,7 @@ mod tests { assert_eq!( pet_image_support_for_terminal(&terminal_info_for_test( TerminalName::Kitty, - Some(Multiplexer::Zellij {}), + Some(Multiplexer::Zellij { version: None }), Some("kitty"), /*term*/ None, )), From 3de4d7f238497b60aab4d4cf3246897ebc9c95a9 Mon Sep 17 00:00:00 2001 From: sayan-oai Date: Wed, 13 May 2026 14:28:57 -0700 Subject: [PATCH 17/47] clean up instructions (#22543) rm behavioral steering in tool docs for code mode. --- codex-rs/code-mode/src/description.rs | 9 +++------ codex-rs/core/src/tools/spec_plan_tests.rs | 5 ++--- codex-rs/core/src/tools/spec_tests.rs | 5 +++++ 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/codex-rs/code-mode/src/description.rs b/codex-rs/code-mode/src/description.rs index 4c5eb6fbdc..0c2813e51a 100644 --- a/codex-rs/code-mode/src/description.rs +++ b/codex-rs/code-mode/src/description.rs @@ -7,10 +7,8 @@ use std::collections::BTreeMap; use crate::PUBLIC_TOOL_NAME; const MAX_JS_SAFE_INTEGER: u64 = (1_u64 << 53) - 1; -const CODE_MODE_ONLY_PREFACE: &str = - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"; const DEFERRED_NESTED_TOOLS_GUIDANCE: &str = r#"Some nested MCP/app tools may be omitted from this description. They are still available on the global `tools` object and listed in `ALL_TOOLS`. -To find one, filter `ALL_TOOLS` by `name` and `description`; do not print the full `ALL_TOOLS` array. Print only a small set of relevant matches if you need to inspect them."#; +To find one, filter `ALL_TOOLS` by `name` and `description`."#; const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/compose tool calls - Evaluates the provided JavaScript code in a fresh V8 isolate as an async module. - All nested tools are available on the global `tools` object, for example `await tools.exec_command(...)`. Tool names are exposed as normalized JavaScript identifiers, for example `await tools.mcp__ologs__get_profile(...)`. @@ -256,9 +254,6 @@ pub fn build_exec_tool_description( deferred_tools_available: bool, ) -> String { let mut sections = Vec::new(); - if code_mode_only { - sections.push(CODE_MODE_ONLY_PREFACE.to_string()); - } sections.push(EXEC_DESCRIPTION_TEMPLATE.to_string()); if deferred_tools_available { sections.push(DEFERRED_NESTED_TOOLS_GUIDANCE.to_string()); @@ -875,6 +870,7 @@ mod tests { "### `foo` bar" )); + assert!(!description.contains("do not attempt to use any other tools directly")); } #[test] @@ -1097,5 +1093,6 @@ bar" assert!(description.contains("Some nested MCP/app tools may be omitted")); assert!(description.contains("filter `ALL_TOOLS` by `name` and `description`")); + assert!(!description.contains("do not print the full `ALL_TOOLS` array")); } } diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 044c445046..e40377e1d2 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -2265,9 +2265,8 @@ fn code_mode_only_exec_description_includes_full_nested_tool_details() { assert!(!description.contains("Enabled nested tools:")); assert!(!description.contains("Nested tool reference:")); - assert!(description.starts_with( - "Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly" - )); + assert!(description.starts_with("Run JavaScript code to orchestrate/compose tool calls")); + assert!(!description.contains("do not attempt to use any other tools directly")); assert!(description.contains("### `update_plan`")); assert!(description.contains("### `view_image`")); } diff --git a/codex-rs/core/src/tools/spec_tests.rs b/codex-rs/core/src/tools/spec_tests.rs index d275b89de3..4f8141065c 100644 --- a/codex-rs/core/src/tools/spec_tests.rs +++ b/codex-rs/core/src/tools/spec_tests.rs @@ -1441,6 +1441,11 @@ async fn code_mode_only_can_expose_multi_agent_v2_as_normal_tools() { }; assert!(!exec.description.contains("spawn_agent")); assert!(!exec.description.contains("wait_agent")); + assert!( + !exec + .description + .contains("do not attempt to use any other tools directly") + ); let spawn_agent = find_tool(&model_visible_specs, "spawn_agent"); let ToolSpec::Function(spawn_agent) = spawn_agent else { From 8ae0c837f0fbc090bef94c6323d0fa3803e55258 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 13 May 2026 14:37:50 -0700 Subject: [PATCH 18/47] Avoid PowerShell profiles in elevated Windows sandbox (#21400) ## Why On Windows, elevated sandboxed commands run under a dedicated sandbox account while `HOME` / `USERPROFILE` can still point at the real user's profile directory. For PowerShell login shells, that combination can make the sandbox account try to load the real user's PowerShell profile script. If the sandbox account's execution policy differs from the real user's policy, startup can emit profile-loading errors before the requested command runs. For this backend, loading the profile is not a faithful user login shell: it is cross-account profile execution. Treating these PowerShell invocations as non-login shells avoids that invalid startup path. ## Why This Happens Late The normal `login` decision is resolved when shell argv is created, but that point is too early to make this Windows sandbox-specific decision. At argv creation time we do not yet know the actual sandbox attempt that will run the command. A turn can include sandboxed and unsandboxed attempts, and a broad turn-level override would also affect Full Access commands where the user's profile should remain available. Instead, this change carries the selected `ShellType` alongside the argv and applies the `-NoProfile` adjustment in the shell runtimes once the `SandboxAttempt` is known. That keeps the override scoped to actual `WindowsRestrictedToken` attempts with `WindowsSandboxLevel::Elevated`. The runtime uses the selected shell metadata rather than re-detecting PowerShell from argv. That avoids brittle parsing and covers PowerShell invocation shapes such as `-EncodedCommand`. ## What Changed - Carry selected shell metadata through `exec_command` / unified exec requests and shell tool requests. - Insert `-NoProfile` for PowerShell commands only when the runtime is about to execute a sandboxed elevated Windows attempt. - Add focused unit coverage for elevated Windows PowerShell, `-EncodedCommand`, existing `-NoProfile`, legacy restricted-token attempts, unsandboxed attempts, and non-PowerShell commands. ## Verification - `cargo test -p codex-core disable_powershell_profile_tests` - `cargo test -p codex-core test_get_command` - `cargo clippy --fix --tests --allow-dirty --allow-no-vcs -p codex-core` A full `cargo test -p codex-core` run was also attempted during development, but it still hit an unrelated stack overflow in `agent::control` tests before reaching this area. --- codex-rs/core/src/memory_usage.rs | 2 +- codex-rs/core/src/tools/handlers/shell.rs | 4 + .../src/tools/handlers/shell/shell_command.rs | 2 + .../core/src/tools/handlers/unified_exec.rs | 27 ++- .../handlers/unified_exec/exec_command.rs | 5 +- .../src/tools/handlers/unified_exec_tests.rs | 39 ++++- codex-rs/core/src/tools/runtimes/mod.rs | 163 ++++++++++++++++++ codex-rs/core/src/tools/runtimes/shell.rs | 8 + .../core/src/tools/runtimes/unified_exec.rs | 9 + codex-rs/core/src/unified_exec/mod.rs | 2 + .../core/src/unified_exec/process_manager.rs | 1 + .../src/unified_exec/process_manager_tests.rs | 1 + 12 files changed, 245 insertions(+), 18 deletions(-) diff --git a/codex-rs/core/src/memory_usage.rs b/codex-rs/core/src/memory_usage.rs index b5bc6eef53..baa64459a2 100644 --- a/codex-rs/core/src/memory_usage.rs +++ b/codex-rs/core/src/memory_usage.rs @@ -71,7 +71,7 @@ fn shell_command_for_invocation(invocation: &ToolInvocation) -> Option<(Vec None, } diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 84f400854d..c8c93bd6c7 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -7,6 +7,7 @@ use crate::exec::ExecParams; use crate::exec_policy::ExecApprovalRequest; use crate::function_tool::FunctionCallError; use crate::session::turn_context::TurnContext; +use crate::shell::ShellType; use crate::tools::context::FunctionToolOutput; use crate::tools::context::ToolPayload; use crate::tools::events::ToolEmitter; @@ -44,6 +45,7 @@ struct RunExecLikeArgs { tool_name: ToolName, exec_params: ExecParams, hook_command: String, + shell_type: Option, additional_permissions: Option, prefix_rule: Option>, session: Arc, @@ -59,6 +61,7 @@ async fn run_exec_like(args: RunExecLikeArgs) -> Result Result for ShellCommandHandler { session.conversation_id, turn.tools_config.allow_login_shell, )?; + let shell_type = Some(session.user_shell().shell_type.clone()); run_exec_like(RunExecLikeArgs { tool_name, exec_params, hook_command: params.command, + shell_type, additional_permissions: params.additional_permissions.clone(), prefix_rule, session, diff --git a/codex-rs/core/src/tools/handlers/unified_exec.rs b/codex-rs/core/src/tools/handlers/unified_exec.rs index c97f5bb6f2..22e0a16dd8 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec.rs @@ -1,5 +1,6 @@ use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; +use crate::shell::ShellType; use crate::shell::get_shell_by_model_provided_path; use crate::tools::context::ExecCommandToolOutput; use crate::tools::context::ToolInvocation; @@ -79,6 +80,12 @@ fn effective_max_output_tokens( resolve_max_tokens(max_output_tokens).min(truncation_policy.token_budget()) } +#[derive(Debug)] +pub(crate) struct ResolvedCommand { + pub(crate) command: Vec, + pub(crate) shell_type: ShellType, +} + fn post_unified_exec_tool_use_payload( invocation: &ToolInvocation, result: &ExecCommandToolOutput, @@ -107,7 +114,7 @@ pub(crate) fn get_command( session_shell: Arc, shell_mode: &UnifiedExecShellMode, allow_login_shell: bool, -) -> Result, String> { +) -> Result { let use_login_shell = match args.login { Some(true) if !allow_login_shell => { return Err( @@ -126,13 +133,19 @@ pub(crate) fn get_command( shell }); let shell = model_shell.as_ref().unwrap_or(session_shell.as_ref()); - Ok(shell.derive_exec_args(&args.cmd, use_login_shell)) + Ok(ResolvedCommand { + command: shell.derive_exec_args(&args.cmd, use_login_shell), + shell_type: shell.shell_type.clone(), + }) } - UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(vec![ - zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(), - if use_login_shell { "-lc" } else { "-c" }.to_string(), - args.cmd.clone(), - ]), + UnifiedExecShellMode::ZshFork(zsh_fork_config) => Ok(ResolvedCommand { + command: vec![ + zsh_fork_config.shell_zsh_path.to_string_lossy().to_string(), + if use_login_shell { "-lc" } else { "-c" }.to_string(), + args.cmd.clone(), + ], + shell_type: ShellType::Zsh, + }), } } diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index cef066ad57..e27c56576a 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -138,13 +138,15 @@ impl ToolExecutor for ExecCommandHandler { ) .await; let process_id = manager.allocate_process_id().await; - let command = get_command( + let resolved_command = get_command( &args, session.user_shell(), &turn.tools_config.unified_exec_shell_mode, turn.tools_config.allow_login_shell, ) .map_err(FunctionCallError::RespondToModel)?; + let command = resolved_command.command; + let shell_type = resolved_command.shell_type; let command_for_display = codex_shell_command::parse_command::shlex_join(&command); let ExecCommandArgs { @@ -249,6 +251,7 @@ impl ToolExecutor for ExecCommandHandler { .exec_command( ExecCommandRequest { command, + shell_type, hook_command: hook_command.clone(), process_id, yield_time_ms, diff --git a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs index 02123c4b64..6ab2752b94 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec_tests.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::shell::ShellType; use crate::shell::default_user_shell; use codex_tools::UnifiedExecShellMode; use codex_tools::ZshForkConfig; @@ -42,13 +43,14 @@ fn test_get_command_uses_default_shell_when_unspecified() -> anyhow::Result<()> assert!(args.shell.is_none()); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command.len(), 3); assert_eq!(command[2], "echo hello"); @@ -63,13 +65,14 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { assert_eq!(args.shell.as_deref(), Some("/bin/bash")); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command.last(), Some(&"echo hello".to_string())); if command @@ -83,21 +86,37 @@ fn test_get_command_respects_explicit_bash_shell() -> anyhow::Result<()> { #[test] fn test_get_command_respects_explicit_powershell_shell() -> anyhow::Result<()> { - let json = r#"{"cmd": "echo hello", "shell": "powershell"}"#; + let temp_dir = tempfile::tempdir()?; + let powershell_path = temp_dir.path().join(if cfg!(windows) { + "powershell.exe" + } else { + "powershell" + }); + std::fs::write(&powershell_path, "")?; + let json = serde_json::json!({ + "cmd": "echo hello", + "shell": powershell_path, + }) + .to_string(); - let args: ExecCommandArgs = parse_arguments(json)?; + let args: ExecCommandArgs = parse_arguments(&json)?; - assert_eq!(args.shell.as_deref(), Some("powershell")); + assert_eq!( + args.shell.as_deref(), + Some(powershell_path.to_string_lossy().as_ref()) + ); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command[2], "echo hello"); + assert_eq!(resolved.shell_type, ShellType::PowerShell); Ok(()) } @@ -109,13 +128,14 @@ fn test_get_command_respects_explicit_cmd_shell() -> anyhow::Result<()> { assert_eq!(args.shell.as_deref(), Some("cmd")); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &UnifiedExecShellMode::Direct, /*allow_login_shell*/ true, ) .map_err(anyhow::Error::msg)?; + let command = resolved.command; assert_eq!(command[2], "echo hello"); Ok(()) @@ -159,7 +179,7 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result< })?, }); - let command = get_command( + let resolved = get_command( &args, Arc::new(default_user_shell()), &shell_mode, @@ -168,13 +188,14 @@ fn test_get_command_ignores_explicit_shell_in_zsh_fork_mode() -> anyhow::Result< .map_err(anyhow::Error::msg)?; assert_eq!( - command, + resolved.command, vec![ shell_zsh_path.to_string_lossy().to_string(), "-lc".to_string(), "echo hello".to_string() ] ); + assert_eq!(resolved.shell_type, ShellType::Zsh); Ok(()) } diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 073fda8ec4..bba1c572ef 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -8,6 +8,7 @@ use crate::exec_env::CODEX_THREAD_ID_ENV_VAR; use crate::path_utils; use crate::sandboxing::SandboxPermissions; use crate::shell::Shell; +use crate::shell::ShellType; use crate::tools::sandboxing::ToolError; #[cfg(target_os = "macos")] use codex_network_proxy::CODEX_PROXY_GIT_SSH_COMMAND_MARKER; @@ -15,8 +16,10 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY; use codex_network_proxy::PROXY_ENV_KEYS; #[cfg(target_os = "macos")] use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY; +use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::AdditionalPermissionProfile; use codex_sandboxing::SandboxCommand; +use codex_sandboxing::SandboxType; use codex_utils_absolute_path::AbsolutePathBuf; use std::collections::HashMap; @@ -67,6 +70,35 @@ pub(crate) fn exec_env_for_sandbox_permissions( env } +pub(crate) fn disable_powershell_profile_for_elevated_windows_sandbox( + command: &[String], + shell_type: Option<&ShellType>, + sandbox: SandboxType, + windows_sandbox_level: WindowsSandboxLevel, +) -> Vec { + if shell_type != Some(&ShellType::PowerShell) + || sandbox != SandboxType::WindowsRestrictedToken + || windows_sandbox_level != WindowsSandboxLevel::Elevated + || command.is_empty() + { + return command.to_vec(); + } + + if command[1..] + .iter() + .any(|arg| arg.eq_ignore_ascii_case("-NoProfile")) + { + return command.to_vec(); + } + + // The elevated Windows sandbox runs as a dedicated sandbox account while + // HOME/USERPROFILE may still point at the real user profile. Loading + // PowerShell profiles in that mixed context is not a valid login shell. + let mut command = command.to_vec(); + command.insert(1, "-NoProfile".to_string()); + command +} + /// POSIX-only helper: for commands produced by `Shell::derive_exec_args` /// for Bash/Zsh/sh of the form `[shell_path, "-lc", "