From d26432161f94b927a4d1616129ec3a74039e3e8a Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 10:59:35 -0800 Subject: [PATCH 01/18] Elevated sandbox NUX --- codex-rs/core/src/config/mod.rs | 9 + codex-rs/core/src/lib.rs | 3 + codex-rs/tui/src/app.rs | 85 ++++++- codex-rs/tui/src/app_event.rs | 26 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 141 ++--------- codex-rs/tui/src/bottom_pane/command_popup.rs | 8 + .../src/bottom_pane/list_selection_view.rs | 71 +----- codex-rs/tui/src/bottom_pane/mod.rs | 5 + codex-rs/tui/src/chatwidget.rs | 218 +++++++++++++++-- ...ts__approvals_selection_popup@windows.snap | 1 + codex-rs/tui/src/chatwidget/tests.rs | 42 +++- codex-rs/tui/src/slash_command.rs | 3 + codex-rs/tui2/src/app.rs | 85 ++++++- codex-rs/tui2/src/app_event.rs | 26 ++ .../tui2/src/bottom_pane/chat_composer.rs | 77 ++---- .../tui2/src/bottom_pane/command_popup.rs | 8 + .../src/bottom_pane/list_selection_view.rs | 72 +----- codex-rs/tui2/src/bottom_pane/mod.rs | 5 + codex-rs/tui2/src/chatwidget.rs | 227 ++++++++++++++++-- codex-rs/tui2/src/chatwidget/tests.rs | 43 +++- codex-rs/tui2/src/slash_command.rs | 3 + codex-rs/windows-sandbox-rs/src/identity.rs | 12 + codex-rs/windows-sandbox-rs/src/lib.rs | 2 + 23 files changed, 819 insertions(+), 353 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f5d6a8ffd62e..162c3226e3dd 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1504,6 +1504,15 @@ impl Config { } self.forced_auto_mode_downgraded_on_windows = !value; } + + pub fn set_windows_elevated_sandbox_globally(&mut self, value: bool) { + crate::safety::set_windows_elevated_sandbox_enabled(value); + if value { + self.features.enable(Feature::WindowsSandboxElevated); + } else { + self.features.disable(Feature::WindowsSandboxElevated); + } + } } fn default_review_model() -> String { diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 370c1ecb97e6..daed9637d96d 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -51,6 +51,7 @@ pub mod token_data; mod truncate; mod unified_exec; mod user_instructions; +pub mod windows_sandbox; pub use model_provider_info::CHAT_WIRE_API_DEPRECATION_SUMMARY; pub use model_provider_info::DEFAULT_LMSTUDIO_PORT; pub use model_provider_info::DEFAULT_OLLAMA_PORT; @@ -115,6 +116,8 @@ pub use exec_policy::ExecPolicyError; pub use exec_policy::load_exec_policy; pub use safety::get_platform_sandbox; pub use safety::set_windows_sandbox_enabled; +pub use safety::is_windows_elevated_sandbox_enabled; +pub use safety::set_windows_elevated_sandbox_enabled; // Re-export the protocol types from the standalone `codex-protocol` crate so existing // `codex_core::protocol::...` references continue to work across the workspace. pub use codex_protocol::protocol; diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 32223f18ec37..8cfe0c3c1f59 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,5 +1,7 @@ use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; +use crate::app_event::WindowsSandboxEnableMode; +use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; @@ -792,19 +794,91 @@ impl App { AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } - AppEvent::EnableWindowsSandboxForAgentMode { preset } => { + AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { + self.chat_widget.clear_windows_sandbox_setup_status(); + self.chat_widget + .open_windows_sandbox_fallback_prompt(preset, reason); + } + AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { + #[cfg(target_os = "windows")] + { + let policy = preset.sandbox.clone(); + let policy_cwd = self.config.cwd.clone(); + let command_cwd = policy_cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let codex_home = self.config.codex_home.clone(); + let tx = self.app_event_tx.clone(); + + // If the elevated setup already ran on this machine, don't prompt for + // elevation again - just flip the config to use the elevated path. + if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path()) + { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode: WindowsSandboxEnableMode::Elevated, + }); + return Ok(true); + } + + self.chat_widget.show_windows_sandbox_setup_status(); + tokio::task::spawn_blocking(move || { + let result = codex_core::windows_sandbox::run_elevated_setup( + &policy, + policy_cwd.as_path(), + command_cwd.as_path(), + &env_map, + codex_home.as_path(), + ); + let event = match result { + Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset.clone(), + mode: WindowsSandboxEnableMode::Elevated, + }, + Err(err) => { + tracing::error!( + error = %err, + "failed to run elevated Windows sandbox setup" + ); + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + reason: WindowsSandboxFallbackReason::ElevationFailed, + } + } + }; + tx.send(event); + }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (preset, mode); + } + } + AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { #[cfg(target_os = "windows")] { + self.chat_widget.clear_windows_sandbox_setup_status(); let profile = self.active_profile.as_deref(); let feature_key = Feature::WindowsSandbox.key(); + let elevated_key = Feature::WindowsSandboxElevated.key(); + let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_feature_enabled(feature_key, true) + .set_feature_enabled(elevated_key, elevated_enabled) .apply() .await { Ok(()) => { self.config.set_windows_sandbox_globally(true); + self.config + .set_windows_elevated_sandbox_globally(elevated_enabled); + self.chat_widget + .set_feature_enabled(Feature::WindowsSandbox, true); + self.chat_widget.set_feature_enabled( + Feature::WindowsSandboxElevated, + elevated_enabled, + ); self.chat_widget.clear_forced_auto_mode_downgrade(); if let Some((sample_paths, extra_count, failed_scan)) = self.chat_widget.world_writable_warning_details() @@ -833,7 +907,14 @@ impl App { self.app_event_tx .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); self.chat_widget.add_info_message( - "Enabled experimental Windows sandbox.".to_string(), + match mode { + WindowsSandboxEnableMode::Elevated => { + "Enabled elevated Windows sandbox.".to_string() + } + WindowsSandboxEnableMode::Legacy => { + "Enabled degraded Windows sandbox.".to_string() + } + }, None, ); } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 1f99e372e97c..cede796c89ce 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -15,6 +15,18 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_protocol::openai_models::ReasoningEffort; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowsSandboxEnableMode { + Elevated, + Legacy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowsSandboxFallbackReason { + DeclinedElevation, + ElevationFailed, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -106,10 +118,24 @@ pub(crate) enum AppEvent { preset: ApprovalPreset, }, + /// Open the Windows sandbox fallback prompt after declining or failing elevation. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + OpenWindowsSandboxFallbackPrompt { + preset: ApprovalPreset, + reason: WindowsSandboxFallbackReason, + }, + + /// Begin the elevated Windows sandbox setup flow. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + BeginWindowsSandboxElevatedSetup { + preset: ApprovalPreset, + }, + /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { preset: ApprovalPreset, + mode: WindowsSandboxEnableMode, }, /// Update the current approval policy in the running app and widget. diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d3c93caa1566..a72a0ecc031b 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -64,6 +64,12 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +fn windows_degraded_sandbox_active() -> bool { + cfg!(target_os = "windows") + && codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled() +} + /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -73,7 +79,6 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; pub enum InputResult { Submitted(String), Command(SlashCommand), - CommandWithArgs(SlashCommand, String), None, } @@ -1229,6 +1234,9 @@ impl ChatComposer { && rest.is_empty() && let Some((_n, cmd)) = built_in_slash_commands() .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) .find(|(n, _)| *n == name) { self.textarea.set_text(""); @@ -1296,6 +1304,10 @@ impl ChatComposer { if !treat_as_plain_text { let is_builtin = built_in_slash_commands() .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() + || *cmd != SlashCommand::ElevateSandbox + }) .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name @@ -1320,18 +1332,6 @@ impl ChatComposer { } } - if !input_starts_with_space - && let Some((name, rest)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); - } - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { Ok(expanded) => expanded, Err(err) => { @@ -1706,16 +1706,6 @@ impl ChatComposer { fn sync_popups(&mut self) { let file_token = Self::current_at_token(&self.textarea); - let browsing_history = self - .history - .should_handle_navigation(self.textarea.text(), self.textarea.cursor()); - // When browsing input history (shell-style Up/Down recall), skip all popup - // synchronization so nothing steals focus from continued history navigation. - if browsing_history { - self.active_popup = ActivePopup::None; - return; - } - let skill_token = self.current_skill_token(); let allow_command_popup = file_token.is_none() && skill_token.is_none(); @@ -1785,6 +1775,9 @@ impl ChatComposer { let builtin_match = built_in_slash_commands() .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { @@ -1920,7 +1913,6 @@ impl ChatComposer { self.has_focus = has_focus; } - #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; self.input_disabled_placeholder = if enabled { None } else { placeholder }; @@ -3043,9 +3035,6 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "init"); } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/init'") - } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -3054,44 +3043,6 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } - #[test] - fn slash_review_with_args_dispatches_command_with_args() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']); - type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - match result { - InputResult::CommandWithArgs(cmd, args) => { - assert_eq!(cmd, SlashCommand::Review); - assert_eq!(args, "fix this"); - } - InputResult::Command(cmd) => { - panic!("expected args for '/review', got bare command: {cmd:?}") - } - InputResult::Submitted(text) => { - panic!("expected command dispatch, got literal submit: {text}") - } - InputResult::None => panic!("expected CommandWithArgs result for '/review'"), - } - assert!(composer.textarea.is_empty(), "composer should be cleared"); - } - #[test] fn extract_args_supports_quoted_paths_single_arg() { let args = extract_positional_args_for_prompt_line( @@ -3157,9 +3108,6 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/diff'") - } InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -3193,9 +3141,6 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "mention"); } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/mention'") - } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -4393,59 +4338,6 @@ mod tests { assert_eq!(result, InputResult::None); } - #[test] - fn history_navigation_takes_priority_over_popups() { - use codex_protocol::protocol::SkillScope; - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - use tokio::sync::mpsc::unbounded_channel; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - composer.set_skill_mentions(Some(vec![SkillMetadata { - name: "codex-cli-release-notes".to_string(), - description: "example".to_string(), - short_description: None, - path: PathBuf::from("skills/codex-cli-release-notes/SKILL.md"), - scope: SkillScope::Repo, - }])); - - // Seed local history; the newest entry triggers the skills popup. - composer.history.record_local_submission("older"); - composer - .history - .record_local_submission("$codex-cli-release-notes"); - - // First Up recalls "$...", but we should not open the skills popup while browsing history. - let (result, _redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "$codex-cli-release-notes"); - assert!( - matches!(composer.active_popup, ActivePopup::None), - "expected no skills popup while browsing history" - ); - - // Second Up should navigate history again (no popup should interfere). - let (result, _redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!(result, InputResult::None); - assert_eq!(composer.textarea.text(), "older"); - assert!( - matches!(composer.active_popup, ActivePopup::None), - "expected popup to be dismissed after history navigation" - ); - } - #[test] fn slash_popup_activated_for_bare_slash_and_valid_prefixes() { // use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -4603,6 +4495,7 @@ mod tests { ); assert_eq!(composer.attached_images.len(), 1); } + #[test] fn input_disabled_ignores_keypresses_and_hides_cursor() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index dc123f6c2c41..6878805525a3 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -15,6 +15,12 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; +fn windows_degraded_sandbox_active() -> bool { + cfg!(target_os = "windows") + && codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled() +} + /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { @@ -32,9 +38,11 @@ pub(crate) struct CommandPopup { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() .into_iter() .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 40787a9c259b..281918fb7b7f 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -13,7 +13,6 @@ use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; use crate::render::Insets; @@ -439,10 +438,8 @@ impl Renderable for ListSelectionView { if self.is_searchable { height = height.saturating_add(1); } - if let Some(note) = &self.footer_note { - let note_width = width.saturating_sub(2); - let note_lines = wrap_styled_line(note, note_width); - height = height.saturating_add(note_lines.len() as u16); + if self.footer_note.is_some() { + height = height.saturating_add(1); } if self.footer_hint.is_some() { height = height.saturating_add(1); @@ -455,15 +452,12 @@ impl Renderable for ListSelectionView { return; } - let note_width = area.width.saturating_sub(2); - let note_lines = self - .footer_note - .as_ref() - .map(|note| wrap_styled_line(note, note_width)); - let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); - let footer_rows = note_height + u16::from(self.footer_hint.is_some()); - let [content_area, footer_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); + let footer_rows = u16::from(self.footer_note.is_some()) + u16::from(self.footer_hint.is_some()); + let [content_area, footer_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(footer_rows), + ]) + .areas(area); Block::default() .style(user_message_style()) @@ -533,30 +527,19 @@ impl Renderable for ListSelectionView { if footer_area.height > 0 { let [note_area, hint_area] = Layout::vertical([ - Constraint::Length(note_height), + Constraint::Length(if self.footer_note.is_some() { 1 } else { 0 }), Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), ]) .areas(footer_area); - if let Some(lines) = note_lines { + if let Some(note) = &self.footer_note { let note_area = Rect { x: note_area.x + 2, y: note_area.y, width: note_area.width.saturating_sub(2), height: note_area.height, }; - for (idx, line) in lines.iter().enumerate() { - if idx as u16 >= note_area.height { - break; - } - let line_area = Rect { - x: note_area.x, - y: note_area.y + idx as u16, - width: note_area.width, - height: 1, - }; - line.clone().render(line_area, buf); - } + note.clone().render(note_area, buf); } if let Some(hint) = &self.footer_hint { @@ -654,38 +637,6 @@ mod tests { assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } - #[test] - fn snapshot_footer_note_wraps() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }]; - let footer_note = Line::from(vec![ - "Note: ".dim(), - "Use /setup-elevated-sandbox".cyan(), - " to allow network access.".dim(), - ]); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_note: Some(footer_note), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_footer_note_wraps", - render_lines_with_width(&view, 40) - ); - } - #[test] fn renders_search_query_line_when_enabled() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index fe626537ac43..f5726f106660 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -298,6 +298,11 @@ impl BottomPane { self.request_redraw(); } + pub(crate) fn set_composer_input_enabled(&mut self, enabled: bool, placeholder: Option) { + self.composer.set_input_enabled(enabled, placeholder); + self.request_redraw(); + } + /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 537adda25124..576001ad07c0 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -85,6 +85,8 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::app_event::AppEvent; +use crate::app_event::WindowsSandboxEnableMode; +use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BetaFeatureItem; @@ -1737,6 +1739,43 @@ impl ChatWidget { SlashCommand::Approvals => { self.open_approvals_popup(); } + SlashCommand::ElevateSandbox => { + #[cfg(target_os = "windows")] + { + let windows_degraded_sandbox_enabled = + codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled(); + if !windows_degraded_sandbox_enabled { + // This command should not be visible/recognized outside degraded mode, + // but guard anyway in case something dispatches it directly. + return; + } + + let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + else { + // Avoid panicking in interactive UI; treat this as a recoverable + // internal error. + self.add_error_message( + "Internal error: missing the 'auto' approval preset.".to_string(), + ); + return; + }; + + if let Err(err) = self.config.approval_policy.can_set(&preset.approval) { + self.add_error_message(err.to_string()); + return; + } + + self.app_event_tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + } + #[cfg(not(target_os = "windows"))] + { + // Not supported; on non-Windows this command should never be reachable. + return; + } + } SlashCommand::Experimental => { self.open_experimental_popup(); } @@ -2840,10 +2879,24 @@ impl ChatWidget { let current_sandbox = self.config.sandbox_policy.get(); let mut items: Vec = Vec::new(); let presets: Vec = builtin_approval_presets(); + + #[cfg(target_os = "windows")] + let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled(); + #[cfg(not(target_os = "windows"))] + let windows_degraded_sandbox_enabled = false; + + let show_elevate_sandbox_hint = windows_degraded_sandbox_enabled + && presets.iter().any(|preset| preset.id == "auto"); + for preset in presets.into_iter() { let is_current = Self::preset_matches_current(current_approval, current_sandbox, &preset); - let name = preset.label.to_string(); + let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { + "Agent (degraded)".to_string() + } else { + preset.label.to_string() + }; let description = Some(preset.description.to_string()); let disabled_reason = match self.config.approval_policy.can_set(&preset.approval) { Ok(()) => None, @@ -2866,12 +2919,24 @@ impl ChatWidget { #[cfg(target_os = "windows")] { if codex_core::get_platform_sandbox().is_none() { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { - preset: preset_clone.clone(), - }); - })] + if codex_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + mode: WindowsSandboxEnableMode::Elevated, + }); + })] + } else { + let preset_clone = preset.clone(); + 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() { @@ -2906,8 +2971,18 @@ impl ChatWidget { }); } + let footer_note = show_elevate_sandbox_hint.then(|| { + vec![ + "To upgrade to the elevated sandbox, run ".dim(), + "/elevate-sandbox".cyan(), + ".".dim(), + ] + .into() + }); + self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select Approval Mode".to_string()), + footer_note, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(()), @@ -3187,29 +3262,105 @@ impl ChatWidget { 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" - ], + line!["Codex works best in Agent mode.".bold()], + line!["To use Agent mode on Windows, we need to configure the sandbox."], + line!["This setup requires elevation. Do you accept?"], + line!["Learn more: https://developers.openai.com/codex/windows"], ]) .wrap(Wrap { trim: false }), )); - let preset_clone = preset; + let preset_accept = preset.clone(); + let preset_decline = preset; + let items = vec![ + SelectionItem { + name: "Yes, I accept".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset_accept.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "No, I do not accept".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { + preset: preset_decline.clone(), + reason: WindowsSandboxFallbackReason::DeclinedElevation, + }); + })], + 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, + reason: WindowsSandboxFallbackReason, + ) { + use ratatui_macros::line; + + let mut lines = Vec::new(); + if reason == WindowsSandboxFallbackReason::ElevationFailed { + lines.push(line!["The elevated setup did not complete.".bold()]); + } + lines.push(line![ + "You can still use a degraded sandbox without elevation." + ]); + lines.push(line!["It is less watertight, but still secure."]); + lines.push(line![ + "Learn more: https://developers.openai.com/codex/windows" + ]); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + + let preset_retry = preset.clone(); + let preset_degraded = preset; let items = vec![ SelectionItem { - name: "Enable experimental sandbox".to_string(), + name: "Try elevated setup again".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset_retry.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use degraded sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), + preset: preset_degraded.clone(), + mode: WindowsSandboxEnableMode::Legacy, }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "Go back".to_string(), + name: "Use no sandbox".to_string(), description: None, actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenApprovalsPopup); @@ -3229,7 +3380,12 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + _preset: ApprovalPreset, + _reason: WindowsSandboxFallbackReason, + ) { + } #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { @@ -3246,6 +3402,36 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} + #[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( + false, + Some("Input disabled until setup completes.".to_string()), + ); + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane.set_interrupt_hint_visible(false); + self.set_status_header( + "Setting up the elevated Windows sandbox (this may take a minute or more). You'll stay in your current mode until it's done." + .to_string(), + ); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + 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(true, 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) {} + #[cfg(target_os = "windows")] pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { self.config.forced_auto_mode_downgraded_on_windows = false; diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap index 6758ec62c57b..ab889de71827 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows.snap @@ -1,5 +1,6 @@ --- source: tui/src/chatwidget/tests.rs +assertion_line: 1980 expression: popup --- Select Approval Mode diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index edfb4e1d411c..906914699331 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -76,6 +76,11 @@ fn set_windows_sandbox_enabled(enabled: bool) { codex_core::set_windows_sandbox_enabled(enabled); } +#[cfg(target_os = "windows")] +fn set_windows_elevated_sandbox_enabled(enabled: bool) { + codex_core::set_windows_elevated_sandbox_enabled(enabled); +} + async fn test_config() -> Config { // Use base defaults to avoid depending on host state. let codex_home = std::env::temp_dir(); @@ -2027,6 +2032,31 @@ async fn approvals_selection_popup_snapshot() { assert_snapshot!("approvals_selection_popup", popup); } +#[cfg(target_os = "windows")] +#[tokio::test] +async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.config.notices.hide_full_access_warning = None; + chat.config.features.enable(Feature::WindowsSandbox); + chat.config + .features + .disable(Feature::WindowsSandboxElevated); + set_windows_sandbox_enabled(true); + set_windows_elevated_sandbox_enabled(false); + + chat.open_approvals_popup(); + + let popup = render_bottom_popup(&chat, 80); + insta::with_settings!({ snapshot_suffix => "windows_degraded" }, { + assert_snapshot!("approvals_selection_popup", popup); + }); + + // Avoid leaking sandbox global state into other tests. + set_windows_sandbox_enabled(true); + set_windows_elevated_sandbox_enabled(false); +} + #[tokio::test] async fn preset_matching_ignores_extra_writable_roots() { let preset = builtin_approval_presets() @@ -2077,8 +2107,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("Agent mode on Windows uses an experimental sandbox"), - "expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}" + popup.contains("requires elevation"), + "expected auto mode prompt to mention elevation, popup: {popup}" ); } @@ -2094,12 +2124,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("Agent mode on Windows uses an experimental sandbox"), - "expected startup prompt to explain sandbox: {popup}" + popup.contains("requires elevation"), + "expected startup prompt to explain elevation: {popup}" ); assert!( - popup.contains("Enable experimental sandbox"), - "expected startup prompt to offer enabling the sandbox: {popup}" + popup.contains("Yes, I accept"), + "expected startup prompt to offer accepting elevation: {popup}" ); set_windows_sandbox_enabled(true); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index c6bd8a771e3c..873e807cc1f6 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + ElevateSandbox, Experimental, Skills, Review, @@ -54,6 +55,7 @@ impl SlashCommand { SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", + SlashCommand::ElevateSandbox => "upgrade to the elevated Windows sandbox", SlashCommand::Experimental => "toggle beta features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", @@ -78,6 +80,7 @@ impl SlashCommand { // | SlashCommand::Undo | SlashCommand::Model | SlashCommand::Approvals + | SlashCommand::ElevateSandbox | SlashCommand::Experimental | SlashCommand::Review | SlashCommand::Logout => false, diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index ead4135a4d7b..fb88c24791b3 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1,5 +1,7 @@ use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; +use crate::app_event::WindowsSandboxEnableMode; +use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::chatwidget::ChatWidget; @@ -1569,19 +1571,91 @@ impl App { AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { self.chat_widget.open_windows_sandbox_enable_prompt(preset); } - AppEvent::EnableWindowsSandboxForAgentMode { preset } => { + AppEvent::OpenWindowsSandboxFallbackPrompt { preset, reason } => { + self.chat_widget.clear_windows_sandbox_setup_status(); + self.chat_widget + .open_windows_sandbox_fallback_prompt(preset, reason); + } + AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { #[cfg(target_os = "windows")] { + let policy = preset.sandbox.clone(); + let policy_cwd = self.config.cwd.clone(); + let command_cwd = policy_cwd.clone(); + let env_map: std::collections::HashMap = + std::env::vars().collect(); + let codex_home = self.config.codex_home.clone(); + let tx = self.app_event_tx.clone(); + + // If the elevated setup already ran on this machine, don't prompt for + // elevation again - just flip the config to use the elevated path. + if codex_core::windows_sandbox::sandbox_setup_is_complete(codex_home.as_path()) + { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode: WindowsSandboxEnableMode::Elevated, + }); + return Ok(true); + } + + self.chat_widget.show_windows_sandbox_setup_status(); + tokio::task::spawn_blocking(move || { + let result = codex_core::windows_sandbox::run_elevated_setup( + &policy, + policy_cwd.as_path(), + command_cwd.as_path(), + &env_map, + codex_home.as_path(), + ); + let event = match result { + Ok(()) => AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset.clone(), + mode: WindowsSandboxEnableMode::Elevated, + }, + Err(err) => { + tracing::error!( + error = %err, + "failed to run elevated Windows sandbox setup" + ); + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + reason: WindowsSandboxFallbackReason::ElevationFailed, + } + } + }; + tx.send(event); + }); + } + #[cfg(not(target_os = "windows"))] + { + let _ = (preset, mode); + } + } + AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { + #[cfg(target_os = "windows")] + { + self.chat_widget.clear_windows_sandbox_setup_status(); let profile = self.active_profile.as_deref(); let feature_key = Feature::WindowsSandbox.key(); + let elevated_key = Feature::WindowsSandboxElevated.key(); + let elevated_enabled = matches!(mode, WindowsSandboxEnableMode::Elevated); match ConfigEditsBuilder::new(&self.config.codex_home) .with_profile(profile) .set_feature_enabled(feature_key, true) + .set_feature_enabled(elevated_key, elevated_enabled) .apply() .await { Ok(()) => { self.config.set_windows_sandbox_globally(true); + self.config + .set_windows_elevated_sandbox_globally(elevated_enabled); + self.chat_widget + .set_feature_enabled(Feature::WindowsSandbox, true); + self.chat_widget.set_feature_enabled( + Feature::WindowsSandboxElevated, + elevated_enabled, + ); self.chat_widget.clear_forced_auto_mode_downgrade(); if let Some((sample_paths, extra_count, failed_scan)) = self.chat_widget.world_writable_warning_details() @@ -1610,7 +1684,14 @@ impl App { self.app_event_tx .send(AppEvent::UpdateSandboxPolicy(preset.sandbox.clone())); self.chat_widget.add_info_message( - "Enabled experimental Windows sandbox.".to_string(), + match mode { + WindowsSandboxEnableMode::Elevated => { + "Enabled elevated Windows sandbox.".to_string() + } + WindowsSandboxEnableMode::Legacy => { + "Enabled degraded Windows sandbox.".to_string() + } + }, None, ); } diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index adb9c1308e86..a73fd6781e54 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -14,6 +14,18 @@ use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_protocol::openai_models::ReasoningEffort; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowsSandboxEnableMode { + Elevated, + Legacy, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowsSandboxFallbackReason { + DeclinedElevation, + ElevationFailed, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -105,10 +117,24 @@ pub(crate) enum AppEvent { preset: ApprovalPreset, }, + /// Open the Windows sandbox fallback prompt after declining or failing elevation. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + OpenWindowsSandboxFallbackPrompt { + preset: ApprovalPreset, + reason: WindowsSandboxFallbackReason, + }, + + /// Begin the elevated Windows sandbox setup flow. + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + BeginWindowsSandboxElevatedSetup { + preset: ApprovalPreset, + }, + /// Enable the Windows sandbox feature and switch to Agent mode. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] EnableWindowsSandboxForAgentMode { preset: ApprovalPreset, + mode: WindowsSandboxEnableMode, }, /// Update the current approval policy in the running app and widget. diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 6198d0a57a28..975f89fbced0 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -67,6 +67,12 @@ use std::path::PathBuf; use std::time::Duration; use std::time::Instant; +fn windows_degraded_sandbox_active() -> bool { + cfg!(target_os = "windows") + && codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled() +} + /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -76,7 +82,6 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; pub enum InputResult { Submitted(String), Command(SlashCommand), - CommandWithArgs(SlashCommand, String), None, } @@ -1146,6 +1151,10 @@ impl ChatComposer { && rest.is_empty() && let Some((_n, cmd)) = built_in_slash_commands() .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() + || *cmd != SlashCommand::ElevateSandbox + }) .find(|(n, _)| *n == name) { self.textarea.set_text(""); @@ -1213,6 +1222,10 @@ impl ChatComposer { if !treat_as_plain_text { let is_builtin = built_in_slash_commands() .into_iter() + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() + || *cmd != SlashCommand::ElevateSandbox + }) .any(|(command_name, _)| command_name == name); let prompt_prefix = format!("{PROMPTS_CMD_PREFIX}:"); let is_known_prompt = name @@ -1237,18 +1250,6 @@ impl ChatComposer { } } - if !input_starts_with_space - && let Some((name, rest)) = parse_slash_name(&text) - && !rest.is_empty() - && !name.contains('/') - && let Some((_n, cmd)) = built_in_slash_commands() - .into_iter() - .find(|(command_name, _)| *command_name == name) - && cmd == SlashCommand::Review - { - return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); - } - let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { Ok(expanded) => expanded, Err(err) => { @@ -1728,6 +1729,7 @@ impl ChatComposer { let builtin_match = built_in_slash_commands() .into_iter() + .filter(|(_, cmd)| windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox) .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { @@ -1863,7 +1865,6 @@ impl ChatComposer { self.has_focus = has_focus; } - #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; self.input_disabled_placeholder = if enabled { None } else { placeholder }; @@ -2964,9 +2965,6 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "init"); } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/init'") - } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -2975,44 +2973,6 @@ mod tests { assert!(composer.textarea.is_empty(), "composer should be cleared"); } - #[test] - fn slash_review_with_args_dispatches_command_with_args() { - use crossterm::event::KeyCode; - use crossterm::event::KeyEvent; - use crossterm::event::KeyModifiers; - - let (tx, _rx) = unbounded_channel::(); - let sender = AppEventSender::new(tx); - let mut composer = ChatComposer::new( - true, - sender, - false, - "Ask Codex to do anything".to_string(), - false, - ); - - type_chars_humanlike(&mut composer, &['/', 'r', 'e', 'v', 'i', 'e', 'w', ' ']); - type_chars_humanlike(&mut composer, &['f', 'i', 'x', ' ', 't', 'h', 'i', 's']); - - let (result, _needs_redraw) = - composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - - match result { - InputResult::CommandWithArgs(cmd, args) => { - assert_eq!(cmd, SlashCommand::Review); - assert_eq!(args, "fix this"); - } - InputResult::Command(cmd) => { - panic!("expected args for '/review', got bare command: {cmd:?}") - } - InputResult::Submitted(text) => { - panic!("expected command dispatch, got literal submit: {text}") - } - InputResult::None => panic!("expected CommandWithArgs result for '/review'"), - } - assert!(composer.textarea.is_empty(), "composer should be cleared"); - } - #[test] fn extract_args_supports_quoted_paths_single_arg() { let args = extract_positional_args_for_prompt_line( @@ -3078,9 +3038,6 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/diff'") - } InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -3114,9 +3071,6 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "mention"); } - InputResult::CommandWithArgs(_, _) => { - panic!("expected command dispatch without args for '/mention'") - } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -4303,6 +4257,7 @@ mod tests { "'/zzz' should not activate slash popup because it is not a prefix of any built-in command" ); } + #[test] fn input_disabled_ignores_keypresses_and_hides_cursor() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index e1e35ae9480c..e0b5e262339e 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -15,6 +15,12 @@ use codex_protocol::custom_prompts::CustomPrompt; use codex_protocol::custom_prompts::PROMPTS_CMD_PREFIX; use std::collections::HashSet; +fn windows_degraded_sandbox_active() -> bool { + cfg!(target_os = "windows") + && codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled() +} + /// A selectable item in the popup: either a built-in command or a user prompt. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum CommandItem { @@ -32,9 +38,11 @@ pub(crate) struct CommandPopup { impl CommandPopup { pub(crate) fn new(mut prompts: Vec, skills_enabled: bool) -> Self { + let allow_elevate_sandbox = windows_degraded_sandbox_active(); let builtins: Vec<(&'static str, SlashCommand)> = built_in_slash_commands() .into_iter() .filter(|(_, cmd)| skills_enabled || *cmd != SlashCommand::Skills) + .filter(|(_, cmd)| allow_elevate_sandbox || *cmd != SlashCommand::ElevateSandbox) .collect(); // Exclude prompts that collide with builtin command names and sort by name. let exclude: HashSet = builtins.iter().map(|(n, _)| (*n).to_string()).collect(); diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs index 27c7dc4233e9..e5ac6b072879 100644 --- a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -13,7 +13,6 @@ use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; -use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; use crate::render::Insets; @@ -396,10 +395,8 @@ impl Renderable for ListSelectionView { if self.is_searchable { height = height.saturating_add(1); } - if let Some(note) = &self.footer_note { - let note_width = width.saturating_sub(2); - let note_lines = wrap_styled_line(note, note_width); - height = height.saturating_add(note_lines.len() as u16); + if self.footer_note.is_some() { + height = height.saturating_add(1); } if self.footer_hint.is_some() { height = height.saturating_add(1); @@ -412,15 +409,13 @@ impl Renderable for ListSelectionView { return; } - let note_width = area.width.saturating_sub(2); - let note_lines = self - .footer_note - .as_ref() - .map(|note| wrap_styled_line(note, note_width)); - let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); - let footer_rows = note_height + u16::from(self.footer_hint.is_some()); - let [content_area, footer_area] = - Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); + let footer_rows = + u16::from(self.footer_note.is_some()) + u16::from(self.footer_hint.is_some()); + let [content_area, footer_area] = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(footer_rows), + ]) + .areas(area); Block::default() .style(user_message_style()) @@ -490,30 +485,19 @@ impl Renderable for ListSelectionView { if footer_area.height > 0 { let [note_area, hint_area] = Layout::vertical([ - Constraint::Length(note_height), + Constraint::Length(if self.footer_note.is_some() { 1 } else { 0 }), Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), ]) .areas(footer_area); - if let Some(lines) = note_lines { + if let Some(note) = &self.footer_note { let note_area = Rect { x: note_area.x + 2, y: note_area.y, width: note_area.width.saturating_sub(2), height: note_area.height, }; - for (idx, line) in lines.iter().enumerate() { - if idx as u16 >= note_area.height { - break; - } - let line_area = Rect { - x: note_area.x, - y: note_area.y + idx as u16, - width: note_area.width, - height: 1, - }; - line.clone().render(line_area, buf); - } + note.clone().render(note_area, buf); } if let Some(hint) = &self.footer_hint { @@ -611,38 +595,6 @@ mod tests { assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } - #[test] - fn snapshot_footer_note_wraps() { - let (tx_raw, _rx) = unbounded_channel::(); - let tx = AppEventSender::new(tx_raw); - let items = vec![SelectionItem { - name: "Read Only".to_string(), - description: Some("Codex can read files".to_string()), - is_current: true, - dismiss_on_select: true, - ..Default::default() - }]; - let footer_note = Line::from(vec![ - "Note: ".dim(), - "Use /setup-elevated-sandbox".cyan(), - " to allow network access.".dim(), - ]); - let view = ListSelectionView::new( - SelectionViewParams { - title: Some("Select Approval Mode".to_string()), - footer_note: Some(footer_note), - footer_hint: Some(standard_popup_hint_line()), - items, - ..Default::default() - }, - tx, - ); - assert_snapshot!( - "list_selection_footer_note_wraps", - render_lines_with_width(&view, 40) - ); - } - #[test] fn renders_search_query_line_when_enabled() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 4b6caf0d1aa8..f14d5149c3f5 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -276,6 +276,11 @@ impl BottomPane { self.composer.current_text() } + pub(crate) fn set_composer_input_enabled(&mut self, enabled: bool, placeholder: Option) { + self.composer.set_input_enabled(enabled, placeholder); + self.request_redraw(); + } + /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index bd65066300bb..171d7fa56fed 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -10,6 +10,7 @@ use codex_backend_client::Client as BackendClient; use codex_core::config::Config; use codex_core::config::ConstraintResult; use codex_core::config::types::Notifications; +use codex_core::features::Feature; use codex_core::git_info::current_branch_name; use codex_core::git_info::local_git_branches; use codex_core::models_manager::manager::ModelsManager; @@ -83,6 +84,8 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::app_event::AppEvent; +use crate::app_event::WindowsSandboxEnableMode; +use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; @@ -1570,6 +1573,43 @@ impl ChatWidget { SlashCommand::Approvals => { self.open_approvals_popup(); } + SlashCommand::ElevateSandbox => { + #[cfg(target_os = "windows")] + { + let windows_degraded_sandbox_enabled = + codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled(); + if !windows_degraded_sandbox_enabled { + // This command should not be visible/recognized outside degraded mode, + // but guard anyway in case something dispatches it directly. + return; + } + + let Some(preset) = builtin_approval_presets() + .into_iter() + .find(|preset| preset.id == "auto") + else { + // Avoid panicking in interactive UI; treat this as a recoverable + // internal error. + self.add_error_message( + "Internal error: missing the 'auto' approval preset.".to_string(), + ); + return; + }; + + if let Err(err) = self.config.approval_policy.can_set(&preset.approval) { + self.add_error_message(err.to_string()); + return; + } + + self.app_event_tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + } + #[cfg(not(target_os = "windows"))] + { + // Not supported; on non-Windows this command should never be reachable. + return; + } + } SlashCommand::Quit | SlashCommand::Exit => { self.request_exit(); } @@ -2593,10 +2633,24 @@ impl ChatWidget { let current_sandbox = self.config.sandbox_policy.get(); let mut items: Vec = Vec::new(); let presets: Vec = builtin_approval_presets(); + + #[cfg(target_os = "windows")] + let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox().is_some() + && !codex_core::is_windows_elevated_sandbox_enabled(); + #[cfg(not(target_os = "windows"))] + let windows_degraded_sandbox_enabled = false; + + let show_elevate_sandbox_hint = windows_degraded_sandbox_enabled + && presets.iter().any(|preset| preset.id == "auto"); + for preset in presets.into_iter() { let is_current = Self::preset_matches_current(current_approval, current_sandbox, &preset); - let name = preset.label.to_string(); + let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { + "Agent (degraded)".to_string() + } else { + preset.label.to_string() + }; let description_text = preset.description; let description = Some(description_text.to_string()); let requires_confirmation = preset.id == "full-access" @@ -2616,12 +2670,24 @@ impl ChatWidget { #[cfg(target_os = "windows")] { if codex_core::get_platform_sandbox().is_none() { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { - preset: preset_clone.clone(), - }); - })] + if codex_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) { + let preset_clone = preset.clone(); + vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset_clone.clone(), + mode: WindowsSandboxEnableMode::Elevated, + }); + })] + } else { + let preset_clone = preset.clone(); + 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() { @@ -2655,8 +2721,18 @@ impl ChatWidget { }); } + let footer_note = show_elevate_sandbox_hint.then(|| { + vec![ + "To upgrade to the elevated sandbox, run ".dim(), + "/elevate-sandbox".cyan(), + ".".dim(), + ] + .into() + }); + self.bottom_pane.show_selection_view(SelectionViewParams { title: Some("Select Approval Mode".to_string()), + footer_note, footer_hint: Some(standard_popup_hint_line()), items, header: Box::new(()), @@ -2917,29 +2993,105 @@ impl ChatWidget { 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" - ], + line!["Codex works best in Agent mode.".bold()], + line!["To use Agent mode on Windows, we need to configure the sandbox."], + line!["This setup requires elevation. Do you accept?"], + line!["Learn more: https://developers.openai.com/codex/windows"], ]) .wrap(Wrap { trim: false }), )); - let preset_clone = preset; + let preset_accept = preset.clone(); + let preset_decline = preset; + let items = vec![ + SelectionItem { + name: "Yes, I accept".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset_accept.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "No, I do not accept".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { + preset: preset_decline.clone(), + reason: WindowsSandboxFallbackReason::DeclinedElevation, + }); + })], + 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, + reason: WindowsSandboxFallbackReason, + ) { + use ratatui_macros::line; + + let mut lines = Vec::new(); + if reason == WindowsSandboxFallbackReason::ElevationFailed { + lines.push(line!["The elevated setup did not complete.".bold()]); + } + lines.push(line![ + "You can still use a degraded sandbox without elevation." + ]); + lines.push(line!["It is less watertight, but still secure."]); + lines.push(line![ + "Learn more: https://developers.openai.com/codex/windows" + ]); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + + let preset_retry = preset.clone(); + let preset_degraded = preset; let items = vec![ SelectionItem { - name: "Enable experimental sandbox".to_string(), + name: "Try elevated setup again".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset_retry.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use degraded sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), + preset: preset_degraded.clone(), + mode: WindowsSandboxEnableMode::Legacy, }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "Go back".to_string(), + name: "Use no sandbox".to_string(), description: None, actions: vec![Box::new(|tx| { tx.send(AppEvent::OpenApprovalsPopup); @@ -2959,7 +3111,12 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, _preset: ApprovalPreset) {} + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + _preset: ApprovalPreset, + _reason: WindowsSandboxFallbackReason, + ) { + } #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { @@ -2976,6 +3133,36 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) {} + #[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( + false, + Some("Input disabled until setup completes.".to_string()), + ); + self.bottom_pane.ensure_status_indicator(); + self.bottom_pane.set_interrupt_hint_visible(false); + self.set_status_header( + "Setting up the elevated Windows sandbox (this may take a minute or more). You'll stay in your current mode until it's done." + .to_string(), + ); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + 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(true, 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) {} + #[cfg(target_os = "windows")] pub(crate) fn clear_forced_auto_mode_downgrade(&mut self) { self.config.forced_auto_mode_downgraded_on_windows = false; @@ -3008,6 +3195,14 @@ impl ChatWidget { Ok(()) } + pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { + if enabled { + self.config.features.enable(feature); + } else { + self.config.features.disable(feature); + } + } + pub(crate) fn set_full_access_warning_acknowledged(&mut self, acknowledged: bool) { self.config.notices.hide_full_access_warning = Some(acknowledged); } diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 09f5073e75e2..1d7e20e636f6 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -10,6 +10,7 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; +use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; use codex_core::protocol::AgentMessageEvent; @@ -72,6 +73,11 @@ fn set_windows_sandbox_enabled(enabled: bool) { codex_core::set_windows_sandbox_enabled(enabled); } +#[cfg(target_os = "windows")] +fn set_windows_elevated_sandbox_enabled(enabled: bool) { + codex_core::set_windows_elevated_sandbox_enabled(enabled); +} + async fn test_config() -> Config { // Use base defaults to avoid depending on host state. let codex_home = std::env::temp_dir(); @@ -1786,6 +1792,31 @@ async fn approvals_selection_popup_snapshot() { assert_snapshot!("approvals_selection_popup", popup); } +#[cfg(target_os = "windows")] +#[tokio::test] +async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + + chat.config.notices.hide_full_access_warning = None; + chat.config.features.enable(Feature::WindowsSandbox); + chat.config + .features + .disable(Feature::WindowsSandboxElevated); + set_windows_sandbox_enabled(true); + set_windows_elevated_sandbox_enabled(false); + + chat.open_approvals_popup(); + + let popup = render_bottom_popup(&chat, 80); + insta::with_settings!({ snapshot_suffix => "windows_degraded" }, { + assert_snapshot!("approvals_selection_popup", popup); + }); + + // Avoid leaking sandbox global state into other tests. + set_windows_sandbox_enabled(true); + set_windows_elevated_sandbox_enabled(false); +} + #[tokio::test] async fn preset_matching_ignores_extra_writable_roots() { let preset = builtin_approval_presets() @@ -1836,8 +1867,8 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("Agent mode on Windows uses an experimental sandbox"), - "expected auto mode prompt to mention enabling the sandbox feature, popup: {popup}" + popup.contains("requires elevation"), + "expected auto mode prompt to mention elevation, popup: {popup}" ); } @@ -1853,12 +1884,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { let popup = render_bottom_popup(&chat, 120); assert!( - popup.contains("Agent mode on Windows uses an experimental sandbox"), - "expected startup prompt to explain sandbox: {popup}" + popup.contains("requires elevation"), + "expected startup prompt to explain elevation: {popup}" ); assert!( - popup.contains("Enable experimental sandbox"), - "expected startup prompt to offer enabling the sandbox: {popup}" + popup.contains("Yes, I accept"), + "expected startup prompt to offer accepting elevation: {popup}" ); set_windows_sandbox_enabled(true); diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index 8fe5de76603c..0b8cb54afebc 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + ElevateSandbox, Skills, Review, New, @@ -51,6 +52,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", + SlashCommand::ElevateSandbox => "upgrade to the elevated Windows sandbox", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", @@ -74,6 +76,7 @@ impl SlashCommand { // | SlashCommand::Undo | SlashCommand::Model | SlashCommand::Approvals + | SlashCommand::ElevateSandbox | SlashCommand::Review | SlashCommand::Logout => false, SlashCommand::Diff diff --git a/codex-rs/windows-sandbox-rs/src/identity.rs b/codex-rs/windows-sandbox-rs/src/identity.rs index 835acc5d8b3a..b195e36b9171 100644 --- a/codex-rs/windows-sandbox-rs/src/identity.rs +++ b/codex-rs/windows-sandbox-rs/src/identity.rs @@ -30,6 +30,18 @@ pub struct SandboxCreds { pub password: String, } +/// Returns true when the on-disk setup artifacts exist and match the current +/// setup version. +/// +/// This reuses the same marker/users validation used by `require_logon_sandbox_creds`. +pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool { + let marker_ok = matches!(load_marker(codex_home), Ok(Some(marker)) if marker.version_matches()); + if !marker_ok { + return false; + } + matches!(load_users(codex_home), Ok(Some(users)) if users.version_matches()) +} + fn load_marker(codex_home: &Path) -> Result> { let path = setup_marker_path(codex_home); let marker = match fs::read_to_string(&path) { diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index a336b00fae89..c4457845c26c 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -45,6 +45,8 @@ pub use hide_users::hide_newly_created_users; #[cfg(target_os = "windows")] pub use identity::require_logon_sandbox_creds; #[cfg(target_os = "windows")] +pub use identity::sandbox_setup_is_complete; +#[cfg(target_os = "windows")] pub use logging::log_note; #[cfg(target_os = "windows")] pub use logging::LOG_FILE_NAME; From 4276c6e3dbe5a9c4bc73d9b837e3891fc92c9c89 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 11:36:53 -0800 Subject: [PATCH 02/18] fix unused var --- codex-rs/tui/src/app.rs | 2 +- codex-rs/tui2/src/app.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8cfe0c3c1f59..bbbf10c674bf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -851,7 +851,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = (preset, mode); + let _ = preset; } } AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index fb88c24791b3..b58992c70e30 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1628,7 +1628,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = (preset, mode); + let _ = preset; } } AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { From e5a52ab0dc6113795b31bdbd719df6821d4a0f85 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 11:37:10 -0800 Subject: [PATCH 03/18] snapshots --- ...pprovals_selection_popup@windows_degraded.snap | 15 +++++++++++++++ ...pprovals_selection_popup@windows_degraded.snap | 15 +++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap create mode 100644 codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap new file mode 100644 index 000000000000..6ec90ecba85e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -0,0 +1,15 @@ +--- +source: tui/src/chatwidget/tests.rs +assertion_line: 2003 +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent (degraded) Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + To upgrade to the elevated sandbox, run /elevate-sandbox. + Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap new file mode 100644 index 000000000000..3fc59e006809 --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -0,0 +1,15 @@ +--- +source: tui2/src/chatwidget/tests.rs +assertion_line: 1773 +expression: popup +--- + Select Approval Mode + +› 1. Read Only (current) Requires approval to edit files and run commands. + 2. Agent (degraded) Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace and run + commands with network access. Exercise caution when + using. + + To upgrade to the elevated sandbox, run /elevate-sandbox. + Press enter to confirm or esc to go back From e4847108b66f8bafa9be904aef9c4a783dbceec9 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 12:09:01 -0800 Subject: [PATCH 04/18] fix build issues, forgot to add a file. --- codex-rs/core/src/windows_sandbox.rs | 43 +++++++++++++++++++++++++++ codex-rs/tui/src/chatwidget/tests.rs | 7 +++-- codex-rs/tui2/src/chatwidget/tests.rs | 7 +++-- 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 codex-rs/core/src/windows_sandbox.rs diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs new file mode 100644 index 000000000000..4518d44ce194 --- /dev/null +++ b/codex-rs/core/src/windows_sandbox.rs @@ -0,0 +1,43 @@ +use crate::protocol::SandboxPolicy; +use std::collections::HashMap; +use std::path::Path; + +#[cfg(target_os = "windows")] +pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool { + codex_windows_sandbox::sandbox_setup_is_complete(codex_home) +} + +#[cfg(not(target_os = "windows"))] +pub fn sandbox_setup_is_complete(_codex_home: &Path) -> bool { + false +} + +#[cfg(target_os = "windows")] +pub fn run_elevated_setup( + policy: &SandboxPolicy, + policy_cwd: &Path, + command_cwd: &Path, + env_map: &HashMap, + codex_home: &Path, +) -> anyhow::Result<()> { + codex_windows_sandbox::run_elevated_setup( + policy, + policy_cwd, + command_cwd, + env_map, + codex_home, + None, + None, + ) +} + +#[cfg(not(target_os = "windows"))] +pub fn run_elevated_setup( + _policy: &SandboxPolicy, + _policy_cwd: &Path, + _command_cwd: &Path, + _env_map: &HashMap, + _codex_home: &Path, +) -> anyhow::Result<()> { + anyhow::bail!("elevated Windows sandbox setup is only supported on Windows") +} diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 906914699331..535a917ca851 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2037,6 +2037,9 @@ async fn approvals_selection_popup_snapshot() { async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some(); + let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled(); + chat.config.notices.hide_full_access_warning = None; chat.config.features.enable(Feature::WindowsSandbox); chat.config @@ -2053,8 +2056,8 @@ async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { }); // Avoid leaking sandbox global state into other tests. - set_windows_sandbox_enabled(true); - set_windows_elevated_sandbox_enabled(false); + set_windows_sandbox_enabled(was_sandbox_enabled); + set_windows_elevated_sandbox_enabled(was_elevated_enabled); } #[tokio::test] diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 1d7e20e636f6..cd37d706ddcc 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -1797,6 +1797,9 @@ async fn approvals_selection_popup_snapshot() { async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; + let was_sandbox_enabled = codex_core::get_platform_sandbox().is_some(); + let was_elevated_enabled = codex_core::is_windows_elevated_sandbox_enabled(); + chat.config.notices.hide_full_access_warning = None; chat.config.features.enable(Feature::WindowsSandbox); chat.config @@ -1813,8 +1816,8 @@ async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { }); // Avoid leaking sandbox global state into other tests. - set_windows_sandbox_enabled(true); - set_windows_elevated_sandbox_enabled(false); + set_windows_sandbox_enabled(was_sandbox_enabled); + set_windows_elevated_sandbox_enabled(was_elevated_enabled); } #[tokio::test] From 0def6e5ce5ff035e930b1937dee6c6edebad1950 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 13:37:04 -0800 Subject: [PATCH 05/18] fix non-Windows build errors --- codex-rs/tui/src/app.rs | 4 +++- codex-rs/tui/src/app_event.rs | 2 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 + codex-rs/tui/src/bottom_pane/mod.rs | 7 ++++++- codex-rs/tui/src/chatwidget.rs | 4 +++- codex-rs/tui2/src/app.rs | 4 +++- codex-rs/tui2/src/app_event.rs | 2 ++ codex-rs/tui2/src/bottom_pane/chat_composer.rs | 1 + codex-rs/tui2/src/bottom_pane/mod.rs | 7 ++++++- codex-rs/tui2/src/chatwidget.rs | 4 +++- 10 files changed, 30 insertions(+), 6 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index bbbf10c674bf..295472eef7bf 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,6 +1,8 @@ use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; +#[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; +#[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; @@ -932,7 +934,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, mode); } } AppEvent::PersistModelSelection { model, effort } => { diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cede796c89ce..6d8d2e7f5678 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -16,12 +16,14 @@ use codex_core::protocol::SandboxPolicy; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { Elevated, Legacy, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxFallbackReason { DeclinedElevation, ElevationFailed, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a72a0ecc031b..940a9875bb61 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1913,6 +1913,7 @@ impl ChatComposer { self.has_focus = has_focus; } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; self.input_disabled_placeholder = if enabled { None } else { placeholder }; diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index f5726f106660..832b827cebec 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -298,7 +298,12 @@ impl BottomPane { self.request_redraw(); } - pub(crate) fn set_composer_input_enabled(&mut self, enabled: bool, placeholder: Option) { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_composer_input_enabled( + &mut self, + enabled: bool, + placeholder: Option, + ) { self.composer.set_input_enabled(enabled, placeholder); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 576001ad07c0..ba282ef22bc6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -85,6 +85,7 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::app_event::AppEvent; +#[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; @@ -1773,7 +1774,7 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] { // Not supported; on non-Windows this command should never be reachable. - return; + () } } SlashCommand::Experimental => { @@ -3420,6 +3421,7 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} #[cfg(target_os = "windows")] diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index b58992c70e30..e838a71f74d3 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1,6 +1,8 @@ use crate::app_backtrack::BacktrackState; use crate::app_event::AppEvent; +#[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; +#[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::ApprovalRequest; @@ -1709,7 +1711,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, mode); } } AppEvent::PersistModelSelection { model, effort } => { diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index a73fd6781e54..9b0595382860 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -15,12 +15,14 @@ use codex_core::protocol::SandboxPolicy; use codex_protocol::openai_models::ReasoningEffort; #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxEnableMode { Elevated, Legacy, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxFallbackReason { DeclinedElevation, ElevationFailed, diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 975f89fbced0..50a9344cbcb8 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1865,6 +1865,7 @@ impl ChatComposer { self.has_focus = has_focus; } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; self.input_disabled_placeholder = if enabled { None } else { placeholder }; diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index f14d5149c3f5..40b4ab9be66b 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -276,7 +276,12 @@ impl BottomPane { self.composer.current_text() } - pub(crate) fn set_composer_input_enabled(&mut self, enabled: bool, placeholder: Option) { + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + pub(crate) fn set_composer_input_enabled( + &mut self, + enabled: bool, + placeholder: Option, + ) { self.composer.set_input_enabled(enabled, placeholder); self.request_redraw(); } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 171d7fa56fed..00b7df4d2dd8 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -84,6 +84,7 @@ use tokio::task::JoinHandle; use tracing::debug; use crate::app_event::AppEvent; +#[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; use crate::app_event::WindowsSandboxFallbackReason; use crate::app_event_sender::AppEventSender; @@ -1607,7 +1608,7 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] { // Not supported; on non-Windows this command should never be reachable. - return; + () } } SlashCommand::Quit | SlashCommand::Exit => { @@ -3151,6 +3152,7 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} #[cfg(target_os = "windows")] From 994033aeb76c5bcda10afdc615cb898e2eccb790 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 13:49:39 -0800 Subject: [PATCH 06/18] fix lint error --- codex-rs/tui/src/chatwidget.rs | 3 +-- codex-rs/tui2/src/chatwidget.rs | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index ba282ef22bc6..142908cd1e61 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1774,8 +1774,7 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] { // Not supported; on non-Windows this command should never be reachable. - () - } + }; } SlashCommand::Experimental => { self.open_experimental_popup(); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 00b7df4d2dd8..110522b3cfa0 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1608,8 +1608,7 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] { // Not supported; on non-Windows this command should never be reachable. - () - } + }; } SlashCommand::Quit | SlashCommand::Exit => { self.request_exit(); From 20906ff81766634486774d0d69d9d0f51747f400 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 14:07:14 -0800 Subject: [PATCH 07/18] build errors --- codex-rs/tui/src/chatwidget.rs | 1 + codex-rs/tui2/src/chatwidget.rs | 1 + codex-rs/tui2/src/chatwidget/tests.rs | 1 + 3 files changed, 3 insertions(+) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 142908cd1e61..5efb85345425 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3465,6 +3465,7 @@ impl ChatWidget { Ok(()) } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { if enabled { self.config.features.enable(feature); diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 110522b3cfa0..ac864cd9530a 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -3196,6 +3196,7 @@ impl ChatWidget { Ok(()) } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_feature_enabled(&mut self, feature: Feature, enabled: bool) { if enabled { self.config.features.enable(feature); diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index cd37d706ddcc..3d294e62ea32 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -10,6 +10,7 @@ use codex_core::CodexAuth; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::Constrained; +#[cfg(target_os = "windows")] use codex_core::features::Feature; use codex_core::models_manager::manager::ModelsManager; use codex_core::protocol::AgentMessageDeltaEvent; From 46ab95939204b3f3c69cd8730ed64ab85dce408e Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 14:13:27 -0800 Subject: [PATCH 08/18] cargo fmt --- codex-rs/core/src/lib.rs | 2 +- codex-rs/tui/src/bottom_pane/chat_composer.rs | 3 ++- codex-rs/tui/src/bottom_pane/list_selection_view.rs | 10 ++++------ codex-rs/tui/src/chatwidget.rs | 13 +++++++------ codex-rs/tui2/src/bottom_pane/chat_composer.rs | 4 +++- .../tui2/src/bottom_pane/list_selection_view.rs | 7 ++----- codex-rs/tui2/src/chatwidget.rs | 13 +++++++------ 7 files changed, 26 insertions(+), 26 deletions(-) diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index daed9637d96d..1fb25ebc1382 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -115,9 +115,9 @@ pub use command_safety::is_safe_command; pub use exec_policy::ExecPolicyError; pub use exec_policy::load_exec_policy; pub use safety::get_platform_sandbox; -pub use safety::set_windows_sandbox_enabled; pub use safety::is_windows_elevated_sandbox_enabled; pub use safety::set_windows_elevated_sandbox_enabled; +pub use safety::set_windows_sandbox_enabled; // Re-export the protocol types from the standalone `codex-protocol` crate so existing // `codex_core::protocol::...` references continue to work across the workspace. pub use codex_protocol::protocol; diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 940a9875bb61..a0309beb1120 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1235,7 +1235,8 @@ impl ChatComposer { && let Some((_n, cmd)) = built_in_slash_commands() .into_iter() .filter(|(_, cmd)| { - windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + windows_degraded_sandbox_active() + || *cmd != SlashCommand::ElevateSandbox }) .find(|(n, _)| *n == name) { diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 281918fb7b7f..432b9ac5edca 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -452,12 +452,10 @@ impl Renderable for ListSelectionView { return; } - let footer_rows = u16::from(self.footer_note.is_some()) + u16::from(self.footer_hint.is_some()); - let [content_area, footer_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(footer_rows), - ]) - .areas(area); + let footer_rows = + u16::from(self.footer_note.is_some()) + u16::from(self.footer_hint.is_some()); + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); Block::default() .style(user_message_style()) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 5efb85345425..f4ba0e2e142d 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1743,9 +1743,9 @@ impl ChatWidget { SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] { - let windows_degraded_sandbox_enabled = - codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled(); + let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox() + .is_some() + && !codex_core::is_windows_elevated_sandbox_enabled(); if !windows_degraded_sandbox_enabled { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. @@ -1769,7 +1769,8 @@ impl ChatWidget { return; } - self.app_event_tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); } #[cfg(not(target_os = "windows"))] { @@ -2886,8 +2887,8 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; - let show_elevate_sandbox_hint = windows_degraded_sandbox_enabled - && presets.iter().any(|preset| preset.id == "auto"); + let show_elevate_sandbox_hint = + windows_degraded_sandbox_enabled && presets.iter().any(|preset| preset.id == "auto"); for preset in presets.into_iter() { let is_current = diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 50a9344cbcb8..0aba5c022040 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1729,7 +1729,9 @@ impl ChatComposer { let builtin_match = built_in_slash_commands() .into_iter() - .filter(|(_, cmd)| windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox) + .filter(|(_, cmd)| { + windows_degraded_sandbox_active() || *cmd != SlashCommand::ElevateSandbox + }) .any(|(cmd_name, _)| fuzzy_match(cmd_name, name).is_some()); if builtin_match { diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs index e5ac6b072879..3b1afcc0fb5b 100644 --- a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -411,11 +411,8 @@ impl Renderable for ListSelectionView { let footer_rows = u16::from(self.footer_note.is_some()) + u16::from(self.footer_hint.is_some()); - let [content_area, footer_area] = Layout::vertical([ - Constraint::Fill(1), - Constraint::Length(footer_rows), - ]) - .areas(area); + let [content_area, footer_area] = + Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); Block::default() .style(user_message_style()) diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ac864cd9530a..32584246f77b 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1577,9 +1577,9 @@ impl ChatWidget { SlashCommand::ElevateSandbox => { #[cfg(target_os = "windows")] { - let windows_degraded_sandbox_enabled = - codex_core::get_platform_sandbox().is_some() - && !codex_core::is_windows_elevated_sandbox_enabled(); + let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox() + .is_some() + && !codex_core::is_windows_elevated_sandbox_enabled(); if !windows_degraded_sandbox_enabled { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. @@ -1603,7 +1603,8 @@ impl ChatWidget { return; } - self.app_event_tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + self.app_event_tx + .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); } #[cfg(not(target_os = "windows"))] { @@ -2640,8 +2641,8 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; - let show_elevate_sandbox_hint = windows_degraded_sandbox_enabled - && presets.iter().any(|preset| preset.id == "auto"); + let show_elevate_sandbox_hint = + windows_degraded_sandbox_enabled && presets.iter().any(|preset| preset.id == "auto"); for preset in presets.into_iter() { let is_current = From 234b0df8eafba4b2c2b1e3207b62a183d417dce9 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 15:09:29 -0800 Subject: [PATCH 09/18] include a kill switch to revert to previous NUX --- codex-rs/core/src/windows_sandbox.rs | 6 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 1 + codex-rs/tui/src/bottom_pane/command_popup.rs | 1 + codex-rs/tui/src/chatwidget.rs | 67 ++++++++++++++++--- .../tui2/src/bottom_pane/chat_composer.rs | 1 + .../tui2/src/bottom_pane/command_popup.rs | 1 + codex-rs/tui2/src/chatwidget.rs | 67 ++++++++++++++++--- 7 files changed, 128 insertions(+), 16 deletions(-) diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs index 4518d44ce194..b355bad2802a 100644 --- a/codex-rs/core/src/windows_sandbox.rs +++ b/codex-rs/core/src/windows_sandbox.rs @@ -2,6 +2,12 @@ use crate::protocol::SandboxPolicy; use std::collections::HashMap; use std::path::Path; +/// Kill switch for the elevated sandbox NUX on Windows. +/// +/// When false, revert to the previous sandbox NUX, which only +/// prompts users to enable the legacy sandbox feature. +pub const ELEVATED_SANDBOX_NUX_ENABLED: bool = true; + #[cfg(target_os = "windows")] pub fn sandbox_setup_is_complete(codex_home: &Path) -> bool { codex_windows_sandbox::sandbox_setup_is_complete(codex_home) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index a0309beb1120..4c473b1fa919 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -66,6 +66,7 @@ use std::time::Instant; fn windows_degraded_sandbox_active() -> bool { cfg!(target_os = "windows") + && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && codex_core::get_platform_sandbox().is_some() && !codex_core::is_windows_elevated_sandbox_enabled() } diff --git a/codex-rs/tui/src/bottom_pane/command_popup.rs b/codex-rs/tui/src/bottom_pane/command_popup.rs index 6878805525a3..ec4e86af03ee 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -17,6 +17,7 @@ use std::collections::HashSet; fn windows_degraded_sandbox_active() -> bool { cfg!(target_os = "windows") + && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && codex_core::get_platform_sandbox().is_some() && !codex_core::is_windows_elevated_sandbox_enabled() } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index f4ba0e2e142d..2e60a7819aa6 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1746,7 +1746,9 @@ impl ChatWidget { let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox() .is_some() && !codex_core::is_windows_elevated_sandbox_enabled(); - if !windows_degraded_sandbox_enabled { + if !windows_degraded_sandbox_enabled + || !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. return; @@ -2887,8 +2889,9 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; - let show_elevate_sandbox_hint = - windows_degraded_sandbox_enabled && presets.iter().any(|preset| preset.id == "auto"); + let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && windows_degraded_sandbox_enabled + && presets.iter().any(|preset| preset.id == "auto"); for preset in presets.into_iter() { let is_current = @@ -2920,10 +2923,12 @@ impl ChatWidget { #[cfg(target_os = "windows")] { if codex_core::get_platform_sandbox().is_none() { - if codex_core::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) { - let preset_clone = preset.clone(); + let preset_clone = preset.clone(); + if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && codex_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(), @@ -2931,7 +2936,6 @@ impl ChatWidget { }); })] } else { - let preset_clone = preset.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { preset: preset_clone.clone(), @@ -3260,6 +3264,53 @@ impl ChatWidget { pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { use ratatui_macros::line; + if !codex_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; + } + let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 0aba5c022040..54abf044e7c8 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -69,6 +69,7 @@ use std::time::Instant; fn windows_degraded_sandbox_active() -> bool { cfg!(target_os = "windows") + && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && codex_core::get_platform_sandbox().is_some() && !codex_core::is_windows_elevated_sandbox_enabled() } diff --git a/codex-rs/tui2/src/bottom_pane/command_popup.rs b/codex-rs/tui2/src/bottom_pane/command_popup.rs index e0b5e262339e..2fee23f1b650 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -17,6 +17,7 @@ use std::collections::HashSet; fn windows_degraded_sandbox_active() -> bool { cfg!(target_os = "windows") + && codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED && codex_core::get_platform_sandbox().is_some() && !codex_core::is_windows_elevated_sandbox_enabled() } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 32584246f77b..ba591b317037 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -1580,7 +1580,9 @@ impl ChatWidget { let windows_degraded_sandbox_enabled = codex_core::get_platform_sandbox() .is_some() && !codex_core::is_windows_elevated_sandbox_enabled(); - if !windows_degraded_sandbox_enabled { + if !windows_degraded_sandbox_enabled + || !codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + { // This command should not be visible/recognized outside degraded mode, // but guard anyway in case something dispatches it directly. return; @@ -2641,8 +2643,9 @@ impl ChatWidget { #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; - let show_elevate_sandbox_hint = - windows_degraded_sandbox_enabled && presets.iter().any(|preset| preset.id == "auto"); + let show_elevate_sandbox_hint = codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && windows_degraded_sandbox_enabled + && presets.iter().any(|preset| preset.id == "auto"); for preset in presets.into_iter() { let is_current = @@ -2671,10 +2674,12 @@ impl ChatWidget { #[cfg(target_os = "windows")] { if codex_core::get_platform_sandbox().is_none() { - if codex_core::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) { - let preset_clone = preset.clone(); + let preset_clone = preset.clone(); + if codex_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && codex_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(), @@ -2682,7 +2687,6 @@ impl ChatWidget { }); })] } else { - let preset_clone = preset.clone(); vec![Box::new(move |tx| { tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { preset: preset_clone.clone(), @@ -2991,6 +2995,53 @@ impl ChatWidget { pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { use ratatui_macros::line; + if !codex_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; + } + let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ From c9516e9f6290d96a671ec0376d9925e71a38ae23 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Tue, 6 Jan 2026 22:45:13 -0800 Subject: [PATCH 10/18] copy/functionality update --- codex-rs/tui/src/app.rs | 4 +- codex-rs/tui/src/app_event.rs | 1 - .../src/bottom_pane/list_selection_view.rs | 33 +++-- codex-rs/tui/src/chatwidget.rs | 114 ++++++++++++------ ...vals_selection_popup@windows_degraded.snap | 2 +- codex-rs/tui/src/chatwidget/tests.rs | 8 +- codex-rs/tui/src/slash_command.rs | 3 +- codex-rs/tui2/src/app.rs | 4 +- codex-rs/tui2/src/app_event.rs | 1 - .../src/bottom_pane/list_selection_view.rs | 33 +++-- codex-rs/tui2/src/chatwidget.rs | 114 ++++++++++++------ ...vals_selection_popup@windows_degraded.snap | 2 +- codex-rs/tui2/src/chatwidget/tests.rs | 8 +- codex-rs/tui2/src/slash_command.rs | 3 +- 14 files changed, 228 insertions(+), 102 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 295472eef7bf..9e5ac2d95e4e 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -911,10 +911,10 @@ impl App { self.chat_widget.add_info_message( match mode { WindowsSandboxEnableMode::Elevated => { - "Enabled elevated Windows sandbox.".to_string() + "Enabled elevated agent sandbox.".to_string() } WindowsSandboxEnableMode::Legacy => { - "Enabled degraded Windows sandbox.".to_string() + "Enabled non-elevated agent sandbox.".to_string() } }, None, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 6d8d2e7f5678..861ba2a54c5b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -25,7 +25,6 @@ pub(crate) enum WindowsSandboxEnableMode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxFallbackReason { - DeclinedElevation, ElevationFailed, } diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 432b9ac5edca..fd5122771d2e 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -20,6 +20,7 @@ use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; +use super::selection_popup_common::wrap_styled_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -438,8 +439,10 @@ impl Renderable for ListSelectionView { if self.is_searchable { height = height.saturating_add(1); } - if self.footer_note.is_some() { - height = height.saturating_add(1); + if let Some(note) = &self.footer_note { + let note_width = width.saturating_sub(2); + let note_lines = wrap_styled_line(note, note_width); + height = height.saturating_add(note_lines.len() as u16); } if self.footer_hint.is_some() { height = height.saturating_add(1); @@ -452,8 +455,13 @@ impl Renderable for ListSelectionView { return; } - let footer_rows = - u16::from(self.footer_note.is_some()) + u16::from(self.footer_hint.is_some()); + let note_width = area.width.saturating_sub(2); + let note_lines = self + .footer_note + .as_ref() + .map(|note| wrap_styled_line(note, note_width)); + let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); + let footer_rows = note_height + u16::from(self.footer_hint.is_some()); let [content_area, footer_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); @@ -525,19 +533,30 @@ impl Renderable for ListSelectionView { if footer_area.height > 0 { let [note_area, hint_area] = Layout::vertical([ - Constraint::Length(if self.footer_note.is_some() { 1 } else { 0 }), + Constraint::Length(note_height), Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), ]) .areas(footer_area); - if let Some(note) = &self.footer_note { + if let Some(lines) = note_lines { let note_area = Rect { x: note_area.x + 2, y: note_area.y, width: note_area.width.saturating_sub(2), height: note_area.height, }; - note.clone().render(note_area, buf); + for (idx, line) in lines.iter().enumerate() { + if idx as u16 >= note_area.height { + break; + } + let line_area = Rect { + x: note_area.x, + y: note_area.y + idx as u16, + width: note_area.width, + height: 1, + }; + line.clone().render(line_area, buf); + } } if let Some(hint) = &self.footer_hint { diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2e60a7819aa6..0b96c75a5eea 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2897,7 +2897,7 @@ impl ChatWidget { let is_current = Self::preset_matches_current(current_approval, current_sandbox, &preset); let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Agent (degraded)".to_string() + "Agent (non-elevated sandbox)".to_string() } else { preset.label.to_string() }; @@ -2978,8 +2978,8 @@ impl ChatWidget { let footer_note = show_elevate_sandbox_hint.then(|| { vec![ - "To upgrade to the elevated sandbox, run ".dim(), - "/elevate-sandbox".cyan(), + "The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(), + "/setup-elevated-sandbox".cyan(), ".".dim(), ] .into() @@ -3311,40 +3311,59 @@ impl ChatWidget { return; } + let current_approval = self.config.approval_policy.value(); + let current_sandbox = self.config.sandbox_policy.get(); + let presets = builtin_approval_presets(); + let stay_full_access = presets + .iter() + .find(|preset| preset.id == "full-access") + .is_some_and(|preset| { + Self::preset_matches_current(current_approval, current_sandbox, preset) + }); + let stay_actions = if stay_full_access { + Vec::new() + } else { + presets + .iter() + .find(|preset| preset.id == "read-only") + .map(|preset| { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }) + .unwrap_or_default() + }; + let stay_label = if stay_full_access { + "Stay in Agent Full Access".to_string() + } else { + "Stay in Read-Only".to_string() + }; + let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ - line!["Codex works best in Agent mode.".bold()], - line!["To use Agent mode on Windows, we need to configure the sandbox."], - line!["This setup requires elevation. Do you accept?"], + line!["Set Up Agent Sandbox".bold()], + line![""], + line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."], line!["Learn more: https://developers.openai.com/codex/windows"], ]) .wrap(Wrap { trim: false }), )); - let preset_accept = preset.clone(); - let preset_decline = preset; let items = vec![ SelectionItem { - name: "Yes, I accept".to_string(), + name: "Set up agent sandbox (requires elevation)".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset_accept.clone(), + preset: preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "No, I do not accept".to_string(), + name: stay_label, description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { - preset: preset_decline.clone(), - reason: WindowsSandboxFallbackReason::DeclinedElevation, - }); - })], + actions: stay_actions, dismiss_on_select: true, ..Default::default() }, @@ -3370,41 +3389,65 @@ impl ChatWidget { ) { use ratatui_macros::line; + let _ = reason; + + let current_approval = self.config.approval_policy.value(); + let current_sandbox = self.config.sandbox_policy.get(); + let presets = builtin_approval_presets(); + let stay_full_access = presets + .iter() + .find(|preset| preset.id == "full-access") + .is_some_and(|preset| { + Self::preset_matches_current(current_approval, current_sandbox, preset) + }); + let stay_actions = if stay_full_access { + Vec::new() + } else { + presets + .iter() + .find(|preset| preset.id == "read-only") + .map(|preset| { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }) + .unwrap_or_default() + }; + let stay_label = if stay_full_access { + "Stay in Agent Full Access".to_string() + } else { + "Stay in Read-Only".to_string() + }; + let mut lines = Vec::new(); - if reason == WindowsSandboxFallbackReason::ElevationFailed { - lines.push(line!["The elevated setup did not complete.".bold()]); - } - lines.push(line![ - "You can still use a degraded sandbox without elevation." - ]); - lines.push(line!["It is less watertight, but still secure."]); + lines.push(line!["Use Non-Elevated Sandbox?".bold()]); + lines.push(line![""]); lines.push(line![ - "Learn more: https://developers.openai.com/codex/windows" + "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." ]); + lines.push(line!["Learn more: https://developers.openai.com/codex/windows"]); let mut header = ColumnRenderable::new(); header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - let preset_retry = preset.clone(); - let preset_degraded = preset; + let elevated_preset = preset.clone(); + let legacy_preset = preset.clone(); let items = vec![ SelectionItem { - name: "Try elevated setup again".to_string(), + name: "Try elevated agent sandbox setup again".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset_retry.clone(), + preset: elevated_preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "Use degraded sandbox".to_string(), + name: "Use non-elevated agent sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_degraded.clone(), + preset: legacy_preset.clone(), mode: WindowsSandboxEnableMode::Legacy, }); })], @@ -3412,11 +3455,9 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Use no sandbox".to_string(), + name: stay_label, description: None, - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })], + actions: stay_actions, dismiss_on_select: true, ..Default::default() }, @@ -3465,8 +3506,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); self.set_status_header( - "Setting up the elevated Windows sandbox (this may take a minute or more). You'll stay in your current mode until it's done." - .to_string(), + "Setting up agent sandbox. This can take a minute.".to_string(), ); self.request_redraw(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index 6ec90ecba85e..d7a62079d901 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -11,5 +11,5 @@ expression: popup commands with network access. Exercise caution when using. - To upgrade to the elevated sandbox, run /elevate-sandbox. + To upgrade to the elevated sandbox, run /setup-elevated-sandbox. Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 535a917ca851..10a43f778c45 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2131,8 +2131,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { "expected startup prompt to explain elevation: {popup}" ); assert!( - popup.contains("Yes, I accept"), - "expected startup prompt to offer accepting elevation: {popup}" + popup.contains("Set up agent sandbox"), + "expected startup prompt to offer agent sandbox setup: {popup}" + ); + assert!( + popup.contains("Stay in"), + "expected startup prompt to offer staying in current mode: {popup}" ); set_windows_sandbox_enabled(true); diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 873e807cc1f6..a5bab57d9a21 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + #[strum(serialize = "setup-elevated-sandbox")] ElevateSandbox, Experimental, Skills, @@ -55,7 +56,7 @@ impl SlashCommand { SlashCommand::Ps => "list background terminals", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", - SlashCommand::ElevateSandbox => "upgrade to the elevated Windows sandbox", + SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle beta features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index e838a71f74d3..292ccb5ac6f5 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1688,10 +1688,10 @@ impl App { self.chat_widget.add_info_message( match mode { WindowsSandboxEnableMode::Elevated => { - "Enabled elevated Windows sandbox.".to_string() + "Enabled elevated agent sandbox.".to_string() } WindowsSandboxEnableMode::Legacy => { - "Enabled degraded Windows sandbox.".to_string() + "Enabled non-elevated agent sandbox.".to_string() } }, None, diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index 9b0595382860..d72eef2b96f9 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -24,7 +24,6 @@ pub(crate) enum WindowsSandboxEnableMode { #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) enum WindowsSandboxFallbackReason { - DeclinedElevation, ElevationFailed, } diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs index 3b1afcc0fb5b..8be5b1c20198 100644 --- a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -20,6 +20,7 @@ use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; +use super::selection_popup_common::wrap_styled_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -395,8 +396,10 @@ impl Renderable for ListSelectionView { if self.is_searchable { height = height.saturating_add(1); } - if self.footer_note.is_some() { - height = height.saturating_add(1); + if let Some(note) = &self.footer_note { + let note_width = width.saturating_sub(2); + let note_lines = wrap_styled_line(note, note_width); + height = height.saturating_add(note_lines.len() as u16); } if self.footer_hint.is_some() { height = height.saturating_add(1); @@ -409,8 +412,13 @@ impl Renderable for ListSelectionView { return; } - let footer_rows = - u16::from(self.footer_note.is_some()) + u16::from(self.footer_hint.is_some()); + let note_width = area.width.saturating_sub(2); + let note_lines = self + .footer_note + .as_ref() + .map(|note| wrap_styled_line(note, note_width)); + let note_height = note_lines.as_ref().map_or(0, |lines| lines.len() as u16); + let footer_rows = note_height + u16::from(self.footer_hint.is_some()); let [content_area, footer_area] = Layout::vertical([Constraint::Fill(1), Constraint::Length(footer_rows)]).areas(area); @@ -482,19 +490,30 @@ impl Renderable for ListSelectionView { if footer_area.height > 0 { let [note_area, hint_area] = Layout::vertical([ - Constraint::Length(if self.footer_note.is_some() { 1 } else { 0 }), + Constraint::Length(note_height), Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }), ]) .areas(footer_area); - if let Some(note) = &self.footer_note { + if let Some(lines) = note_lines { let note_area = Rect { x: note_area.x + 2, y: note_area.y, width: note_area.width.saturating_sub(2), height: note_area.height, }; - note.clone().render(note_area, buf); + for (idx, line) in lines.iter().enumerate() { + if idx as u16 >= note_area.height { + break; + } + let line_area = Rect { + x: note_area.x, + y: note_area.y + idx as u16, + width: note_area.width, + height: 1, + }; + line.clone().render(line_area, buf); + } } if let Some(hint) = &self.footer_hint { diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index ba591b317037..0c53f8970fef 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -2651,7 +2651,7 @@ impl ChatWidget { let is_current = Self::preset_matches_current(current_approval, current_sandbox, &preset); let name = if preset.id == "auto" && windows_degraded_sandbox_enabled { - "Agent (degraded)".to_string() + "Agent (non-elevated sandbox)".to_string() } else { preset.label.to_string() }; @@ -2728,8 +2728,8 @@ impl ChatWidget { let footer_note = show_elevate_sandbox_hint.then(|| { vec![ - "To upgrade to the elevated sandbox, run ".dim(), - "/elevate-sandbox".cyan(), + "The non-elevated sandbox protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected. To upgrade to the elevated sandbox, run ".dim(), + "/setup-elevated-sandbox".cyan(), ".".dim(), ] .into() @@ -3042,40 +3042,59 @@ impl ChatWidget { return; } + let current_approval = self.config.approval_policy.value(); + let current_sandbox = self.config.sandbox_policy.get(); + let presets = builtin_approval_presets(); + let stay_full_access = presets + .iter() + .find(|preset| preset.id == "full-access") + .is_some_and(|preset| { + Self::preset_matches_current(current_approval, current_sandbox, preset) + }); + let stay_actions = if stay_full_access { + Vec::new() + } else { + presets + .iter() + .find(|preset| preset.id == "read-only") + .map(|preset| { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }) + .unwrap_or_default() + }; + let stay_label = if stay_full_access { + "Stay in Agent Full Access".to_string() + } else { + "Stay in Read-Only".to_string() + }; + let mut header = ColumnRenderable::new(); header.push(*Box::new( Paragraph::new(vec![ - line!["Codex works best in Agent mode.".bold()], - line!["To use Agent mode on Windows, we need to configure the sandbox."], - line!["This setup requires elevation. Do you accept?"], + line!["Set Up Agent Sandbox".bold()], + line![""], + line!["Agent mode uses an experimental Windows sandbox that protects your files and prevents network access by default."], line!["Learn more: https://developers.openai.com/codex/windows"], ]) .wrap(Wrap { trim: false }), )); - let preset_accept = preset.clone(); - let preset_decline = preset; let items = vec![ SelectionItem { - name: "Yes, I accept".to_string(), + name: "Set up agent sandbox (requires elevation)".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset_accept.clone(), + preset: preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "No, I do not accept".to_string(), + name: stay_label, description: None, - actions: vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { - preset: preset_decline.clone(), - reason: WindowsSandboxFallbackReason::DeclinedElevation, - }); - })], + actions: stay_actions, dismiss_on_select: true, ..Default::default() }, @@ -3101,41 +3120,65 @@ impl ChatWidget { ) { use ratatui_macros::line; + let _ = reason; + + let current_approval = self.config.approval_policy.value(); + let current_sandbox = self.config.sandbox_policy.get(); + let presets = builtin_approval_presets(); + let stay_full_access = presets + .iter() + .find(|preset| preset.id == "full-access") + .is_some_and(|preset| { + Self::preset_matches_current(current_approval, current_sandbox, preset) + }); + let stay_actions = if stay_full_access { + Vec::new() + } else { + presets + .iter() + .find(|preset| preset.id == "read-only") + .map(|preset| { + Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + }) + .unwrap_or_default() + }; + let stay_label = if stay_full_access { + "Stay in Agent Full Access".to_string() + } else { + "Stay in Read-Only".to_string() + }; + let mut lines = Vec::new(); - if reason == WindowsSandboxFallbackReason::ElevationFailed { - lines.push(line!["The elevated setup did not complete.".bold()]); - } - lines.push(line![ - "You can still use a degraded sandbox without elevation." - ]); - lines.push(line!["It is less watertight, but still secure."]); + lines.push(line!["Use Non-Elevated Sandbox?".bold()]); + lines.push(line![""]); lines.push(line![ - "Learn more: https://developers.openai.com/codex/windows" + "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." ]); + lines.push(line!["Learn more: https://developers.openai.com/codex/windows"]); let mut header = ColumnRenderable::new(); header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); - let preset_retry = preset.clone(); - let preset_degraded = preset; + let elevated_preset = preset.clone(); + let legacy_preset = preset.clone(); let items = vec![ SelectionItem { - name: "Try elevated setup again".to_string(), + name: "Try elevated agent sandbox setup again".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { - preset: preset_retry.clone(), + preset: elevated_preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "Use degraded sandbox".to_string(), + name: "Use non-elevated agent sandbox".to_string(), description: None, actions: vec![Box::new(move |tx| { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_degraded.clone(), + preset: legacy_preset.clone(), mode: WindowsSandboxEnableMode::Legacy, }); })], @@ -3143,11 +3186,9 @@ impl ChatWidget { ..Default::default() }, SelectionItem { - name: "Use no sandbox".to_string(), + name: stay_label, description: None, - actions: vec![Box::new(|tx| { - tx.send(AppEvent::OpenApprovalsPopup); - })], + actions: stay_actions, dismiss_on_select: true, ..Default::default() }, @@ -3196,8 +3237,7 @@ impl ChatWidget { self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); self.set_status_header( - "Setting up the elevated Windows sandbox (this may take a minute or more). You'll stay in your current mode until it's done." - .to_string(), + "Setting up agent sandbox. This can take a minute.".to_string(), ); self.request_redraw(); } diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index 3fc59e006809..cfdd73dd08c0 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -11,5 +11,5 @@ expression: popup commands with network access. Exercise caution when using. - To upgrade to the elevated sandbox, run /elevate-sandbox. + To upgrade to the elevated sandbox, run /setup-elevated-sandbox. Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 3d294e62ea32..7a8b7c304718 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -1892,8 +1892,12 @@ async fn startup_prompts_for_windows_sandbox_when_agent_requested() { "expected startup prompt to explain elevation: {popup}" ); assert!( - popup.contains("Yes, I accept"), - "expected startup prompt to offer accepting elevation: {popup}" + popup.contains("Set up agent sandbox"), + "expected startup prompt to offer agent sandbox setup: {popup}" + ); + assert!( + popup.contains("Stay in"), + "expected startup prompt to offer staying in current mode: {popup}" ); set_windows_sandbox_enabled(true); diff --git a/codex-rs/tui2/src/slash_command.rs b/codex-rs/tui2/src/slash_command.rs index 0b8cb54afebc..bbebcd40944b 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -14,6 +14,7 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + #[strum(serialize = "setup-elevated-sandbox")] ElevateSandbox, Skills, Review, @@ -52,7 +53,7 @@ impl SlashCommand { SlashCommand::Status => "show current session configuration and token usage", SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", - SlashCommand::ElevateSandbox => "upgrade to the elevated Windows sandbox", + SlashCommand::ElevateSandbox => "set up elevated agent sandbox", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", From c55e0418e295e2d131ff5af1c31e39c1ab9fd162 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 7 Jan 2026 21:06:53 -0800 Subject: [PATCH 11/18] Sync footer note and input enabled plumbing --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 2 +- .../src/bottom_pane/list_selection_view.rs | 34 ++++++++++++++++++- .../tui2/src/bottom_pane/chat_composer.rs | 2 +- .../src/bottom_pane/list_selection_view.rs | 34 ++++++++++++++++++- 4 files changed, 68 insertions(+), 4 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 4c473b1fa919..d37b409f1069 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -1915,7 +1915,7 @@ impl ChatComposer { self.has_focus = has_focus; } - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; self.input_disabled_placeholder = if enabled { None } else { placeholder }; diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index fd5122771d2e..40787a9c259b 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -13,6 +13,7 @@ use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; use crate::render::Insets; @@ -20,7 +21,6 @@ use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; -use super::selection_popup_common::wrap_styled_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -654,6 +654,38 @@ mod tests { assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + #[test] + fn snapshot_footer_note_wraps() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }]; + let footer_note = Line::from(vec![ + "Note: ".dim(), + "Use /setup-elevated-sandbox".cyan(), + " to allow network access.".dim(), + ]); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_note: Some(footer_note), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_footer_note_wraps", + render_lines_with_width(&view, 40) + ); + } + #[test] fn renders_search_query_line_when_enabled() { let (tx_raw, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 54abf044e7c8..be6e3f22bcdc 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -1868,7 +1868,7 @@ impl ChatComposer { self.has_focus = has_focus; } - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] + #[allow(dead_code)] pub(crate) fn set_input_enabled(&mut self, enabled: bool, placeholder: Option) { self.input_enabled = enabled; self.input_disabled_placeholder = if enabled { None } else { placeholder }; diff --git a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs index 8be5b1c20198..27c7dc4233e9 100644 --- a/codex-rs/tui2/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui2/src/bottom_pane/list_selection_view.rs @@ -13,6 +13,7 @@ use ratatui::widgets::Block; use ratatui::widgets::Paragraph; use ratatui::widgets::Widget; +use super::selection_popup_common::wrap_styled_line; use crate::app_event_sender::AppEventSender; use crate::key_hint::KeyBinding; use crate::render::Insets; @@ -20,7 +21,6 @@ use crate::render::RectExt as _; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::Renderable; use crate::style::user_message_style; -use super::selection_popup_common::wrap_styled_line; use super::CancellationEvent; use super::bottom_pane_view::BottomPaneView; @@ -611,6 +611,38 @@ mod tests { assert_snapshot!("list_selection_spacing_with_subtitle", render_lines(&view)); } + #[test] + fn snapshot_footer_note_wraps() { + let (tx_raw, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let items = vec![SelectionItem { + name: "Read Only".to_string(), + description: Some("Codex can read files".to_string()), + is_current: true, + dismiss_on_select: true, + ..Default::default() + }]; + let footer_note = Line::from(vec![ + "Note: ".dim(), + "Use /setup-elevated-sandbox".cyan(), + " to allow network access.".dim(), + ]); + let view = ListSelectionView::new( + SelectionViewParams { + title: Some("Select Approval Mode".to_string()), + footer_note: Some(footer_note), + footer_hint: Some(standard_popup_hint_line()), + items, + ..Default::default() + }, + tx, + ); + assert_snapshot!( + "list_selection_footer_note_wraps", + render_lines_with_width(&view, 40) + ); + } + #[test] fn renders_search_query_line_when_enabled() { let (tx_raw, _rx) = unbounded_channel::(); From 9291dfefd81dbcc7ea4d5aae02bb8c42b2b41c42 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 7 Jan 2026 22:16:38 -0800 Subject: [PATCH 12/18] merge conflicts --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 22 +++++++++++++++++++ codex-rs/tui/src/bottom_pane/mod.rs | 10 --------- .../tui2/src/bottom_pane/chat_composer.rs | 22 +++++++++++++++++++ codex-rs/tui2/src/bottom_pane/mod.rs | 10 --------- 4 files changed, 44 insertions(+), 20 deletions(-) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index d37b409f1069..932f29dcbbb1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -80,6 +80,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; pub enum InputResult { Submitted(String), Command(SlashCommand), + CommandWithArgs(SlashCommand, String), None, } @@ -1334,6 +1335,18 @@ impl ChatComposer { } } + if !input_starts_with_space + && let Some((name, rest)) = parse_slash_name(&text) + && !rest.is_empty() + && !name.contains('/') + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(command_name, _)| *command_name == name) + && cmd == SlashCommand::Review + { + return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); + } + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { Ok(expanded) => expanded, Err(err) => { @@ -1708,6 +1721,15 @@ impl ChatComposer { fn sync_popups(&mut self) { let file_token = Self::current_at_token(&self.textarea); + let browsing_history = self + .history + .should_handle_navigation(self.textarea.text(), self.textarea.cursor()); + // When browsing input history (shell-style Up/Down recall), skip all popup + // synchronization so nothing steals focus from continued history navigation. + if browsing_history { + self.active_popup = ActivePopup::None; + return; + } let skill_token = self.current_skill_token(); let allow_command_popup = file_token.is_none() && skill_token.is_none(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 832b827cebec..fe626537ac43 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -298,16 +298,6 @@ impl BottomPane { self.request_redraw(); } - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_composer_input_enabled( - &mut self, - enabled: bool, - placeholder: Option, - ) { - self.composer.set_input_enabled(enabled, placeholder); - self.request_redraw(); - } - /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index be6e3f22bcdc..12734f327898 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -83,6 +83,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; pub enum InputResult { Submitted(String), Command(SlashCommand), + CommandWithArgs(SlashCommand, String), None, } @@ -1251,6 +1252,18 @@ impl ChatComposer { } } + if !input_starts_with_space + && let Some((name, rest)) = parse_slash_name(&text) + && !rest.is_empty() + && !name.contains('/') + && let Some((_n, cmd)) = built_in_slash_commands() + .into_iter() + .find(|(command_name, _)| *command_name == name) + && cmd == SlashCommand::Review + { + return (InputResult::CommandWithArgs(cmd, rest.to_string()), true); + } + let expanded_prompt = match expand_custom_prompt(&text, &self.custom_prompts) { Ok(expanded) => expanded, Err(err) => { @@ -1661,6 +1674,15 @@ impl ChatComposer { fn sync_popups(&mut self) { let file_token = Self::current_at_token(&self.textarea); + let browsing_history = self + .history + .should_handle_navigation(self.textarea.text(), self.textarea.cursor()); + // When browsing input history (shell-style Up/Down recall), skip all popup + // synchronization so nothing steals focus from continued history navigation. + if browsing_history { + self.active_popup = ActivePopup::None; + return; + } let skill_token = self.current_skill_token(); let allow_command_popup = file_token.is_none() && skill_token.is_none(); diff --git a/codex-rs/tui2/src/bottom_pane/mod.rs b/codex-rs/tui2/src/bottom_pane/mod.rs index 40b4ab9be66b..4b6caf0d1aa8 100644 --- a/codex-rs/tui2/src/bottom_pane/mod.rs +++ b/codex-rs/tui2/src/bottom_pane/mod.rs @@ -276,16 +276,6 @@ impl BottomPane { self.composer.current_text() } - #[cfg_attr(not(target_os = "windows"), allow(dead_code))] - pub(crate) fn set_composer_input_enabled( - &mut self, - enabled: bool, - placeholder: Option, - ) { - self.composer.set_input_enabled(enabled, placeholder); - self.request_redraw(); - } - /// Update the status indicator header (defaults to "Working") and details below it. /// /// Passing `None` clears any existing details. No-ops if the status indicator is not active. From 73e018b8c12d644f03a632a697166929e3992e31 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Wed, 7 Jan 2026 22:22:33 -0800 Subject: [PATCH 13/18] run tests that change global vars serially --- codex-rs/tui/src/chatwidget/tests.rs | 2 ++ codex-rs/tui2/src/chatwidget/tests.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 10a43f778c45..0d1394438f2c 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -64,6 +64,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; +use serial_test::serial; use std::collections::HashSet; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -2034,6 +2035,7 @@ async fn approvals_selection_popup_snapshot() { #[cfg(target_os = "windows")] #[tokio::test] +#[serial] async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 7a8b7c304718..1a88e95aa5be 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -62,6 +62,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; +use serial_test::serial; use std::collections::HashSet; use std::path::PathBuf; use tempfile::NamedTempFile; @@ -1795,6 +1796,7 @@ async fn approvals_selection_popup_snapshot() { #[cfg(target_os = "windows")] #[tokio::test] +#[serial] async fn approvals_selection_popup_snapshot_windows_degraded_sandbox() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await; From 4111eae51d76cfbe93edc3ff3b02c71210491b43 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 8 Jan 2026 11:25:28 -0800 Subject: [PATCH 14/18] fmt and clippy fix --- codex-rs/tui/src/chatwidget.rs | 10 +++++----- codex-rs/tui2/src/chatwidget.rs | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0b96c75a5eea..33cbdb983afb 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3423,13 +3423,15 @@ impl ChatWidget { lines.push(line![ "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." ]); - lines.push(line!["Learn more: https://developers.openai.com/codex/windows"]); + lines.push(line![ + "Learn more: https://developers.openai.com/codex/windows" + ]); 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.clone(); + let legacy_preset = preset; let items = vec![ SelectionItem { name: "Try elevated agent sandbox setup again".to_string(), @@ -3505,9 +3507,7 @@ impl ChatWidget { ); self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); - self.set_status_header( - "Setting up agent sandbox. This can take a minute.".to_string(), - ); + self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string()); self.request_redraw(); } diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 0c53f8970fef..f8aba336a6a6 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -3154,13 +3154,15 @@ impl ChatWidget { lines.push(line![ "Elevation failed. You can also use a non-elevated sandbox, which protects your files and prevents network access under most circumstances. However, it carries greater risk if prompt injected." ]); - lines.push(line!["Learn more: https://developers.openai.com/codex/windows"]); + lines.push(line![ + "Learn more: https://developers.openai.com/codex/windows" + ]); 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.clone(); + let legacy_preset = preset; let items = vec![ SelectionItem { name: "Try elevated agent sandbox setup again".to_string(), @@ -3236,9 +3238,7 @@ impl ChatWidget { ); self.bottom_pane.ensure_status_indicator(); self.bottom_pane.set_interrupt_hint_visible(false); - self.set_status_header( - "Setting up agent sandbox. This can take a minute.".to_string(), - ); + self.set_status_header("Setting up agent sandbox. This can take a minute.".to_string()); self.request_redraw(); } From 000b90d14bb028b8894d002f69f2da7c7f593125 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 8 Jan 2026 13:30:18 -0800 Subject: [PATCH 15/18] re-introduce lost code in merge --- codex-rs/tui/src/bottom_pane/chat_composer.rs | 9 +++++++++ codex-rs/tui2/src/bottom_pane/chat_composer.rs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 932f29dcbbb1..620b52d9f9e1 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -3060,6 +3060,9 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "init"); } + InputResult::CommandWithArgs(_, _) => { + panic!("expected command dispatch without args for '/init'") + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -3133,6 +3136,9 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), + InputResult::CommandWithArgs(_, _) => { + panic!("expected command dispatch without args for '/diff'") + } InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -3166,6 +3172,9 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "mention"); } + InputResult::CommandWithArgs(_, _) => { + panic!("expected command dispatch without args for '/mention'") + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } diff --git a/codex-rs/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 12734f327898..22e62bb4fba6 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -2991,6 +2991,9 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "init"); } + InputResult::CommandWithArgs(_, _) => { + panic!("expected command dispatch without args for '/init'") + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } @@ -3064,6 +3067,9 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"), + InputResult::CommandWithArgs(_, _) => { + panic!("expected command dispatch without args for '/diff'") + } InputResult::Submitted(text) => { panic!("expected command dispatch after Tab completion, got literal submit: {text}") } @@ -3097,6 +3103,9 @@ mod tests { InputResult::Command(cmd) => { assert_eq!(cmd.command(), "mention"); } + InputResult::CommandWithArgs(_, _) => { + panic!("expected command dispatch without args for '/mention'") + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } From b7fb22067476a6204e3bc2763c70b5ea48fefb78 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 8 Jan 2026 13:40:23 -0800 Subject: [PATCH 16/18] unused import --- codex-rs/tui/src/chatwidget/tests.rs | 1 + codex-rs/tui2/src/chatwidget/tests.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 0d1394438f2c..1b9723ec54e9 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -64,6 +64,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; +#[cfg(target_os = "windows")] use serial_test::serial; use std::collections::HashSet; use std::path::PathBuf; diff --git a/codex-rs/tui2/src/chatwidget/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 1a88e95aa5be..9978911186ea 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -62,6 +62,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; +#[cfg(target_os = "windows")] use serial_test::serial; use std::collections::HashSet; use std::path::PathBuf; From 5483198e5a32692f4427955abb4d415ffa005243 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 8 Jan 2026 14:04:24 -0800 Subject: [PATCH 17/18] update snapshots with new label --- ...s__approvals_selection_popup@windows_degraded.snap | 11 ++++++----- ...s__approvals_selection_popup@windows_degraded.snap | 11 ++++++----- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index d7a62079d901..064d4cee8e28 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -5,11 +5,12 @@ expression: popup --- Select Approval Mode -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent (degraded) Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. +› 1. Read Only (current) Requires approval to edit files and run + commands. + 2. Agent (non-elevated sandbox) Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace + and run commands with network access. + Exercise caution when using. To upgrade to the elevated sandbox, run /setup-elevated-sandbox. Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index cfdd73dd08c0..65dc73a56fb5 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -5,11 +5,12 @@ expression: popup --- Select Approval Mode -› 1. Read Only (current) Requires approval to edit files and run commands. - 2. Agent (degraded) Read and edit files, and run commands. - 3. Agent (full access) Codex can edit files outside this workspace and run - commands with network access. Exercise caution when - using. +› 1. Read Only (current) Requires approval to edit files and run + commands. + 2. Agent (non-elevated sandbox) Read and edit files, and run commands. + 3. Agent (full access) Codex can edit files outside this workspace + and run commands with network access. + Exercise caution when using. To upgrade to the elevated sandbox, run /setup-elevated-sandbox. Press enter to confirm or esc to go back From a2e9a3e35d59370aa48839e25f76c7bad1f121f5 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 8 Jan 2026 14:34:40 -0800 Subject: [PATCH 18/18] fix snapshots for real --- ...et__tests__approvals_selection_popup@windows_degraded.snap | 4 +++- ...et__tests__approvals_selection_popup@windows_degraded.snap | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index 064d4cee8e28..3c023a831f5b 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -12,5 +12,7 @@ expression: popup and run commands with network access. Exercise caution when using. - To upgrade to the elevated sandbox, run /setup-elevated-sandbox. + The non-elevated sandbox protects your files and prevents network access under + most circumstances. However, it carries greater risk if prompt injected. To + upgrade to the elevated sandbox, run /setup-elevated-sandbox. Press enter to confirm or esc to go back diff --git a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap index 65dc73a56fb5..bd6b8343edd7 100644 --- a/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -12,5 +12,7 @@ expression: popup and run commands with network access. Exercise caution when using. - To upgrade to the elevated sandbox, run /setup-elevated-sandbox. + The non-elevated sandbox protects your files and prevents network access under + most circumstances. However, it carries greater risk if prompt injected. To + upgrade to the elevated sandbox, run /setup-elevated-sandbox. Press enter to confirm or esc to go back