Persist approval preset selection

This commit is contained in:
Ahmed Ibrahim
2026-01-13 10:39:03 -08:00
parent cbca43d57a
commit 74425ba6ad
7 changed files with 237 additions and 24 deletions

View File

@@ -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<String>,
effort: Option<ReasoningEffort>,
},
/// Update the active (or default) approval policy.
SetApprovalPolicy { policy: Option<AskForApproval> },
/// Update the active (or default) sandbox mode.
SetSandboxMode { mode: Option<SandboxMode> },
/// 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));

View File

@@ -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<SandboxMode> {
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()
}

View File

@@ -86,6 +86,12 @@ pub(crate) enum AppEvent {
effort: Option<ReasoningEffort>,
},
/// 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,

View File

@@ -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<SelectionAction> {
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<SelectionAction> = 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()
};

View File

@@ -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<SandboxMode> {
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()
}

View File

@@ -85,6 +85,12 @@ pub(crate) enum AppEvent {
effort: Option<ReasoningEffort>,
},
/// 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,

View File

@@ -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<SelectionAction> {
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<SelectionAction> = 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()
};