diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index cbdbee0402ed..8d1797e72478 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -10,6 +10,7 @@ use crate::app_event::AppEvent; use crate::app_event::ExitMode; use crate::app_event::FeedbackCategory; use crate::app_event::HistoryLookupResponse; +use crate::app_event::PermissionProfileSelection; use crate::app_event::RateLimitRefreshOrigin; use crate::app_event::RealtimeAudioDeviceKind; #[cfg(target_os = "windows")] @@ -484,7 +485,7 @@ pub(crate) struct App { harness_overrides: ConfigOverrides, loader_overrides: LoaderOverrides, runtime_approval_policy_override: Option, - runtime_permission_profile_override: Option, + runtime_permission_profile_override: Option, pub(crate) file_search: FileSearchManager, @@ -554,6 +555,23 @@ pub(crate) struct App { pending_hook_enabled_writes: HashMap>, } +#[derive(Debug, Clone, PartialEq)] +struct RuntimePermissionProfileOverride { + permission_profile: PermissionProfile, + active_permission_profile: Option, + network: Option, +} + +impl RuntimePermissionProfileOverride { + fn from_config(config: &Config) -> Self { + Self { + permission_profile: config.permissions.permission_profile().clone(), + active_permission_profile: config.permissions.active_permission_profile(), + network: config.permissions.network.clone(), + } + } +} + fn active_turn_not_steerable_turn_error(error: &TypedRequestError) -> Option { let TypedRequestError::Server { source, .. } = error else { return None; diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 3955b5ab09dd..d4fdebad89df 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -5,6 +5,8 @@ //! loop. use super::*; +#[cfg(target_os = "windows")] +use codex_utils_approval_presets::ApprovalPreset; impl App { pub(super) async fn rebuild_config_for_cwd(&self, cwd: PathBuf) -> Result { @@ -21,6 +23,161 @@ impl App { .wrap_err_with(|| format!("Failed to rebuild config for cwd {cwd_display}")) } + pub(super) async fn rebuild_config_for_permission_profile( + &self, + profile_id: &str, + ) -> Result { + let mut overrides = self.harness_overrides.clone(); + overrides.cwd = Some(self.chat_widget.config_ref().cwd.to_path_buf()); + overrides.sandbox_mode = None; + overrides.permission_profile = None; + overrides.default_permissions = Some(profile_id.to_string()); + ConfigBuilder::default() + .codex_home(self.config.codex_home.to_path_buf()) + .cli_overrides(self.cli_kv_overrides.clone()) + .harness_overrides(overrides) + .loader_overrides(self.loader_overrides.clone()) + .build() + .await + .wrap_err_with(|| { + format!("Failed to rebuild config for permission profile {profile_id}") + }) + } + + #[cfg(target_os = "windows")] + pub(super) async fn permission_profile_for_windows_setup( + &self, + preset: &ApprovalPreset, + profile_selection: Option<&PermissionProfileSelection>, + ) -> Result { + match profile_selection { + Some(selection) => Ok(self + .rebuild_config_for_permission_profile(selection.profile_id.as_str()) + .await? + .permissions + .permission_profile() + .clone()), + None => Ok(preset.permission_profile.clone()), + } + } + + pub(super) async fn apply_permission_profile_selection( + &mut self, + selection: PermissionProfileSelection, + ) -> bool { + let PermissionProfileSelection { + profile_id, + approval_policy, + approvals_reviewer, + display_label, + } = selection; + let selected_config = match self + .rebuild_config_for_permission_profile(profile_id.as_str()) + .await + { + Ok(config) => config, + Err(err) => { + tracing::warn!( + error = %err, + profile_id, + "failed to resolve selected permission profile" + ); + self.chat_widget.add_error_message(format!( + "Failed to set permission profile `{profile_id}`: {err}" + )); + return false; + } + }; + let permission_profile = selected_config.permissions.permission_profile(); + let active_permission_profile = selected_config.permissions.active_permission_profile(); + let network = selected_config.permissions.network.clone(); + + let mut config = self.config.clone(); + if let Some(policy) = approval_policy + && !self.try_set_approval_policy_on_config( + &mut config, + policy, + "Failed to set approval policy", + "failed to set selected permission profile approval policy on app config", + ) + { + return false; + } + if let Err(err) = config + .permissions + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::from_session_snapshot( + permission_profile.clone(), + active_permission_profile.clone(), + ), + ) + { + tracing::warn!( + error = %err, + profile_id, + "failed to set selected permission profile on app config" + ); + self.chat_widget.add_error_message(format!( + "Failed to set permission profile `{profile_id}`: {err}" + )); + return false; + } + if let Some(reviewer) = approvals_reviewer { + config.approvals_reviewer = reviewer; + } + config.permissions.network = network.clone(); + self.config = config; + + if let Some(policy) = approval_policy { + self.runtime_approval_policy_override = Some(policy); + self.chat_widget.set_approval_policy(policy); + } + if let Err(err) = self.chat_widget.set_permission_profile_with_active_profile( + permission_profile.clone(), + active_permission_profile.clone(), + ) { + tracing::warn!( + error = %err, + profile_id, + "failed to set selected permission profile on chat config" + ); + self.chat_widget.add_error_message(format!( + "Failed to set permission profile `{profile_id}`: {err}" + )); + return false; + } + if let Some(reviewer) = approvals_reviewer { + self.chat_widget.set_approvals_reviewer(reviewer); + } + self.chat_widget.set_permission_network(network); + self.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&self.config)); + self.sync_active_thread_permission_settings_to_cached_session() + .await; + self.app_event_tx + .send(AppEvent::CodexOp(AppCommand::override_turn_context( + /*cwd*/ None, + approval_policy, + approvals_reviewer, + Some(permission_profile.clone()), + active_permission_profile, + /*windows_sandbox_level*/ None, + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ))); + self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new( + history_cell::new_info_event( + format!("Permissions updated to {display_label}"), + /*hint*/ None, + ), + ))); + true + } + pub(super) async fn refresh_in_memory_config_from_disk(&mut self) -> Result<()> { let mut config = self .rebuild_config_for_cwd(self.chat_widget.config_ref().cwd.to_path_buf()) @@ -73,13 +230,25 @@ impl App { "Failed to carry forward approval policy override: {err}" )); } - if let Some(profile) = self.runtime_permission_profile_override.as_ref() - && let Err(err) = config.permissions.set_permission_profile(profile.clone()) - { - tracing::warn!(%err, "failed to carry forward permission profile override"); - self.chat_widget.add_error_message(format!( - "Failed to carry forward permission profile override: {err}" - )); + if let Some(profile_override) = self.runtime_permission_profile_override.as_ref() { + match config + .permissions + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::from_session_snapshot( + profile_override.permission_profile.clone(), + profile_override.active_permission_profile.clone(), + ), + ) { + Ok(()) => { + config.permissions.network = profile_override.network.clone(); + } + Err(err) => { + tracing::warn!(%err, "failed to carry forward permission profile override"); + self.chat_widget.add_error_message(format!( + "Failed to carry forward permission profile override: {err}" + )); + } + } } } @@ -341,8 +510,9 @@ impl App { self.chat_widget .add_error_message(format!("Failed to enable Auto-review: {err}")); } - if let Some(permission_profile) = permission_profile_override_value { - self.runtime_permission_profile_override = Some(permission_profile); + if permission_profile_override.is_some() { + self.runtime_permission_profile_override = + Some(RuntimePermissionProfileOverride::from_config(&self.config)); } if approval_policy_override.is_some() diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 51615f6a956f..cd6bd0b5c913 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -798,18 +798,24 @@ impl App { AppEvent::OpenFullAccessConfirmation { preset, return_to_permissions, + profile_selection, } => { - self.chat_widget - .open_full_access_confirmation(preset, return_to_permissions); + self.chat_widget.open_full_access_confirmation( + preset, + return_to_permissions, + profile_selection, + ); } AppEvent::OpenWorldWritableWarningConfirmation { preset, + profile_selection, sample_paths, extra_count, failed_scan, } => { self.chat_widget.open_world_writable_warning_confirmation( preset, + profile_selection, sample_paths, extra_count, failed_scan, @@ -846,10 +852,17 @@ impl App { self.launch_external_editor(tui).await; } } - AppEvent::OpenWindowsSandboxEnablePrompt { preset } => { - self.chat_widget.open_windows_sandbox_enable_prompt(preset); + AppEvent::OpenWindowsSandboxEnablePrompt { + preset, + profile_selection, + } => { + self.chat_widget + .open_windows_sandbox_enable_prompt(preset, profile_selection); } - AppEvent::OpenWindowsSandboxFallbackPrompt { preset } => { + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + profile_selection, + } => { self.session_telemetry.counter( "codex.windows_sandbox.fallback_prompt_shown", /*inc*/ 1, @@ -864,12 +877,30 @@ impl App { ); } self.chat_widget - .open_windows_sandbox_fallback_prompt(preset); + .open_windows_sandbox_fallback_prompt(preset, profile_selection); } - AppEvent::BeginWindowsSandboxElevatedSetup { preset } => { + AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + profile_selection, + } => { #[cfg(target_os = "windows")] { - let permission_profile = preset.permission_profile.clone(); + let permission_profile = match self + .permission_profile_for_windows_setup(&preset, profile_selection.as_ref()) + .await + { + Ok(permission_profile) => permission_profile, + Err(err) => { + tracing::warn!( + error = %err, + "failed to resolve permission profile for elevated Windows sandbox setup" + ); + self.chat_widget.add_error_message(format!( + "Failed to prepare Windows sandbox for the selected permission profile: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; let policy_cwd = self.config.cwd.clone(); let command_cwd = policy_cwd.clone(); let env_map: std::collections::HashMap = @@ -885,6 +916,7 @@ impl App { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Elevated, + profile_selection, }); return Ok(AppRunControl::Continue); } @@ -901,7 +933,10 @@ impl App { ); }) else { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { preset }); + tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + profile_selection, + }); return Ok(AppRunControl::Continue); }; tokio::task::spawn_blocking(move || { @@ -922,6 +957,7 @@ impl App { AppEvent::EnableWindowsSandboxForAgentMode { preset: preset.clone(), mode: WindowsSandboxEnableMode::Elevated, + profile_selection: profile_selection.clone(), } } Err(err) => { @@ -953,7 +989,10 @@ impl App { error = %err, "failed to run elevated Windows sandbox setup" ); - AppEvent::OpenWindowsSandboxFallbackPrompt { preset } + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + profile_selection, + } } }; tx.send(event); @@ -961,13 +1000,31 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, profile_selection); } } - AppEvent::BeginWindowsSandboxLegacySetup { preset } => { + AppEvent::BeginWindowsSandboxLegacySetup { + preset, + profile_selection, + } => { #[cfg(target_os = "windows")] { - let permission_profile = preset.permission_profile.clone(); + let permission_profile = match self + .permission_profile_for_windows_setup(&preset, profile_selection.as_ref()) + .await + { + Ok(permission_profile) => permission_profile, + Err(err) => { + tracing::warn!( + error = %err, + "failed to resolve permission profile for legacy Windows sandbox setup" + ); + self.chat_widget.add_error_message(format!( + "Failed to prepare Windows sandbox for the selected permission profile: {err}" + )); + return Ok(AppRunControl::Continue); + } + }; let policy_cwd = self.config.cwd.clone(); let command_cwd = policy_cwd.clone(); let env_map: std::collections::HashMap = @@ -986,7 +1043,10 @@ impl App { ); }) else { - tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { preset }); + tx.send(AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + profile_selection, + }); return Ok(AppRunControl::Continue); }; tokio::task::spawn_blocking(move || { @@ -1012,12 +1072,13 @@ impl App { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Legacy, + profile_selection, }); }); } #[cfg(not(target_os = "windows"))] { - let _ = preset; + let _ = (preset, profile_selection); } } AppEvent::BeginWindowsSandboxGrantReadRoot { path } => { @@ -1080,7 +1141,11 @@ impl App { )); } }, - AppEvent::EnableWindowsSandboxForAgentMode { preset, mode } => { + AppEvent::EnableWindowsSandboxForAgentMode { + preset, + mode, + profile_selection, + } => { #[cfg(target_os = "windows")] { self.chat_widget.clear_windows_sandbox_setup_status(); @@ -1140,11 +1205,40 @@ impl App { self.app_event_tx.send( AppEvent::OpenWorldWritableWarningConfirmation { preset: Some(preset.clone()), + profile_selection: profile_selection.clone(), sample_paths, extra_count, failed_scan, }, ); + } else if let Some(selection) = profile_selection { + self.app_event_tx.send(AppEvent::CodexOp( + AppCommand::override_turn_context( + /*cwd*/ None, + /*approval_policy*/ None, + /*approvals_reviewer*/ None, + /*permission_profile*/ None, + /*active_permission_profile*/ None, + #[cfg(target_os = "windows")] + Some(windows_sandbox_level), + /*model*/ None, + /*effort*/ None, + /*summary*/ None, + /*service_tier*/ None, + /*collaboration_mode*/ None, + /*personality*/ None, + ), + )); + self.apply_permission_profile_selection(selection).await; + let _ = mode; + self.chat_widget.add_plain_history_lines(vec![ + Line::from(vec!["• ".dim(), "Sandbox ready".into()]), + Line::from(vec![ + " ".into(), + "Codex can now safely edit files and execute commands in your computer" + .dark_gray(), + ]), + ]); } else { self.app_event_tx.send(AppEvent::CodexOp( AppCommand::override_turn_context( @@ -1194,7 +1288,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = (preset, mode); + let _ = (preset, mode, profile_selection); } } AppEvent::PersistModelSelection { model, effort } => { @@ -1461,7 +1555,7 @@ impl App { return Ok(AppRunControl::Continue); } self.runtime_permission_profile_override = - Some(self.config.permissions.permission_profile().clone()); + Some(RuntimePermissionProfileOverride::from_config(&self.config)); self.sync_active_thread_permission_settings_to_cached_session() .await; @@ -1497,6 +1591,9 @@ impl App { } } } + AppEvent::SelectPermissionProfile(selection) => { + self.apply_permission_profile_selection(selection).await; + } AppEvent::UpdateApprovalsReviewer(policy) => { self.config.approvals_reviewer = policy; self.chat_widget.set_approvals_reviewer(policy); diff --git a/codex-rs/tui/src/app/platform_actions.rs b/codex-rs/tui/src/app/platform_actions.rs index 11289bc2aaee..c87776f48b1c 100644 --- a/codex-rs/tui/src/app/platform_actions.rs +++ b/codex-rs/tui/src/app/platform_actions.rs @@ -47,6 +47,7 @@ impl App { fn send_world_writable_scan_failed(tx: &AppEventSender) { tx.send(AppEvent::OpenWorldWritableWarningConfirmation { preset: None, + profile_selection: None, sample_paths: Vec::new(), extra_count: 0usize, failed_scan: true, diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 92e824aec724..c0050db28582 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -80,6 +80,7 @@ use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::ServiceTier; use codex_protocol::config_types::Settings; use codex_protocol::models::ActivePermissionProfile; @@ -1627,6 +1628,91 @@ async fn reset_memories_clears_local_memory_directories() -> Result<()> { .await } +#[tokio::test] +async fn apply_permission_profile_selection_preserves_loader_overrides() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + let selected_config = codex_home.path().join("work.config.toml"); + std::fs::write( + &selected_config, + r#" +default_permissions = "locked-down" + +[permissions.locked-down.filesystem] +":minimal" = "read" +"#, + )?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + app.loader_overrides.user_config_path = Some(selected_config.abs()); + app.harness_overrides.sandbox_mode = Some(SandboxMode::WorkspaceWrite); + app.harness_overrides.permission_profile = Some(PermissionProfile::workspace_write()); + + assert!( + app.apply_permission_profile_selection(PermissionProfileSelection { + profile_id: "locked-down".to_string(), + approval_policy: None, + approvals_reviewer: None, + display_label: "locked-down".to_string(), + }) + .await + ); + + assert_eq!( + app.config + .permissions + .active_permission_profile() + .as_ref() + .map(|profile| profile.id.as_str()), + Some("locked-down") + ); + assert_eq!( + app.chat_widget + .config_ref() + .permissions + .active_permission_profile() + .as_ref() + .map(|profile| profile.id.as_str()), + Some("locked-down") + ); + assert_eq!( + app.runtime_permission_profile_override, + Some(RuntimePermissionProfileOverride::from_config(&app.config)) + ); + let op = match app_event_rx.try_recv() { + Ok(AppEvent::CodexOp(op)) => op, + other => panic!("expected CodexOp event, got {other:?}"), + }; + assert_eq!( + op, + Op::OverrideTurnContext { + cwd: None, + approval_policy: None, + approvals_reviewer: None, + permission_profile: Some(app.config.permissions.permission_profile().clone()), + active_permission_profile: app.config.permissions.active_permission_profile(), + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + } + ); + let cell = match app_event_rx.try_recv() { + Ok(AppEvent::InsertHistoryCell(cell)) => cell, + other => panic!("expected InsertHistoryCell event, got {other:?}"), + }; + let rendered = cell + .display_lines(/*width*/ 120) + .into_iter() + .map(|line| line.to_string()) + .collect::>() + .join("\n"); + assert!(rendered.contains("Permissions updated to locked-down")); + Ok(()) +} + #[tokio::test] async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result<()> { let (mut app, mut app_event_rx, mut op_rx) = make_test_app_with_channels().await; @@ -1687,7 +1773,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< assert_eq!(app.runtime_approval_policy_override, None); assert_eq!( app.runtime_permission_profile_override, - Some(auto_review.permission_profile()) + Some(RuntimePermissionProfileOverride::from_config(&app.config)) ); assert_eq!( op_rx.try_recv(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index 2c2baf512ddc..8f15aed79a38 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -591,7 +591,9 @@ impl App { let permissions_override = Self::turn_permissions_override_from_config( config, active_permission_profile.as_ref(), - self.runtime_permission_profile_override.as_ref(), + self.runtime_permission_profile_override + .as_ref() + .map(|profile| &profile.permission_profile), ); app_server .turn_start( diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index a85ee5a70c9e..17b749971a2e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -686,6 +686,7 @@ pub(crate) enum AppEvent { OpenFullAccessConfirmation { preset: ApprovalPreset, return_to_permissions: bool, + profile_selection: Option, }, /// Open the Windows world-writable directories warning. @@ -695,6 +696,7 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWorldWritableWarningConfirmation { preset: Option, + profile_selection: Option, /// Up to 3 sample world-writable directories to display in the warning. sample_paths: Vec, /// If there are more than `sample_paths`, this carries the remaining count. @@ -707,24 +709,28 @@ pub(crate) enum AppEvent { #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxEnablePrompt { preset: ApprovalPreset, + profile_selection: Option, }, /// Open the Windows sandbox fallback prompt after declining or failing elevation. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] OpenWindowsSandboxFallbackPrompt { preset: ApprovalPreset, + profile_selection: Option, }, /// Begin the elevated Windows sandbox setup flow. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] BeginWindowsSandboxElevatedSetup { preset: ApprovalPreset, + profile_selection: Option, }, /// Begin the non-elevated Windows sandbox setup flow. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] BeginWindowsSandboxLegacySetup { preset: ApprovalPreset, + profile_selection: Option, }, /// Begin a non-elevated grant of read access for an additional directory. @@ -745,6 +751,7 @@ pub(crate) enum AppEvent { EnableWindowsSandboxForAgentMode { preset: ApprovalPreset, mode: WindowsSandboxEnableMode, + profile_selection: Option, }, /// Update the Windows sandbox feature mode without changing approval presets. @@ -756,6 +763,9 @@ pub(crate) enum AppEvent { /// Update the current built-in active permission profile in the running app and widget. UpdateActivePermissionProfile(ActivePermissionProfile), + /// Select a named permission profile, optionally applying built-in mode settings too. + SelectPermissionProfile(PermissionProfileSelection), + /// Update the current approvals reviewer in the running app and widget. UpdateApprovalsReviewer(ApprovalsReviewer), @@ -995,6 +1005,15 @@ pub(crate) enum AppEvent { }, } +/// Named profile selection to apply after any required UI guardrails complete. +#[derive(Debug, Clone)] +pub(crate) struct PermissionProfileSelection { + pub profile_id: String, + pub approval_policy: Option, + pub approvals_reviewer: Option, + pub display_label: String, +} + #[derive(Debug)] pub(crate) struct RealtimeWebrtcOffer { pub(crate) offer_sdp: String, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0ba974d88e4e..1b24daac26c3 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -256,6 +256,7 @@ fn queued_message_edit_hint_binding( use crate::app_event::AppEvent; use crate::app_event::ExitMode; +use crate::app_event::PermissionProfileSelection; use crate::app_event::RateLimitRefreshOrigin; #[cfg(target_os = "windows")] use crate::app_event::WindowsSandboxEnableMode; @@ -459,6 +460,7 @@ use unicode_segmentation::UnicodeSegmentation; const USER_SHELL_COMMAND_HELP_TITLE: &str = "Prefix a command with ! to run it locally"; const USER_SHELL_COMMAND_HELP_HINT: &str = "Example: !ls"; +const AUTO_REVIEW_DESCRIPTION: &str = "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent."; const DEFAULT_OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; const DEFAULT_STATUS_LINE_ITEMS: [&str; 2] = ["model-with-reasoning", "current-dir"]; const MAX_AGENT_COPY_HISTORY: usize = 32; diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index dc5d6c1870d9..d908cf67750e 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -55,7 +55,6 @@ impl ChatWidget { } else { preset.label.to_string() }; - let preset_approval = AskForApproval::from(preset.approval); let base_description = Some(preset.description.replace(" (Identical to Agent mode)", "")); let approval_disabled_reason = match self @@ -70,86 +69,13 @@ impl ChatWidget { let default_disabled_reason = approval_disabled_reason .clone() .or_else(|| guardian_disabled_reason(false)); - let requires_confirmation = preset.id == "full-access" - && !self - .config - .notices - .hide_full_access_warning - .unwrap_or(false); - let default_actions: Vec = if requires_confirmation { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenFullAccessConfirmation { - preset: preset_clone.clone(), - return_to_permissions: !include_read_only, - }); - })] - } else if preset.id == "auto" { - #[cfg(target_os = "windows")] - { - if WindowsSandboxLevel::from_config(&self.config) - == WindowsSandboxLevel::Disabled - { - let preset_clone = preset.clone(); - if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED - && crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( - self.config.codex_home.as_path(), - ) - { - vec![Box::new(move |tx| { - tx.send(AppEvent::EnableWindowsSandboxForAgentMode { - preset: preset_clone.clone(), - mode: WindowsSandboxEnableMode::Elevated, - }); - })] - } else { - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { - preset: preset_clone.clone(), - }); - })] - } - } else if let Some((sample_paths, extra_count, failed_scan)) = - self.world_writable_warning_details() - { - let preset_clone = preset.clone(); - vec![Box::new(move |tx| { - tx.send(AppEvent::OpenWorldWritableWarningConfirmation { - preset: Some(preset_clone.clone()), - sample_paths: sample_paths.clone(), - extra_count, - failed_scan, - }); - })] - } else { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - preset.active_permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - } - } - #[cfg(not(target_os = "windows"))] - { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - preset.active_permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - } - } else { - Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - preset.active_permission_profile.clone(), - base_name.clone(), - ApprovalsReviewer::User, - ) - }; + let default_actions = self.permission_mode_actions( + &preset, + base_name.clone(), + ApprovalsReviewer::User, + /*profile_selection*/ None, + /*return_to_permissions*/ !include_read_only, + ); if preset.id == "auto" { items.push(SelectionItem { name: base_name.clone(), @@ -170,10 +96,7 @@ impl ChatWidget { if guardian_approval_enabled { items.push(SelectionItem { name: "Auto-review".to_string(), - description: Some( - "Same workspace-write permissions as Default, but eligible `on-request` approvals are routed through the auto-reviewer subagent." - .to_string(), - ), + description: Some(AUTO_REVIEW_DESCRIPTION.to_string()), is_current: current_review_policy == ApprovalsReviewer::AutoReview && Self::preset_matches_current( current_approval, @@ -181,12 +104,12 @@ impl ChatWidget { self.config.cwd.as_path(), &preset, ), - actions: Self::approval_preset_actions( - preset_approval, - preset.permission_profile.clone(), - preset.active_permission_profile.clone(), + actions: self.permission_mode_actions( + &preset, "Auto-review".to_string(), ApprovalsReviewer::AutoReview, + /*profile_selection*/ None, + /*return_to_permissions*/ !include_read_only, ), dismiss_on_select: true, disabled_reason: approval_disabled_reason @@ -346,6 +269,97 @@ impl ChatWidget { })] } + pub(super) fn permission_profile_selection_actions( + selection: PermissionProfileSelection, + ) -> Vec { + vec![Box::new(move |tx| { + tx.send(AppEvent::SelectPermissionProfile(selection.clone())); + })] + } + + pub(super) fn permission_mode_actions( + &self, + preset: &ApprovalPreset, + label: String, + approvals_reviewer: ApprovalsReviewer, + profile_selection: Option, + return_to_permissions: bool, + ) -> Vec { + let apply_actions = || { + profile_selection.clone().map_or_else( + || { + Self::approval_preset_actions( + AskForApproval::from(preset.approval), + preset.permission_profile.clone(), + preset.active_permission_profile.clone(), + label.clone(), + approvals_reviewer, + ) + }, + Self::permission_profile_selection_actions, + ) + }; + let requires_confirmation = approvals_reviewer == ApprovalsReviewer::User + && preset.id == "full-access" + && !self + .config + .notices + .hide_full_access_warning + .unwrap_or(false); + if requires_confirmation { + let preset = preset.clone(); + return vec![Box::new(move |tx| { + tx.send(AppEvent::OpenFullAccessConfirmation { + preset: preset.clone(), + return_to_permissions, + profile_selection: profile_selection.clone(), + }); + })]; + } + if approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if WindowsSandboxLevel::from_config(&self.config) == WindowsSandboxLevel::Disabled { + let preset = preset.clone(); + if crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED + && crate::legacy_core::windows_sandbox::sandbox_setup_is_complete( + self.config.codex_home.as_path(), + ) + { + return vec![Box::new(move |tx| { + tx.send(AppEvent::EnableWindowsSandboxForAgentMode { + preset: preset.clone(), + mode: WindowsSandboxEnableMode::Elevated, + profile_selection: profile_selection.clone(), + }); + })]; + } + return vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWindowsSandboxEnablePrompt { + preset: preset.clone(), + profile_selection: profile_selection.clone(), + }); + })]; + } + if let Some((sample_paths, extra_count, failed_scan)) = + self.world_writable_warning_details() + { + let preset = preset.clone(); + return vec![Box::new(move |tx| { + tx.send(AppEvent::OpenWorldWritableWarningConfirmation { + preset: Some(preset.clone()), + profile_selection: profile_selection.clone(), + sample_paths: sample_paths.clone(), + extra_count, + failed_scan, + }); + })]; + } + } + } + apply_actions() + } + pub(super) fn preset_matches_current( current_approval: AskForApproval, current_permission_profile: &PermissionProfile, @@ -389,6 +403,7 @@ impl ChatWidget { &mut self, preset: ApprovalPreset, return_to_permissions: bool, + profile_selection: Option, ) { let selected_name = preset.label.to_string(); let approval = AskForApproval::from(preset.approval); @@ -406,23 +421,33 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions( - approval, - preset.permission_profile.clone(), - preset.active_permission_profile.clone(), - selected_name.clone(), - ApprovalsReviewer::User, + let mut accept_actions = profile_selection.clone().map_or_else( + || { + Self::approval_preset_actions( + approval, + preset.permission_profile.clone(), + preset.active_permission_profile.clone(), + selected_name.clone(), + ApprovalsReviewer::User, + ) + }, + Self::permission_profile_selection_actions, ); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); - let mut accept_and_remember_actions = Self::approval_preset_actions( - approval, - preset.permission_profile, - preset.active_permission_profile, - selected_name, - ApprovalsReviewer::User, + let mut accept_and_remember_actions = profile_selection.map_or_else( + || { + Self::approval_preset_actions( + approval, + preset.permission_profile, + preset.active_permission_profile, + selected_name, + ApprovalsReviewer::User, + ) + }, + Self::permission_profile_selection_actions, ); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 0df36f6bf890..eb3fc4eefd92 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -30,6 +30,30 @@ impl ChatWidget { Ok(()) } + pub(crate) fn set_permission_profile_with_active_profile( + &mut self, + profile: PermissionProfile, + active_permission_profile: Option, + ) -> ConstraintResult<()> { + self.config + .permissions + .set_permission_profile_from_session_snapshot( + PermissionProfileSnapshot::from_session_snapshot( + profile, + active_permission_profile, + ), + )?; + self.refresh_status_surfaces(); + Ok(()) + } + + pub(crate) fn set_permission_network( + &mut self, + network: Option, + ) { + self.config.permissions.network = network; + } + #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) fn set_windows_sandbox_mode(&mut self, mode: Option) { self.config.permissions.windows_sandbox_mode = mode; diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index aa9e5112f804..b479718304e1 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -297,7 +297,10 @@ impl ChatWidget { &[], ); self.app_event_tx - .send(AppEvent::BeginWindowsSandboxElevatedSetup { preset }); + .send(AppEvent::BeginWindowsSandboxElevatedSetup { + preset, + profile_selection: None, + }); } #[cfg(not(target_os = "windows"))] { diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index ae58271dff35..df59db17cbf3 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -166,7 +166,9 @@ async fn full_access_confirmation_popup_snapshot() { .into_iter() .find(|preset| preset.id == "full-access") .expect("full access preset"); - chat.open_full_access_confirmation(preset, /*return_to_permissions*/ false); + chat.open_full_access_confirmation( + preset, /*return_to_permissions*/ false, /*profile_selection*/ None, + ); let popup = render_bottom_popup(&chat, /*width*/ 80); assert_chatwidget_snapshot!("full_access_confirmation_popup", popup); @@ -181,7 +183,7 @@ async fn windows_auto_mode_prompt_requests_enabling_sandbox_feature() { .into_iter() .find(|preset| preset.id == "auto") .expect("auto preset"); - chat.open_windows_sandbox_enable_prompt(preset); + chat.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); let popup = render_bottom_popup(&chat, /*width*/ 120); assert!( @@ -799,8 +801,9 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() AppEvent::OpenFullAccessConfirmation { preset, return_to_permissions, + profile_selection, } => { - open_confirmation_event = Some((preset, return_to_permissions)); + open_confirmation_event = Some((preset, return_to_permissions, profile_selection)); } _ => {} } @@ -811,9 +814,9 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation() "did not expect history cell before confirming full access" ); } - let (preset, return_to_permissions) = + let (preset, return_to_permissions, profile_selection) = open_confirmation_event.expect("expected full access confirmation event"); - chat.open_full_access_confirmation(preset, return_to_permissions); + chat.open_full_access_confirmation(preset, return_to_permissions, profile_selection); let popup = render_bottom_popup(&chat, /*width*/ 80); assert!( diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index 3b71d467bf29..dd90af0557b7 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -38,6 +38,7 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, preset: Option, + profile_selection: Option, sample_paths: Vec, extra_count: usize, failed_scan: bool, @@ -111,7 +112,9 @@ impl ChatWidget { tx.send(AppEvent::SkipNextWorldWritableScan); })); } - if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) = ( + if let Some(selection) = profile_selection.clone() { + accept_actions.extend(Self::permission_profile_selection_actions(selection)); + } else if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) = ( approval, permission_profile.clone(), active_permission_profile.clone(), @@ -130,7 +133,10 @@ impl ChatWidget { tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); - if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) = + if let Some(selection) = profile_selection { + accept_and_remember_actions + .extend(Self::permission_profile_selection_actions(selection)); + } else if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) = (approval, permission_profile, active_permission_profile) { accept_and_remember_actions.extend(Self::approval_preset_actions( @@ -171,6 +177,7 @@ impl ChatWidget { pub(crate) fn open_world_writable_warning_confirmation( &mut self, _preset: Option, + _profile_selection: Option, _sample_paths: Vec, _extra_count: usize, _failed_scan: bool, @@ -178,7 +185,11 @@ impl ChatWidget { } #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_enable_prompt(&mut self, preset: ApprovalPreset) { + pub(crate) fn open_windows_sandbox_enable_prompt( + &mut self, + preset: ApprovalPreset, + profile_selection: Option, + ) { use ratatui_macros::line; if !crate::legacy_core::windows_sandbox::ELEVATED_SANDBOX_NUX_ENABLED { @@ -202,6 +213,7 @@ impl ChatWidget { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset: preset_clone.clone(), mode: WindowsSandboxEnableMode::Legacy, + profile_selection: profile_selection.clone(), }); })], dismiss_on_select: true, @@ -245,6 +257,7 @@ impl ChatWidget { let accept_otel = self.session_telemetry.clone(); let legacy_otel = self.session_telemetry.clone(); let legacy_preset = preset.clone(); + let legacy_profile_selection = profile_selection.clone(); let quit_otel = self.session_telemetry.clone(); let items = vec![ SelectionItem { @@ -258,6 +271,7 @@ impl ChatWidget { ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), + profile_selection: profile_selection.clone(), }); })], dismiss_on_select: true, @@ -274,6 +288,7 @@ impl ChatWidget { ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: legacy_preset.clone(), + profile_selection: legacy_profile_selection.clone(), }); })], dismiss_on_select: true, @@ -305,10 +320,19 @@ 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_enable_prompt( + &mut self, + _preset: ApprovalPreset, + _profile_selection: Option, + ) { + } #[cfg(target_os = "windows")] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, preset: ApprovalPreset) { + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + preset: ApprovalPreset, + profile_selection: Option, + ) { use ratatui_macros::line; let mut lines = Vec::new(); @@ -328,6 +352,8 @@ impl ChatWidget { let elevated_preset = preset.clone(); let legacy_preset = preset; + let elevated_profile_selection = profile_selection.clone(); + let legacy_profile_selection = profile_selection; let quit_otel = self.session_telemetry.clone(); let items = vec![ SelectionItem { @@ -344,6 +370,7 @@ impl ChatWidget { ); tx.send(AppEvent::BeginWindowsSandboxElevatedSetup { preset: preset.clone(), + profile_selection: elevated_profile_selection.clone(), }); } })], @@ -364,6 +391,7 @@ impl ChatWidget { ); tx.send(AppEvent::BeginWindowsSandboxLegacySetup { preset: preset.clone(), + profile_selection: legacy_profile_selection.clone(), }); } })], @@ -396,7 +424,12 @@ impl ChatWidget { } #[cfg(not(target_os = "windows"))] - pub(crate) fn open_windows_sandbox_fallback_prompt(&mut self, _preset: ApprovalPreset) {} + pub(crate) fn open_windows_sandbox_fallback_prompt( + &mut self, + _preset: ApprovalPreset, + _profile_selection: Option, + ) { + } #[cfg(target_os = "windows")] pub(crate) fn maybe_prompt_windows_sandbox_enable(&mut self, show_now: bool) { @@ -406,7 +439,7 @@ impl ChatWidget { .into_iter() .find(|preset| preset.id == "auto") { - self.open_windows_sandbox_enable_prompt(preset); + self.open_windows_sandbox_enable_prompt(preset, /*profile_selection*/ None); } }