From 6372ba9d5f0694482b0809e1eac4b123cab65e55 Mon Sep 17 00:00:00 2001 From: iceweasel-oai Date: Thu, 8 Jan 2026 16:23:06 -0800 Subject: [PATCH] Elevated sandbox NUX (#8789) Elevated Sandbox NUX: * prompt for elevated sandbox setup when agent mode is selected (via /approvals or at startup) * prompt for degraded sandbox if elevated setup is declined or fails * introduce /elevate-sandbox command to upgrade from degraded experience. --- codex-rs/core/src/config/mod.rs | 9 + codex-rs/core/src/lib.rs | 3 + codex-rs/core/src/windows_sandbox.rs | 49 +++ codex-rs/tui/src/app.rs | 89 ++++- codex-rs/tui/src/app_event.rs | 27 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 111 +----- codex-rs/tui/src/bottom_pane/command_popup.rs | 9 + codex-rs/tui/src/chatwidget.rs | 316 ++++++++++++++++- ...ts__approvals_selection_popup@windows.snap | 1 + ...vals_selection_popup@windows_degraded.snap | 18 + codex-rs/tui/src/chatwidget/tests.rs | 52 ++- codex-rs/tui/src/slash_command.rs | 4 + codex-rs/tui2/src/app.rs | 89 ++++- codex-rs/tui2/src/app_event.rs | 27 ++ .../tui2/src/bottom_pane/chat_composer.rs | 66 ++-- .../tui2/src/bottom_pane/command_popup.rs | 9 + codex-rs/tui2/src/chatwidget.rs | 325 +++++++++++++++++- ...vals_selection_popup@windows_degraded.snap | 18 + codex-rs/tui2/src/chatwidget/tests.rs | 54 ++- codex-rs/tui2/src/slash_command.rs | 4 + codex-rs/windows-sandbox-rs/src/identity.rs | 12 + codex-rs/windows-sandbox-rs/src/lib.rs | 2 + 22 files changed, 1110 insertions(+), 184 deletions(-) create mode 100644 codex-rs/core/src/windows_sandbox.rs 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/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f5d6a8ffd..162c3226e 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 370c1ecb9..1fb25ebc1 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; @@ -114,6 +115,8 @@ 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::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. diff --git a/codex-rs/core/src/windows_sandbox.rs b/codex-rs/core/src/windows_sandbox.rs new file mode 100644 index 000000000..b355bad28 --- /dev/null +++ b/codex-rs/core/src/windows_sandbox.rs @@ -0,0 +1,49 @@ +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) +} + +#[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/app.rs b/codex-rs/tui/src/app.rs index 32223f18e..9e5ac2d95 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,5 +1,9 @@ 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; use crate::chatwidget::ChatWidget; @@ -792,19 +796,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; + } + } + 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 +909,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 agent sandbox.".to_string() + } + WindowsSandboxEnableMode::Legacy => { + "Enabled non-elevated agent sandbox.".to_string() + } + }, None, ); } @@ -851,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 1f99e372e..861ba2a54 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -15,6 +15,19 @@ use codex_core::protocol::AskForApproval; 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 { + ElevationFailed, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -106,10 +119,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 d3c93caa1..620b52d9f 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -64,6 +64,13 @@ use std::path::PathBuf; use std::time::Duration; 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() +} + /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -1229,6 +1236,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(""); @@ -1296,6 +1307,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 @@ -1715,7 +1730,6 @@ impl ChatComposer { 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 +1799,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 { @@ -3054,44 +3071,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( @@ -4393,59 +4372,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 +4529,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 dc123f6c2..ec4e86af0 100644 --- a/codex-rs/tui/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui/src/bottom_pane/command_popup.rs @@ -15,6 +15,13 @@ 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::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && 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 +39,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/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index fa0be84a7..bd0ba788b 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -85,6 +85,9 @@ 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; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BetaFeatureItem; @@ -1737,6 +1740,45 @@ 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 + || !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; + } + + 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. + }; + } SlashCommand::Experimental => { self.open_experimental_popup(); } @@ -2841,10 +2883,25 @@ 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 = 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 = 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 (non-elevated sandbox)".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, @@ -2868,11 +2925,24 @@ impl ChatWidget { { 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::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(), + mode: WindowsSandboxEnableMode::Elevated, + }); + })] + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + }); + })] + } } else if let Some((sample_paths, extra_count, failed_scan)) = self.world_writable_warning_details() { @@ -2907,8 +2977,18 @@ impl ChatWidget { }); } + let footer_note = show_elevate_sandbox_hint.then(|| { + vec![ + "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() + }); + 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(()), @@ -3185,36 +3265,106 @@ 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 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!["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!["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_clone = preset; let items = vec![ SelectionItem { - name: "Enable experimental sandbox".to_string(), + name: "Set up agent sandbox (requires elevation)".to_string(), description: None, actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "Go back".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() }, @@ -3232,6 +3382,107 @@ impl ChatWidget { #[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 _ = 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(); + lines.push(line!["Use Non-Elevated Sandbox?".bold()]); + lines.push(line![""]); + 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" + ]); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + + let elevated_preset = preset.clone(); + let legacy_preset = preset; + let items = vec![ + SelectionItem { + name: "Try elevated agent sandbox setup again".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: elevated_preset.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use non-elevated agent sandbox".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: legacy_preset.clone(), + mode: WindowsSandboxEnableMode::Legacy, + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: stay_label, + description: None, + actions: stay_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + _preset: ApprovalPreset, + _reason: WindowsSandboxFallbackReason, + ) { + } + #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { if self.config.forced_auto_mode_downgraded_on_windows @@ -3247,6 +3498,34 @@ 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 agent sandbox. This can take a minute.".to_string()); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} + + #[cfg(target_os = "windows")] + pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { + self.bottom_pane.set_composer_input_enabled(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; @@ -3279,6 +3558,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/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 6758ec62c..ab889de71 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/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 000000000..3c023a831 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -0,0 +1,18 @@ +--- +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 (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. + + 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/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index edfb4e1d4..1b9723ec5 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -64,6 +64,8 @@ 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; use tempfile::NamedTempFile; @@ -76,6 +78,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 +2034,35 @@ async fn approvals_selection_popup_snapshot() { assert_snapshot!("approvals_selection_popup", popup); } +#[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; + + 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 + .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(was_sandbox_enabled); + set_windows_elevated_sandbox_enabled(was_elevated_enabled); +} + #[tokio::test] async fn preset_matching_ignores_extra_writable_roots() { let preset = builtin_approval_presets() @@ -2077,8 +2113,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 +2130,16 @@ 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("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 c6bd8a771..a5bab57d9 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -14,6 +14,8 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + #[strum(serialize = "setup-elevated-sandbox")] + ElevateSandbox, Experimental, Skills, Review, @@ -54,6 +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 => "set up elevated agent sandbox", SlashCommand::Experimental => "toggle beta features", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", @@ -78,6 +81,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 ead4135a4..292ccb5ac 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -1,5 +1,9 @@ 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; use crate::chatwidget::ChatWidget; @@ -1569,19 +1573,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; + } + } + 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 +1686,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 agent sandbox.".to_string() + } + WindowsSandboxEnableMode::Legacy => { + "Enabled non-elevated agent sandbox.".to_string() + } + }, None, ); } @@ -1628,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 adb9c1308..d72eef2b9 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -14,6 +14,19 @@ use codex_core::protocol::AskForApproval; 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 { + ElevationFailed, +} + #[allow(clippy::large_enum_variant)] #[derive(Debug)] pub(crate) enum AppEvent { @@ -105,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/tui2/src/bottom_pane/chat_composer.rs b/codex-rs/tui2/src/bottom_pane/chat_composer.rs index 6198d0a57..22e62bb4f 100644 --- a/codex-rs/tui2/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui2/src/bottom_pane/chat_composer.rs @@ -67,6 +67,13 @@ use std::path::PathBuf; use std::time::Duration; 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() +} + /// If the pasted content exceeds this number of characters, replace it with a /// placeholder in the UI. const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; @@ -1146,6 +1153,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 +1224,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 @@ -1659,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(); @@ -1728,6 +1752,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 { @@ -2975,44 +3002,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( @@ -4303,6 +4292,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 e1e35ae94..2fee23f1b 100644 --- a/codex-rs/tui2/src/bottom_pane/command_popup.rs +++ b/codex-rs/tui2/src/bottom_pane/command_popup.rs @@ -15,6 +15,13 @@ 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::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && 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 +39,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/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 4ee3aa5f1..df5acb442 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,9 @@ 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; use crate::bottom_pane::ApprovalRequest; use crate::bottom_pane::BottomPane; @@ -1570,6 +1574,45 @@ 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 + || !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; + } + + 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. + }; + } SlashCommand::Quit | SlashCommand::Exit => { self.request_exit(); } @@ -2594,10 +2637,25 @@ 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 = 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 = 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 (non-elevated sandbox)".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" @@ -2618,11 +2676,24 @@ impl ChatWidget { { 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::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(), + mode: WindowsSandboxEnableMode::Elevated, + }); + })] + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset_clone.clone(), + }); + })] + } } else if let Some((sample_paths, extra_count, failed_scan)) = self.world_writable_warning_details() { @@ -2656,8 +2727,18 @@ impl ChatWidget { }); } + let footer_note = show_elevate_sandbox_hint.then(|| { + vec![ + "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() + }); + 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(()), @@ -2915,36 +2996,106 @@ 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 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!["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!["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_clone = preset; let items = vec![ SelectionItem { - name: "Enable experimental sandbox".to_string(), + name: "Set up agent sandbox (requires elevation)".to_string(), description: None, actions: vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: preset.clone(), }); })], dismiss_on_select: true, ..Default::default() }, SelectionItem { - name: "Go back".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() }, @@ -2962,6 +3113,107 @@ impl ChatWidget { #[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 _ = 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(); + lines.push(line!["Use Non-Elevated Sandbox?".bold()]); + lines.push(line![""]); + 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" + ]); + + let mut header = ColumnRenderable::new(); + header.push(*Box::new(Paragraph::new(lines).wrap(Wrap { trim: false }))); + + let elevated_preset = preset.clone(); + let legacy_preset = preset; + let items = vec![ + SelectionItem { + name: "Try elevated agent sandbox setup again".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset: elevated_preset.clone(), + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: "Use non-elevated agent sandbox".to_string(), + description: None, + actions: vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: legacy_preset.clone(), + mode: WindowsSandboxEnableMode::Legacy, + }); + })], + dismiss_on_select: true, + ..Default::default() + }, + SelectionItem { + name: stay_label, + description: None, + actions: stay_actions, + dismiss_on_select: true, + ..Default::default() + }, + ]; + + self.bottom_pane.show_selection_view(SelectionViewParams { + title: None, + footer_hint: Some(standard_popup_hint_line()), + items, + header: Box::new(header), + ..Default::default() + }); + } + + #[cfg(not(target_os = "windows"))] + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + _preset: ApprovalPreset, + _reason: WindowsSandboxFallbackReason, + ) { + } + #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self) { if self.config.forced_auto_mode_downgraded_on_windows @@ -2977,6 +3229,34 @@ 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 agent sandbox. This can take a minute.".to_string()); + self.request_redraw(); + } + + #[cfg(not(target_os = "windows"))] + #[allow(dead_code)] + pub(crate) fn show_windows_sandbox_setup_status(&mut self) {} + + #[cfg(target_os = "windows")] + pub(crate) fn clear_windows_sandbox_setup_status(&mut self) { + self.bottom_pane.set_composer_input_enabled(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; @@ -3009,6 +3289,15 @@ 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); + } 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/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 000000000..bd6b8343e --- /dev/null +++ b/codex-rs/tui2/src/chatwidget/snapshots/codex_tui2__chatwidget__tests__approvals_selection_popup@windows_degraded.snap @@ -0,0 +1,18 @@ +--- +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 (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. + + 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/tests.rs b/codex-rs/tui2/src/chatwidget/tests.rs index 09f5073e7..997891118 100644 --- a/codex-rs/tui2/src/chatwidget/tests.rs +++ b/codex-rs/tui2/src/chatwidget/tests.rs @@ -10,6 +10,8 @@ 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; use codex_core::protocol::AgentMessageEvent; @@ -60,6 +62,8 @@ 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; use tempfile::NamedTempFile; @@ -72,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(); @@ -1786,6 +1795,35 @@ async fn approvals_selection_popup_snapshot() { assert_snapshot!("approvals_selection_popup", popup); } +#[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; + + 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 + .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(was_sandbox_enabled); + set_windows_elevated_sandbox_enabled(was_elevated_enabled); +} + #[tokio::test] async fn preset_matching_ignores_extra_writable_roots() { let preset = builtin_approval_presets() @@ -1836,8 +1874,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 +1891,16 @@ 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("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 8fe5de766..bbebcd409 100644 --- a/codex-rs/tui2/src/slash_command.rs +++ b/codex-rs/tui2/src/slash_command.rs @@ -14,6 +14,8 @@ pub enum SlashCommand { // more frequently used commands should be listed first. Model, Approvals, + #[strum(serialize = "setup-elevated-sandbox")] + ElevateSandbox, Skills, Review, New, @@ -51,6 +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 => "set up elevated agent sandbox", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", SlashCommand::Rollout => "print the rollout file path", @@ -74,6 +77,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 835acc5d8..b195e36b9 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 a336b00fa..c4457845c 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;