From 74425ba6adadf46bf69fd68cbd641b8b31268e6f Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Tue, 13 Jan 2026 10:39:03 -0800 Subject: [PATCH] Persist approval preset selection --- codex-rs/core/src/config/edit.rs | 25 +++++++++++++ codex-rs/tui/src/app.rs | 48 ++++++++++++++++++++++++ codex-rs/tui/src/app_event.rs | 6 +++ codex-rs/tui/src/chatwidget.rs | 64 ++++++++++++++++++++++++++------ codex-rs/tui2/src/app.rs | 48 ++++++++++++++++++++++++ codex-rs/tui2/src/app_event.rs | 6 +++ codex-rs/tui2/src/chatwidget.rs | 64 ++++++++++++++++++++++++++------ 7 files changed, 237 insertions(+), 24 deletions(-) diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index a24c09e36b..ec48333960 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -1,7 +1,9 @@ use crate::config::CONFIG_TOML_FILE; use crate::config::types::McpServerConfig; use crate::config::types::Notice; +use crate::protocol::AskForApproval; use anyhow::Context; +use codex_protocol::config_types::SandboxMode; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; use std::collections::BTreeMap; @@ -22,6 +24,10 @@ pub enum ConfigEdit { model: Option, effort: Option, }, + /// Update the active (or default) approval policy. + SetApprovalPolicy { policy: Option }, + /// Update the active (or default) sandbox mode. + SetSandboxMode { mode: Option }, /// Toggle the acknowledgement flag under `[notice]`. SetNoticeHideFullAccessWarning(bool), /// Toggle the Windows world-writable directories warning acknowledgement flag. @@ -265,6 +271,12 @@ impl ConfigDocument { ); mutated }), + ConfigEdit::SetApprovalPolicy { policy } => Ok(self.write_profile_value( + &["approval_policy"], + policy.map(|policy| value(policy.to_string())), + )), + ConfigEdit::SetSandboxMode { mode } => Ok(self + .write_profile_value(&["sandbox_mode"], mode.map(|mode| value(mode.to_string())))), ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged) => Ok(self.write_value( Scope::Global, &[Notice::TABLE_KEY, "hide_full_access_warning"], @@ -596,6 +608,19 @@ impl ConfigEditsBuilder { self } + pub fn set_approval_policy(mut self, policy: AskForApproval) -> Self { + self.edits.push(ConfigEdit::SetApprovalPolicy { + policy: Some(policy), + }); + self + } + + pub fn set_sandbox_mode(mut self, mode: SandboxMode) -> Self { + self.edits + .push(ConfigEdit::SetSandboxMode { mode: Some(mode) }); + self + } + pub fn set_hide_full_access_warning(mut self, acknowledged: bool) -> Self { self.edits .push(ConfigEdit::SetNoticeHideFullAccessWarning(acknowledged)); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index ad5b32c821..1974e041d5 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -39,10 +39,12 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_protocol::ThreadId; +use codex_protocol::config_types::SandboxMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -1069,6 +1071,43 @@ impl App { } } } + AppEvent::PersistApprovalSelection { approval, sandbox } => { + let profile = self.active_profile.as_deref(); + let Some(sandbox_mode) = Self::sandbox_mode_for_policy(&sandbox) else { + tracing::warn!( + "Skipping approval persistence for unsupported sandbox policy: {sandbox:?}" + ); + self.chat_widget.add_error_message( + "Failed to save approval preference: unsupported sandbox policy." + .to_string(), + ); + return Ok(true); + }; + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_approval_policy(approval) + .set_sandbox_mode(sandbox_mode) + .apply() + .await + { + Ok(()) => {} + Err(err) => { + tracing::error!( + error = %err, + "failed to persist approval selection" + ); + if let Some(profile) = profile { + self.chat_widget.add_error_message(format!( + "Failed to save approvals for profile `{profile}`: {err}" + )); + } else { + self.chat_widget.add_error_message(format!( + "Failed to save approval preferences: {err}" + )); + } + } + } + } AppEvent::UpdateAskForApprovalPolicy(policy) => { self.chat_widget.set_approval_policy(policy); } @@ -1310,6 +1349,15 @@ impl App { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } + fn sandbox_mode_for_policy(policy: &SandboxPolicy) -> Option { + match policy { + SandboxPolicy::ReadOnly => Some(SandboxMode::ReadOnly), + SandboxPolicy::WorkspaceWrite { .. } => Some(SandboxMode::WorkspaceWrite), + SandboxPolicy::DangerFullAccess => Some(SandboxMode::DangerFullAccess), + SandboxPolicy::ExternalSandbox { .. } => None, + } + } + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage() } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 9301be8278..dd5b62fb6e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -86,6 +86,12 @@ pub(crate) enum AppEvent { effort: Option, }, + /// Persist the selected approval policy and sandbox policy to the appropriate config. + PersistApprovalSelection { + approval: AskForApproval, + sandbox: SandboxPolicy, + }, + /// Open the reasoning selection popup after picking a model. OpenReasoningPopup { model: ModelPreset, diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 3cfd02074f..fec7224e07 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -3055,15 +3055,27 @@ impl ChatWidget { }); })] } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) } } #[cfg(not(target_os = "windows"))] { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) } } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) }; items.push(SelectionItem { name, @@ -3117,19 +3129,27 @@ impl ChatWidget { fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, + persist: bool, ) -> Vec { vec![Box::new(move |tx| { - let sandbox_clone = sandbox.clone(); + let sandbox_for_update = sandbox.clone(); + let sandbox_for_persist = sandbox.clone(); tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: Some(approval), - sandbox_policy: Some(sandbox_clone.clone()), + sandbox_policy: Some(sandbox_for_update.clone()), model: None, effort: None, summary: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox_for_update)); + if persist { + tx.send(AppEvent::PersistApprovalSelection { + approval, + sandbox: sandbox_for_persist, + }); + } })] } @@ -3202,12 +3222,13 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone(), false); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); - let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + let mut accept_and_remember_actions = + Self::approval_preset_actions(approval, sandbox, true); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); tx.send(AppEvent::PersistFullAccessWarningAcknowledged); @@ -3312,8 +3333,15 @@ impl ChatWidget { tx.send(AppEvent::SkipNextWorldWritableScan); })); } + let persist_approval = preset + .as_ref() + .is_some_and(|preset| preset.id != "full-access"); if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + persist_approval, + )); } let mut accept_and_remember_actions: Vec = Vec::new(); @@ -3322,7 +3350,11 @@ impl ChatWidget { tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_and_remember_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + persist_approval, + )); } let items = vec![ @@ -3427,7 +3459,11 @@ impl ChatWidget { .iter() .find(|preset| preset.id == "read-only") .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) }) .unwrap_or_default() }; @@ -3507,7 +3543,11 @@ impl ChatWidget { .iter() .find(|preset| preset.id == "read-only") .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) }) .unwrap_or_default() }; diff --git a/codex-rs/tui2/src/app.rs b/codex-rs/tui2/src/app.rs index 305ae114d6..bb9a19e520 100644 --- a/codex-rs/tui2/src/app.rs +++ b/codex-rs/tui2/src/app.rs @@ -56,11 +56,13 @@ use codex_core::protocol::EventMsg; use codex_core::protocol::FinalOutput; use codex_core::protocol::ListSkillsResponseEvent; use codex_core::protocol::Op; +use codex_core::protocol::SandboxPolicy; use codex_core::protocol::SessionSource; use codex_core::protocol::SkillErrorInfo; use codex_core::protocol::TokenUsage; use codex_core::terminal::terminal_info; use codex_protocol::ThreadId; +use codex_protocol::config_types::SandboxMode; use codex_protocol::openai_models::ModelPreset; use codex_protocol::openai_models::ModelUpgrade; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; @@ -1845,6 +1847,43 @@ impl App { } } } + AppEvent::PersistApprovalSelection { approval, sandbox } => { + let profile = self.active_profile.as_deref(); + let Some(sandbox_mode) = Self::sandbox_mode_for_policy(&sandbox) else { + tracing::warn!( + "Skipping approval persistence for unsupported sandbox policy: {sandbox:?}" + ); + self.chat_widget.add_error_message( + "Failed to save approval preference: unsupported sandbox policy." + .to_string(), + ); + return Ok(true); + }; + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_profile(profile) + .set_approval_policy(approval) + .set_sandbox_mode(sandbox_mode) + .apply() + .await + { + Ok(()) => {} + Err(err) => { + tracing::error!( + error = %err, + "failed to persist approval selection" + ); + if let Some(profile) = profile { + self.chat_widget.add_error_message(format!( + "Failed to save approvals for profile `{profile}`: {err}" + )); + } else { + self.chat_widget.add_error_message(format!( + "Failed to save approval preferences: {err}" + )); + } + } + } + } AppEvent::UpdateAskForApprovalPolicy(policy) => { self.chat_widget.set_approval_policy(policy); } @@ -2050,6 +2089,15 @@ impl App { (!model.starts_with("codex-auto-")).then(|| Self::reasoning_label(reasoning_effort)) } + fn sandbox_mode_for_policy(policy: &SandboxPolicy) -> Option { + match policy { + SandboxPolicy::ReadOnly => Some(SandboxMode::ReadOnly), + SandboxPolicy::WorkspaceWrite { .. } => Some(SandboxMode::WorkspaceWrite), + SandboxPolicy::DangerFullAccess => Some(SandboxMode::DangerFullAccess), + SandboxPolicy::ExternalSandbox { .. } => None, + } + } + pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { self.chat_widget.token_usage() } diff --git a/codex-rs/tui2/src/app_event.rs b/codex-rs/tui2/src/app_event.rs index a7e566edfa..c8bbc6033d 100644 --- a/codex-rs/tui2/src/app_event.rs +++ b/codex-rs/tui2/src/app_event.rs @@ -85,6 +85,12 @@ pub(crate) enum AppEvent { effort: Option, }, + /// Persist the selected approval policy and sandbox policy to the appropriate config. + PersistApprovalSelection { + approval: AskForApproval, + sandbox: SandboxPolicy, + }, + /// Open the reasoning selection popup after picking a model. OpenReasoningPopup { model: ModelPreset, diff --git a/codex-rs/tui2/src/chatwidget.rs b/codex-rs/tui2/src/chatwidget.rs index 225d1b0246..29ab05d159 100644 --- a/codex-rs/tui2/src/chatwidget.rs +++ b/codex-rs/tui2/src/chatwidget.rs @@ -2806,15 +2806,27 @@ impl ChatWidget { }); })] } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) } } #[cfg(not(target_os = "windows"))] { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) } } else { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) }; items.push(SelectionItem { name, @@ -2848,19 +2860,27 @@ impl ChatWidget { fn approval_preset_actions( approval: AskForApproval, sandbox: SandboxPolicy, + persist: bool, ) -> Vec { vec![Box::new(move |tx| { - let sandbox_clone = sandbox.clone(); + let sandbox_for_update = sandbox.clone(); + let sandbox_for_persist = sandbox.clone(); tx.send(AppEvent::CodexOp(Op::OverrideTurnContext { cwd: None, approval_policy: Some(approval), - sandbox_policy: Some(sandbox_clone.clone()), + sandbox_policy: Some(sandbox_for_update.clone()), model: None, effort: None, summary: None, })); tx.send(AppEvent::UpdateAskForApprovalPolicy(approval)); - tx.send(AppEvent::UpdateSandboxPolicy(sandbox_clone)); + tx.send(AppEvent::UpdateSandboxPolicy(sandbox_for_update)); + if persist { + tx.send(AppEvent::PersistApprovalSelection { + approval, + sandbox: sandbox_for_persist, + }); + } })] } @@ -2933,12 +2953,13 @@ impl ChatWidget { )); let header = ColumnRenderable::with(header_children); - let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone()); + let mut accept_actions = Self::approval_preset_actions(approval, sandbox.clone(), false); accept_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); })); - let mut accept_and_remember_actions = Self::approval_preset_actions(approval, sandbox); + let mut accept_and_remember_actions = + Self::approval_preset_actions(approval, sandbox, true); accept_and_remember_actions.push(Box::new(|tx| { tx.send(AppEvent::UpdateFullAccessWarningAcknowledged(true)); tx.send(AppEvent::PersistFullAccessWarningAcknowledged); @@ -3043,8 +3064,15 @@ impl ChatWidget { tx.send(AppEvent::SkipNextWorldWritableScan); })); } + let persist_approval = preset + .as_ref() + .is_some_and(|preset| preset.id != "full-access"); if let (Some(approval), Some(sandbox)) = (approval, sandbox.clone()) { - accept_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + persist_approval, + )); } let mut accept_and_remember_actions: Vec = Vec::new(); @@ -3053,7 +3081,11 @@ impl ChatWidget { tx.send(AppEvent::PersistWorldWritableWarningAcknowledged); })); if let (Some(approval), Some(sandbox)) = (approval, sandbox) { - accept_and_remember_actions.extend(Self::approval_preset_actions(approval, sandbox)); + accept_and_remember_actions.extend(Self::approval_preset_actions( + approval, + sandbox, + persist_approval, + )); } let items = vec![ @@ -3158,7 +3190,11 @@ impl ChatWidget { .iter() .find(|preset| preset.id == "read-only") .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) }) .unwrap_or_default() }; @@ -3238,7 +3274,11 @@ impl ChatWidget { .iter() .find(|preset| preset.id == "read-only") .map(|preset| { - Self::approval_preset_actions(preset.approval, preset.sandbox.clone()) + Self::approval_preset_actions( + preset.approval, + preset.sandbox.clone(), + preset.id != "full-access", + ) }) .unwrap_or_default() };