From 9e9a4059f00773e09a00da32a253a30f11d89ff9 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 7 May 2026 17:11:05 -0700 Subject: [PATCH 01/15] fix: refresh profile network proxy on permission switches Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/config/mod.rs | 91 +++++++++---- codex-rs/core/src/guardian/review.rs | 3 +- codex-rs/core/src/session/mod.rs | 39 +++++- codex-rs/core/src/session/session.rs | 33 ++++- codex-rs/core/src/session/tests.rs | 124 +++++++++++++++++- codex-rs/core/src/session/turn_context.rs | 1 + codex-rs/core/src/state/service.rs | 6 +- codex-rs/core/src/tasks/mod.rs | 3 +- codex-rs/tui/src/app/config_persistence.rs | 2 + codex-rs/tui/src/app/event_dispatch.rs | 2 + codex-rs/tui/src/app/tests.rs | 6 + codex-rs/tui/src/app_command.rs | 4 + codex-rs/tui/src/chatwidget/service_tiers.rs | 1 + .../tui/src/chatwidget/tests/permissions.rs | 1 + 14 files changed, 278 insertions(+), 38 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 434969e2c5f4..6960bcdd5a44 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -495,6 +495,39 @@ fn profile_allows_configured_network_proxy(permission_profile: &PermissionProfil } } +fn build_network_proxy_spec( + configured_network_proxy_config: NetworkProxyConfig, + network_requirements: Option>, + permission_profile: &PermissionProfile, +) -> std::io::Result> { + let (network_requirements, network_requirements_source) = match network_requirements { + Some(Sourced { value, source }) => (Some(value), Some(source)), + None => (None, None), + }; + let has_network_requirements = network_requirements.is_some(); + let network = NetworkProxySpec::from_config_and_constraints( + configured_network_proxy_config, + network_requirements, + permission_profile, + ) + .map_err(|err| { + if let Some(source) = network_requirements_source.as_ref() { + std::io::Error::new( + err.kind(), + format!("failed to build managed network proxy from {source}: {err}"), + ) + } else { + err + } + })?; + + Ok(if has_network_requirements { + Some(network) + } else { + network.enabled().then_some(network) + }) +} + /// Configured thread persistence backend. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub enum ThreadStoreConfig { @@ -3320,32 +3353,12 @@ impl Config { let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref()) .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?; - let (network_requirements, network_requirements_source) = match network_requirements { - Some(Sourced { value, source }) => (Some(value), Some(source)), - None => (None, None), - }; - let has_network_requirements = network_requirements.is_some(); let network_permission_profile = constrained_permission_profile.get().clone(); - let network = NetworkProxySpec::from_config_and_constraints( + let network = build_network_proxy_spec( configured_network_proxy_config, network_requirements, &network_permission_profile, - ) - .map_err(|err| { - if let Some(source) = network_requirements_source.as_ref() { - std::io::Error::new( - err.kind(), - format!("failed to build managed network proxy from {source}: {err}"), - ) - } else { - err - } - })?; - let network = if has_network_requirements { - Some(network) - } else { - network.enabled().then_some(network) - }; + )?; let helper_readable_roots = get_readable_roots_required_for_codex_runtime( &codex_home, zsh_path.as_ref(), @@ -3700,6 +3713,40 @@ impl Config { .is_some() } + pub(crate) fn network_proxy_spec_for_active_permission_profile( + &self, + active_permission_profile: &ActivePermissionProfile, + permission_profile: &PermissionProfile, + ) -> std::io::Result> { + let configured_network_proxy_config = + if profile_allows_configured_network_proxy(permission_profile) { + let cfg: ConfigToml = self + .config_layer_stack + .effective_config() + .try_into() + .map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "failed to read effective config for selected permission profile: {err}" + ), + ) + })?; + network_proxy_config_for_profile_selection( + cfg.permissions.as_ref(), + active_permission_profile.id.as_str(), + )? + } else { + NetworkProxyConfig::default() + }; + + build_network_proxy_spec( + configured_network_proxy_config, + self.config_layer_stack.requirements().network.clone(), + permission_profile, + ) + } + pub fn bundled_skills_enabled(&self) -> bool { crate::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack) } diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index 2908492ddd5b..7df7a96928a5 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -635,7 +635,8 @@ pub(super) async fn run_guardian_review_session( schema: serde_json::Value, external_cancel: Option, ) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) { - let live_network_config = match session.services.network_proxy.as_ref() { + let network_proxy = session.services.network_proxy.load_full(); + let live_network_config = match network_proxy.as_ref() { Some(network_proxy) => match network_proxy.proxy().current_cfg().await { Ok(config) => Some(config), Err(err) => { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index b710e095ef87..55fd5444c6d2 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -952,9 +952,6 @@ impl Session { } async fn refresh_managed_network_proxy_for_current_permission_profile(&self) { - let Some(started_proxy) = self.services.network_proxy.as_ref() else { - return; - }; let Ok(_refresh_guard) = self.managed_network_proxy_refresh_lock.acquire().await else { error!("managed network proxy refresh semaphore closed"); return; @@ -968,7 +965,9 @@ impl Session { .permissions .network .as_ref() + .cloned() else { + self.services.network_proxy.store(None); return; }; @@ -991,8 +990,36 @@ impl Session { spec } }; - if let Err(err) = spec.apply_to_started_proxy(started_proxy).await { - warn!("failed to refresh managed network proxy for sandbox change: {err}"); + if let Some(started_proxy) = self.services.network_proxy.load_full() { + if let Err(err) = spec.apply_to_started_proxy(started_proxy.as_ref()).await { + warn!("failed to refresh managed network proxy for sandbox change: {err}"); + } + return; + } + + match Self::start_managed_network_proxy( + &spec, + current_exec_policy.as_ref(), + &session_configuration.permission_profile(), + /*network_policy_decider*/ None, + self.services + .managed_network_requirements_configured + .then(|| { + build_blocked_request_observer(Arc::clone(&self.services.network_approval)) + }), + self.services.managed_network_requirements_configured, + self.services.network_proxy_audit_metadata.clone(), + ) + .await + { + Ok((started_proxy, _session_network_proxy)) => { + self.services + .network_proxy + .store(Some(Arc::new(started_proxy))); + } + Err(err) => { + warn!("failed to start managed network proxy for sandbox change: {err}"); + } } } @@ -1886,7 +1913,7 @@ impl Session { let execpolicy_amendment = execpolicy_network_rule_amendment(amendment, network_approval_context, &host); - if let Some(started_network_proxy) = self.services.network_proxy.as_ref() { + if let Some(started_network_proxy) = self.services.network_proxy.load_full() { let proxy = started_network_proxy.proxy(); match amendment.action { NetworkPolicyRuleAction::Allow => proxy diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 88958b39ef40..8d6d1b49be2b 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1,5 +1,6 @@ use super::input_queue::InputQueue; use super::*; +use crate::config::ConstraintError; use crate::goals::GoalRuntimeState; use crate::skills::SkillError; use crate::state::ActiveTurn; @@ -291,6 +292,32 @@ impl SessionConfiguration { updates.profile_workspace_roots.clone().unwrap_or_default(), Some(¤t_file_system_sandbox_policy), )?; + if let Some(active_permission_profile) = + next_configuration.active_permission_profile() + { + let mut config = (*next_configuration.original_config_do_not_use).clone(); + let permission_profile = next_configuration.permission_profile(); + config.permissions.network = config + .network_proxy_spec_for_active_permission_profile( + &active_permission_profile, + &permission_profile, + ) + .map_err(|err| ConstraintError::InvalidValue { + field_name: "default_permissions", + candidate: active_permission_profile.id.clone(), + allowed: format!( + "configured permission profile with valid network policy ({err})" + ), + requirement_source: codex_config::RequirementSource::Unknown, + })?; + config + .permissions + .set_permission_profile_with_active_profile( + permission_profile, + Some(active_permission_profile), + )?; + next_configuration.original_config_do_not_use = Arc::new(config); + } } else if let Some(sandbox_policy) = updates.sandbox_policy.clone() { let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries( @@ -885,7 +912,7 @@ impl Session { network_policy_decider.as_ref().map(Arc::clone), blocked_request_observer.as_ref().map(Arc::clone), managed_network_requirements_configured, - network_proxy_audit_metadata, + network_proxy_audit_metadata.clone(), ) .instrument(info_span!( "session_init.network_proxy", @@ -977,7 +1004,9 @@ impl Session { session_extension_data, thread_extension_data, agent_control, - network_proxy, + network_proxy: arc_swap::ArcSwapOption::from(network_proxy.map(Arc::new)), + network_proxy_audit_metadata, + managed_network_requirements_configured, network_approval: Arc::clone(&network_approval), state_db: state_db_ctx.clone(), live_thread: live_thread_init.as_ref().cloned(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index a08243701c68..1bf502a9958d 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -1,6 +1,7 @@ use super::turn_context::TurnEnvironment; use super::*; use crate::config::ConfigBuilder; +use crate::config::ConfigOverrides; use crate::config::test_config; use crate::context::ContextualUserFragment; use crate::context::TurnAborted; @@ -80,6 +81,11 @@ use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::McpElicitationSchema; use codex_config::config_toml::ConfigToml; use codex_config::config_toml::ProjectConfig; +use codex_config::permissions_toml::FilesystemPermissionToml; +use codex_config::permissions_toml::FilesystemPermissionsToml; +use codex_config::permissions_toml::NetworkToml; +use codex_config::permissions_toml::PermissionProfileToml; +use codex_config::permissions_toml::PermissionsToml; use codex_execpolicy::Decision; use codex_execpolicy::NetworkRuleProtocol; use codex_execpolicy::Policy; @@ -818,7 +824,7 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R #[tokio::test] async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow::Result<()> { - let (mut session, _turn_context) = make_session_and_context().await; + let (session, _turn_context) = make_session_and_context().await; let initial_permission_profile = PermissionProfile::workspace_write(); let mut network_config = NetworkProxyConfig::default(); @@ -873,7 +879,10 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow .set_permission_profile_for_tests(initial_permission_profile) .expect("test setup should allow permission profile"); } - session.services.network_proxy = Some(started_proxy); + session + .services + .network_proxy + .store(Some(Arc::new(started_proxy))); session .new_turn_with_sub_id( @@ -888,7 +897,7 @@ async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow let started_proxy = session .services .network_proxy - .as_ref() + .load_full() .expect("managed network proxy should be present"); assert_eq!( started_proxy @@ -3805,6 +3814,107 @@ async fn session_configuration_apply_retargets_implicit_workspace_root_on_cwd_up assert!(!updated_policy.can_write_path_with_cwd(old_root.as_path(), updated.cwd.as_path())); } +#[tokio::test] +async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Result<()> { + let codex_home = tempfile::tempdir().expect("create codex home"); + let cwd = tempfile::tempdir().expect("create cwd"); + let permissions = PermissionsToml { + entries: std::collections::BTreeMap::from([ + ( + "locked-down".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: std::collections::BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: None, + }, + ), + ( + "web-enabled".to_string(), + PermissionProfileToml { + filesystem: Some(FilesystemPermissionsToml { + glob_scan_max_depth: None, + entries: std::collections::BTreeMap::from([( + ":minimal".to_string(), + FilesystemPermissionToml::Access(FileSystemAccessMode::Read), + )]), + }), + network: Some(NetworkToml { + enabled: Some(true), + proxy_url: Some("http://127.0.0.1:43128".to_string()), + enable_socks5: Some(false), + ..Default::default() + }), + }, + ), + ]), + }; + let base_config = ConfigToml { + default_permissions: Some("locked-down".to_string()), + permissions: Some(permissions), + ..Default::default() + }; + std::fs::write( + codex_home.path().join(codex_config::CONFIG_TOML_FILE), + toml::to_string(&base_config).expect("serialize config"), + )?; + let locked_config = Arc::new( + ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + ..Default::default() + }) + .build() + .await?, + ); + assert_ne!( + locked_config + .permissions + .network + .as_ref() + .map(crate::config::NetworkProxySpec::proxy_host_and_port) + .as_deref(), + Some("127.0.0.1:43128") + ); + let selected_config = ConfigBuilder::default() + .codex_home(codex_home.path().to_path_buf()) + .harness_overrides(ConfigOverrides { + cwd: Some(cwd.path().to_path_buf()), + default_permissions: Some("web-enabled".to_string()), + ..Default::default() + }) + .build() + .await?; + + let mut session_configuration = make_session_configuration_for_tests().await; + session_configuration.permission_profile_state = + locked_config.permissions.permission_profile_state().clone(); + session_configuration.original_config_do_not_use = Arc::clone(&locked_config); + + let updated = session_configuration + .apply(&SessionSettingsUpdate { + permission_profile: Some(selected_config.permissions.permission_profile()), + active_permission_profile: selected_config.permissions.active_permission_profile(), + ..Default::default() + }) + .expect("active profile update should apply"); + + let network = updated + .original_config_do_not_use + .permissions + .network + .as_ref() + .expect("selected profile proxy should become the session proxy config"); + assert_eq!(network.proxy_host_and_port(), "127.0.0.1:43128"); + assert!(!network.socks_enabled()); + Ok(()) +} + #[cfg_attr(windows, ignore)] #[tokio::test] async fn new_default_turn_uses_config_aware_skills_for_role_overrides() { @@ -4359,7 +4469,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, - network_proxy: None, + network_proxy: arc_swap::ArcSwapOption::from(None), + network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), + managed_network_requirements_configured: false, network_approval: Arc::clone(&network_approval), state_db: None, live_thread: None, @@ -6186,7 +6298,9 @@ where ), thread_extension_data: codex_extension_api::ExtensionData::new(thread_id.to_string()), agent_control, - network_proxy: None, + network_proxy: arc_swap::ArcSwapOption::from(None), + network_proxy_audit_metadata: crate::config::NetworkProxyAuditMetadata::default(), + managed_network_requirements_configured: false, network_approval: Arc::clone(&network_approval), state_db: state_db.clone(), live_thread: None, diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 3338d0a5581f..818a8b7bbe3a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -704,6 +704,7 @@ impl Session { &self.services.models_manager, self.services .network_proxy + .load_full() .as_ref() .and_then(|started_proxy| { Self::managed_network_proxy_active_for_permission_profile( diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index afba409a068e..59def9c7abac 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -5,6 +5,7 @@ use crate::SkillsManager; use crate::agent::AgentControl; use crate::attestation::AttestationProvider; use crate::client::ModelClient; +use crate::config::NetworkProxyAuditMetadata; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; use crate::guardian::GuardianRejection; @@ -15,6 +16,7 @@ use crate::tools::network_approval::NetworkApprovalService; use crate::tools::sandboxing::ApprovalStore; use crate::unified_exec::UnifiedExecProcessManager; use arc_swap::ArcSwap; +use arc_swap::ArcSwapOption; use codex_analytics::AnalyticsEventsClient; use codex_core_plugins::PluginsManager; use codex_exec_server::EnvironmentManager; @@ -65,7 +67,9 @@ pub(crate) struct SessionServices { pub(crate) session_extension_data: ExtensionData, pub(crate) thread_extension_data: ExtensionData, pub(crate) agent_control: AgentControl, - pub(crate) network_proxy: Option, + pub(crate) network_proxy: ArcSwapOption, + pub(crate) network_proxy_audit_metadata: NetworkProxyAuditMetadata, + pub(crate) managed_network_requirements_configured: bool, pub(crate) network_approval: Arc, pub(crate) state_db: Option, pub(crate) live_thread: Option, diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs index 80ddfee89742..9cb6b5f0f20e 100644 --- a/codex-rs/core/src/tasks/mod.rs +++ b/codex-rs/core/src/tasks/mod.rs @@ -655,7 +655,8 @@ impl Session { "false" }, ); - let network_proxy_active = match self.services.network_proxy.as_ref() { + let network_proxy = self.services.network_proxy.load_full(); + let network_proxy_active = match network_proxy.as_ref() { Some(started_network_proxy) => { match started_network_proxy.proxy().current_cfg().await { Ok(config) => config.network.enabled, diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 1f594d64cef0..3955b5ab09dd 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -360,6 +360,7 @@ impl App { /*cwd*/ None, approval_policy_override, approvals_reviewer_override, + permission_profile_override, active_permission_profile_override, /*windows_sandbox_level*/ None, /*model*/ None, @@ -387,6 +388,7 @@ impl App { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, + /*permission_profile*/ None, /*active_permission_profile*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ce0eb0ff936a..51615f6a956f 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -1125,6 +1125,7 @@ impl App { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, + /*permission_profile*/ None, /*active_permission_profile*/ None, #[cfg(target_os = "windows")] Some(windows_sandbox_level), @@ -1150,6 +1151,7 @@ impl App { /*cwd*/ None, Some(AskForApproval::from(preset.approval)), Some(self.config.approvals_reviewer), + Some(preset.permission_profile.clone()), Some(preset.active_permission_profile.clone()), #[cfg(target_os = "windows")] Some(windows_sandbox_level), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 0a2f382a8a4d..d99b831bd47e 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1695,6 +1695,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), + permission_profile: Some(auto_review.permission_profile.clone()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -1787,6 +1788,7 @@ async fn update_feature_flags_disabling_guardian_clears_review_policy_and_restor cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), + permission_profile: None, active_permission_profile: None, windows_sandbox_level: None, model: None, @@ -1865,6 +1867,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), + permission_profile: Some(auto_review.permission_profile.clone()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -1922,6 +1925,7 @@ async fn update_feature_flags_disabling_guardian_clears_manual_review_policy_wit cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), + permission_profile: None, active_permission_profile: None, windows_sandbox_level: None, model: None, @@ -1981,6 +1985,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), + permission_profile: Some(auto_review.permission_profile.clone()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -2068,6 +2073,7 @@ guardian_approval = true cwd: None, approval_policy: None, approvals_reviewer: Some(ApprovalsReviewer::User), + permission_profile: None, active_permission_profile: None, windows_sandbox_level: None, model: None, diff --git a/codex-rs/tui/src/app_command.rs b/codex-rs/tui/src/app_command.rs index 0b6484d0155e..2ecf17f5a34c 100644 --- a/codex-rs/tui/src/app_command.rs +++ b/codex-rs/tui/src/app_command.rs @@ -17,6 +17,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; use codex_protocol::request_permissions::RequestPermissionsResponse; use serde::Serialize; @@ -54,6 +55,7 @@ pub(crate) enum AppCommand { cwd: Option, approval_policy: Option, approvals_reviewer: Option, + permission_profile: Option, active_permission_profile: Option, windows_sandbox_level: Option, model: Option, @@ -172,6 +174,7 @@ impl AppCommand { cwd: Option, approval_policy: Option, approvals_reviewer: Option, + permission_profile: Option, active_permission_profile: Option, windows_sandbox_level: Option, model: Option, @@ -185,6 +188,7 @@ impl AppCommand { cwd, approval_policy, approvals_reviewer, + permission_profile, active_permission_profile, windows_sandbox_level, model, diff --git a/codex-rs/tui/src/chatwidget/service_tiers.rs b/codex-rs/tui/src/chatwidget/service_tiers.rs index a60af0a69eb2..fd11048cb242 100644 --- a/codex-rs/tui/src/chatwidget/service_tiers.rs +++ b/codex-rs/tui/src/chatwidget/service_tiers.rs @@ -107,6 +107,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, + /*permission_profile*/ None, /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index 71483bd39a70..ae58271dff35 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -743,6 +743,7 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context cwd: None, approval_policy: Some(AskForApproval::OnRequest), approvals_reviewer: Some(ApprovalsReviewer::AutoReview), + permission_profile: Some(PermissionProfile::workspace_write()), active_permission_profile: Some(ActivePermissionProfile::new( BUILT_IN_PERMISSION_PROFILE_WORKSPACE, )), From b79b55650fd4107ad1d8f563b7b35396dfa52c4f Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Thu, 7 May 2026 17:21:02 -0700 Subject: [PATCH 02/15] style: stabilize network proxy config formatting Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/config/mod.rs | 43 +++++++++++++++++---------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 6960bcdd5a44..fade3ea392d9 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3718,27 +3718,28 @@ impl Config { active_permission_profile: &ActivePermissionProfile, permission_profile: &PermissionProfile, ) -> std::io::Result> { - let configured_network_proxy_config = - if profile_allows_configured_network_proxy(permission_profile) { - let cfg: ConfigToml = self - .config_layer_stack - .effective_config() - .try_into() - .map_err(|err| { - std::io::Error::new( - ErrorKind::InvalidInput, - format!( - "failed to read effective config for selected permission profile: {err}" - ), - ) - })?; - network_proxy_config_for_profile_selection( - cfg.permissions.as_ref(), - active_permission_profile.id.as_str(), - )? - } else { - NetworkProxyConfig::default() - }; + let profile_allows_network_proxy = + profile_allows_configured_network_proxy(permission_profile); + let configured_network_proxy_config = if profile_allows_network_proxy { + let cfg: ConfigToml = self + .config_layer_stack + .effective_config() + .try_into() + .map_err(|err| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!( + "failed to read effective config for selected permission profile: {err}" + ), + ) + })?; + network_proxy_config_for_profile_selection( + cfg.permissions.as_ref(), + active_permission_profile.id.as_str(), + )? + } else { + NetworkProxyConfig::default() + }; build_network_proxy_spec( configured_network_proxy_config, From 7d923c586a1eeef34bf0c072adffdfe24f57ed9e Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 15 May 2026 16:54:33 -0700 Subject: [PATCH 03/15] fix: align runtime profile refresh with current session APIs Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/session/session.rs | 2 +- codex-rs/core/src/session/tests.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 8d6d1b49be2b..ebe1027dba5e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -312,7 +312,7 @@ impl SessionConfiguration { })?; config .permissions - .set_permission_profile_with_active_profile( + .set_permission_profile_from_session_snapshot( permission_profile, Some(active_permission_profile), )?; diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 1bf502a9958d..851d894d783e 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3823,6 +3823,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul ( "locked-down".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: std::collections::BTreeMap::from([( @@ -3836,6 +3837,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul ( "web-enabled".to_string(), PermissionProfileToml { + workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, entries: std::collections::BTreeMap::from([( @@ -3898,7 +3900,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul let updated = session_configuration .apply(&SessionSettingsUpdate { - permission_profile: Some(selected_config.permissions.permission_profile()), + permission_profile: Some(selected_config.permissions.permission_profile().clone()), active_permission_profile: selected_config.permissions.active_permission_profile(), ..Default::default() }) From 0f94819a746d96e56f56ed76cb96107d13a54891 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Fri, 15 May 2026 17:01:28 -0700 Subject: [PATCH 04/15] style(core): satisfy session formatting Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/session/session.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index ebe1027dba5e..6882303c56d2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -292,8 +292,7 @@ impl SessionConfiguration { updates.profile_workspace_roots.clone().unwrap_or_default(), Some(¤t_file_system_sandbox_policy), )?; - if let Some(active_permission_profile) = - next_configuration.active_permission_profile() + if let Some(active_permission_profile) = next_configuration.active_permission_profile() { let mut config = (*next_configuration.original_config_do_not_use).clone(); let permission_profile = next_configuration.permission_profile(); From e84f80c735be78101096da0994adea1bde7d4450 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 18 May 2026 10:46:33 -0700 Subject: [PATCH 05/15] fix(core): adopt permission snapshot refresh API Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/session/session.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 6882303c56d2..7ae5ff241d4e 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -312,8 +312,10 @@ impl SessionConfiguration { config .permissions .set_permission_profile_from_session_snapshot( - permission_profile, - Some(active_permission_profile), + PermissionProfileSnapshot::active( + permission_profile, + active_permission_profile, + ), )?; next_configuration.original_config_do_not_use = Arc::new(config); } From a91223148490a26186faa5eb41d66dd1cdef1916 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 18 May 2026 18:17:53 -0700 Subject: [PATCH 06/15] fix(tui): restore permission profile override plumbing Co-authored-by: Codex noreply@openai.com --- codex-rs/tui/src/app/tests.rs | 6 +++--- codex-rs/tui/src/chatwidget/permission_popups.rs | 8 ++++++++ codex-rs/tui/src/chatwidget/rate_limits.rs | 1 + codex-rs/tui/src/chatwidget/settings_popups.rs | 1 + 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d99b831bd47e..a501c2519072 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1695,7 +1695,7 @@ async fn update_feature_flags_enabling_guardian_selects_auto_review() -> Result< cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + permission_profile: Some(auto_review.permission_profile()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -1867,7 +1867,7 @@ async fn update_feature_flags_enabling_guardian_overrides_explicit_manual_review cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + permission_profile: Some(auto_review.permission_profile()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, @@ -1985,7 +1985,7 @@ async fn update_feature_flags_enabling_guardian_in_profile_sets_profile_auto_rev cwd: None, approval_policy: Some(auto_review.approval_policy), approvals_reviewer: Some(auto_review.approvals_reviewer), - permission_profile: Some(auto_review.permission_profile.clone()), + permission_profile: Some(auto_review.permission_profile()), active_permission_profile: Some(auto_review.active_permission_profile.clone()), windows_sandbox_level: None, model: None, diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs index a82f3a6cdbed..dc5d6c1870d9 100644 --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -124,6 +124,7 @@ impl ChatWidget { } else { Self::approval_preset_actions( preset_approval, + preset.permission_profile.clone(), preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -134,6 +135,7 @@ impl ChatWidget { { Self::approval_preset_actions( preset_approval, + preset.permission_profile.clone(), preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -142,6 +144,7 @@ impl ChatWidget { } else { Self::approval_preset_actions( preset_approval, + preset.permission_profile.clone(), preset.active_permission_profile.clone(), base_name.clone(), ApprovalsReviewer::User, @@ -180,6 +183,7 @@ impl ChatWidget { ), actions: Self::approval_preset_actions( preset_approval, + preset.permission_profile.clone(), preset.active_permission_profile.clone(), "Auto-review".to_string(), ApprovalsReviewer::AutoReview, @@ -308,6 +312,7 @@ impl ChatWidget { pub(super) fn approval_preset_actions( approval: AskForApproval, + permission_profile: PermissionProfile, active_permission_profile: ActivePermissionProfile, label: String, approvals_reviewer: ApprovalsReviewer, @@ -317,6 +322,7 @@ impl ChatWidget { /*cwd*/ None, Some(approval), Some(approvals_reviewer), + Some(permission_profile.clone()), Some(active_permission_profile.clone()), /*windows_sandbox_level*/ None, /*model*/ None, @@ -402,6 +408,7 @@ impl ChatWidget { let mut accept_actions = Self::approval_preset_actions( approval, + preset.permission_profile.clone(), preset.active_permission_profile.clone(), selected_name.clone(), ApprovalsReviewer::User, @@ -412,6 +419,7 @@ impl ChatWidget { let mut accept_and_remember_actions = Self::approval_preset_actions( approval, + preset.permission_profile, preset.active_permission_profile, selected_name, ApprovalsReviewer::User, diff --git a/codex-rs/tui/src/chatwidget/rate_limits.rs b/codex-rs/tui/src/chatwidget/rate_limits.rs index 544611f4f0fd..df45a11c7d37 100644 --- a/codex-rs/tui/src/chatwidget/rate_limits.rs +++ b/codex-rs/tui/src/chatwidget/rate_limits.rs @@ -308,6 +308,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, + /*permission_profile*/ None, /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, Some(switch_model_for_events.clone()), diff --git a/codex-rs/tui/src/chatwidget/settings_popups.rs b/codex-rs/tui/src/chatwidget/settings_popups.rs index 2dcfefb73ace..357223a6ca41 100644 --- a/codex-rs/tui/src/chatwidget/settings_popups.rs +++ b/codex-rs/tui/src/chatwidget/settings_popups.rs @@ -53,6 +53,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, + /*permission_profile*/ None, /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, From fcf5260e738cd22731d3358e4add60330c0b5b31 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 18 May 2026 18:49:43 -0700 Subject: [PATCH 07/15] fix(ci): repair profile permission refresh Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/config/mod.rs | 16 ++++++++++++++-- .../src/chatwidget/windows_sandbox_prompts.rs | 19 ++++++++++++------- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index fade3ea392d9..ff09f26c2845 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3733,10 +3733,22 @@ impl Config { ), ) })?; - network_proxy_config_for_profile_selection( + let mut configured_network_proxy_config = network_proxy_config_for_profile_selection( cfg.permissions.as_ref(), active_permission_profile.id.as_str(), - )? + )?; + if self.features.enabled(Feature::NetworkProxy) + && permission_profile.network_sandbox_policy().is_enabled() + { + if let Some(network_proxy) = network_proxy_toml_config(cfg.features.as_ref()) { + apply_network_proxy_feature_config( + &mut configured_network_proxy_config, + network_proxy, + ); + } + configured_network_proxy_config.network.enabled = true; + } + configured_network_proxy_config } else { NetworkProxyConfig::default() }; diff --git a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs index d8ff59da4846..3b71d467bf29 100644 --- a/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs +++ b/codex-rs/tui/src/chatwidget/windows_sandbox_prompts.rs @@ -42,12 +42,13 @@ impl ChatWidget { extra_count: usize, failed_scan: bool, ) { - let (approval, active_permission_profile) = match &preset { + let (approval, permission_profile, active_permission_profile) = match &preset { Some(p) => ( Some(AskForApproval::from(p.approval)), + Some(p.permission_profile.clone()), Some(p.active_permission_profile.clone()), ), - None => (None, None), + None => (None, None, None), }; let mut header_children: Vec> = Vec::new(); let describe_profile = |profile: &PermissionProfile| { @@ -110,11 +111,14 @@ impl ChatWidget { tx.send(AppEvent::SkipNextWorldWritableScan); })); } - if let (Some(approval), Some(active_permission_profile)) = - (approval, active_permission_profile.clone()) - { + if let (Some(approval), Some(permission_profile), Some(active_permission_profile)) = ( + approval, + permission_profile.clone(), + active_permission_profile.clone(), + ) { accept_actions.extend(Self::approval_preset_actions( approval, + permission_profile, active_permission_profile, mode_label.to_string(), ApprovalsReviewer::User, @@ -126,11 +130,12 @@ impl ChatWidget { tx.send(AppEvent::UpdateWorldWritableWarningAcknowledged(true)); tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); - if let (Some(approval), Some(active_permission_profile)) = - (approval, active_permission_profile) + 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( approval, + permission_profile, active_permission_profile, mode_label.to_string(), ApprovalsReviewer::User, From 2ccc164bd922c80bdbc97c3c40cca0350f4de8f4 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Mon, 18 May 2026 19:03:11 -0700 Subject: [PATCH 08/15] test(core): pin network proxy in profile refresh coverage Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/session/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 851d894d783e..de6fbf0f2542 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3856,6 +3856,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul ]), }; let base_config = ConfigToml { + features: Some(toml::from_str("network_proxy = true").expect("valid features")), default_permissions: Some("locked-down".to_string()), permissions: Some(permissions), ..Default::default() From 9badcc117a82e1706c1292665d9ce15832aee77b Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Tue, 19 May 2026 22:09:37 -0700 Subject: [PATCH 09/15] fix(core): populate profile descriptions in runtime tests Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/session/tests.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index de6fbf0f2542..fd16594dc8a6 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3823,6 +3823,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul ( "locked-down".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -3837,6 +3838,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul ( "web-enabled".to_string(), PermissionProfileToml { + description: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, From b526f8b8b5a4f757fc6c6661d3f40a5fdb479fba Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 14:27:16 -0700 Subject: [PATCH 10/15] fix(tui): align profile runtime with current settings Co-authored-by: Codex noreply@openai.com --- codex-rs/core/src/session/tests.rs | 2 ++ codex-rs/tui/src/app/thread_settings.rs | 1 + codex-rs/tui/src/chatwidget/settings.rs | 1 + 3 files changed, 4 insertions(+) diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index fd16594dc8a6..e669becc4c9a 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3824,6 +3824,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul "locked-down".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, @@ -3839,6 +3840,7 @@ async fn active_profile_update_rebuilds_network_proxy_config() -> std::io::Resul "web-enabled".to_string(), PermissionProfileToml { description: None, + extends: None, workspace_roots: None, filesystem: Some(FilesystemPermissionsToml { glob_scan_max_depth: None, diff --git a/codex-rs/tui/src/app/thread_settings.rs b/codex-rs/tui/src/app/thread_settings.rs index bf9282787998..27c435af3fd1 100644 --- a/codex-rs/tui/src/app/thread_settings.rs +++ b/codex-rs/tui/src/app/thread_settings.rs @@ -101,6 +101,7 @@ impl App { cwd, approval_policy, approvals_reviewer, + permission_profile: _, active_permission_profile, windows_sandbox_level: _, model, diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index 0434f7e38661..0df36f6bf890 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -734,6 +734,7 @@ impl ChatWidget { /*cwd*/ None, /*approval_policy*/ None, /*approvals_reviewer*/ None, + /*permission_profile*/ None, /*active_permission_profile*/ None, /*windows_sandbox_level*/ None, /*model*/ None, From b7084b84b513198f925f6acef3d1e47a6c18d721 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 14:37:21 -0700 Subject: [PATCH 11/15] fix(tui): align profile runtime test override args Co-authored-by: Codex noreply@openai.com --- codex-rs/tui/src/app/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index a501c2519072..92e824aec724 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5282,6 +5282,7 @@ async fn override_turn_context_sends_thread_settings_update() { /*cwd*/ None, Some(AskForApproval::OnRequest), Some(ApprovalsReviewer::AutoReview), + /*permission_profile*/ None, Some(ActivePermissionProfile::new( codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE, )), From 0ae28633186cf7a176bd067ed13178eb27ca142d Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 08:47:26 -0700 Subject: [PATCH 12/15] feat(tui): plumb permission profile selections Co-authored-by: Codex noreply@openai.com --- codex-rs/tui/src/app.rs | 20 +- codex-rs/tui/src/app/config_persistence.rs | 168 ++++++++++++- codex-rs/tui/src/app/event_dispatch.rs | 99 ++++++-- codex-rs/tui/src/app/platform_actions.rs | 1 + codex-rs/tui/src/app/tests.rs | 86 ++++++- codex-rs/tui/src/app/thread_routing.rs | 4 +- codex-rs/tui/src/app_event.rs | 19 ++ .../tui/src/chatwidget/permission_popups.rs | 227 ++++++++++-------- codex-rs/tui/src/chatwidget/settings.rs | 22 ++ .../src/chatwidget/windows_sandbox_prompts.rs | 47 +++- 10 files changed, 557 insertions(+), 136 deletions(-) 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..cdcce127b138 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -21,6 +21,143 @@ 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) + .build() + .await + .wrap_err_with(|| { + format!("Failed to rebuild config for permission profile {profile_id}") + }) + } + + 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 +210,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 +490,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..61f1fe5fcdbb 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,9 +877,12 @@ 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(); @@ -885,6 +901,7 @@ impl App { tx.send(AppEvent::EnableWindowsSandboxForAgentMode { preset, mode: WindowsSandboxEnableMode::Elevated, + profile_selection, }); return Ok(AppRunControl::Continue); } @@ -901,7 +918,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 +942,7 @@ impl App { AppEvent::EnableWindowsSandboxForAgentMode { preset: preset.clone(), mode: WindowsSandboxEnableMode::Elevated, + profile_selection: profile_selection.clone(), } } Err(err) => { @@ -953,7 +974,10 @@ impl App { error = %err, "failed to run elevated Windows sandbox setup" ); - AppEvent::OpenWindowsSandboxFallbackPrompt { preset } + AppEvent::OpenWindowsSandboxFallbackPrompt { + preset, + profile_selection, + } } }; tx.send(event); @@ -961,10 +985,13 @@ 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(); @@ -986,7 +1013,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 +1042,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 +1111,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 +1175,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 +1258,7 @@ impl App { } #[cfg(not(target_os = "windows"))] { - let _ = (preset, mode); + let _ = (preset, mode, profile_selection); } } AppEvent::PersistModelSelection { model, effort } => { @@ -1461,7 +1525,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 +1561,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..6a99d5998277 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,89 @@ async fn reset_memories_clears_local_memory_directories() -> Result<()> { .await } +#[tokio::test] +async fn apply_permission_profile_selection_ignores_legacy_harness_overrides() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = make_test_app_with_channels().await; + let codex_home = tempdir()?; + std::fs::write( + codex_home.path().join("config.toml"), + r#" +default_permissions = "locked-down" + +[permissions.locked-down.filesystem] +":minimal" = "read" +"#, + )?; + app.config.codex_home = codex_home.path().to_path_buf().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 +1771,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/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..cf1d902017cd 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -30,6 +30,28 @@ 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, + ), + ) + } + + 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/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); } } From f5ab488cb7ac75dd71d5e4cb53b88f4f7ed089fa Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 08:49:54 -0700 Subject: [PATCH 13/15] fix(tui): keep profile picker symbols in split base Co-authored-by: Codex noreply@openai.com --- codex-rs/tui/src/chatwidget.rs | 2 ++ 1 file changed, 2 insertions(+) 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; From 82e21da745c75bfecb474df50650297204daf547 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 10:42:39 -0700 Subject: [PATCH 14/15] fix(tui): address permission profile review feedback Co-authored-by: Codex noreply@openai.com --- codex-rs/tui/src/app/config_persistence.rs | 18 ++++++++++ codex-rs/tui/src/app/event_dispatch.rs | 34 +++++++++++++++++-- codex-rs/tui/src/app/tests.rs | 6 ++-- codex-rs/tui/src/chatwidget/settings.rs | 4 ++- .../tui/src/chatwidget/tests/permissions.rs | 13 ++++--- 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index cdcce127b138..8d6d392ec4ce 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -34,6 +34,7 @@ impl App { .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(|| { @@ -41,6 +42,23 @@ impl App { }) } + #[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, diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 61f1fe5fcdbb..cd6bd0b5c913 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -885,7 +885,22 @@ impl App { } => { #[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 = @@ -994,7 +1009,22 @@ impl App { } => { #[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 = diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index 6a99d5998277..c0050db28582 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -1629,11 +1629,12 @@ async fn reset_memories_clears_local_memory_directories() -> Result<()> { } #[tokio::test] -async fn apply_permission_profile_selection_ignores_legacy_harness_overrides() -> Result<()> { +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( - codex_home.path().join("config.toml"), + &selected_config, r#" default_permissions = "locked-down" @@ -1642,6 +1643,7 @@ default_permissions = "locked-down" "#, )?; 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()); diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs index cf1d902017cd..eb3fc4eefd92 100644 --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs @@ -42,7 +42,9 @@ impl ChatWidget { profile, active_permission_profile, ), - ) + )?; + self.refresh_status_surfaces(); + Ok(()) } pub(crate) fn set_permission_network( 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!( From 3ee4233c79fd8b10fa3b4dd88cc1c00b511b4256 Mon Sep 17 00:00:00 2001 From: viyatb-oai Date: Wed, 20 May 2026 11:18:34 -0700 Subject: [PATCH 15/15] fix(tui): repair windows permission setup build Co-authored-by: Codex noreply@openai.com --- codex-rs/tui/src/app/config_persistence.rs | 2 ++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 8d6d392ec4ce..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 { 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"))] {