diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 963ee07c25..b43a3bfde5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3265,6 +3265,8 @@ mod handlers { use codex_protocol::protocol::WarningEvent; use codex_protocol::request_user_input::RequestUserInputResponse; + use crate::config::ConstraintError; + use crate::config_loader::RequirementSource; use crate::context_manager::is_user_turn_boundary; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -3287,12 +3289,37 @@ mod handlers { sess.close_unified_exec_processes().await; } + fn disallowed_setting_update_warning(err: &ConstraintError) -> Option { + match err { + ConstraintError::InvalidValue { + field_name, + requirement_source, + .. + } if matches!(*field_name, "approval_policy" | "sandbox_mode") + && !matches!(requirement_source, RequirementSource::Unknown) => + { + Some(format!( + "Requested `{field_name}` is disallowed by requirements; continuing with the current value. Details: {err}" + )) + } + _ => None, + } + } + pub async fn override_turn_context( sess: &Session, sub_id: String, updates: SessionSettingsUpdate, ) { if let Err(err) = sess.update_settings(updates).await { + if let Some(message) = disallowed_setting_update_warning(&err) { + sess.send_event_raw(Event { + id: sub_id, + msg: EventMsg::Warning(WarningEvent { message }), + }) + .await; + return; + } sess.send_event_raw(Event { id: sub_id, msg: EventMsg::Error(ErrorEvent { @@ -6802,6 +6829,61 @@ mod tests { ); } + #[tokio::test] + async fn override_turn_context_disallowed_sandbox_update_emits_warning() { + let (sess, _tc, rx) = make_session_and_context_with_rx().await; + { + let mut state = sess.state.lock().await; + state.session_configuration.sandbox_policy = + Constrained::new(SandboxPolicy::new_read_only_policy(), |candidate| { + if matches!(candidate, SandboxPolicy::ReadOnly { .. }) { + Ok(()) + } else { + Err(crate::config::ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "WorkspaceWrite".to_string(), + allowed: "[ReadOnly]".to_string(), + requirement_source: RequirementSource::CloudRequirements, + }) + } + }) + .expect("set constrained sandbox policy"); + } + + handlers::override_turn_context( + sess.as_ref(), + "sub-override".to_string(), + SessionSettingsUpdate { + sandbox_policy: Some(SandboxPolicy::new_workspace_write_policy()), + ..Default::default() + }, + ) + .await; + + let evt = tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("timeout waiting for event") + .expect("event"); + match evt.msg { + EventMsg::Warning(event) => { + assert!( + event + .message + .contains("Requested `sandbox_mode` is disallowed") + ); + assert!(event.message.contains("continuing with the current value")); + } + other => panic!("unexpected event: {other:?}"), + } + assert!(rx.try_recv().is_err()); + + let state = sess.state.lock().await; + assert_eq!( + *state.session_configuration.sandbox_policy.get(), + SandboxPolicy::new_read_only_policy() + ); + } + #[tokio::test] async fn set_rate_limits_updates_plan_type_when_present() { let codex_home = tempfile::tempdir().expect("create temp dir"); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index b23b5f1a13..b20065b0f1 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -38,9 +38,11 @@ use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::config::ConfigBuilder; use codex_core::config::ConfigOverrides; +use codex_core::config::ConstraintError; use codex_core::config::edit::ConfigEdit; use codex_core::config::edit::ConfigEditsBuilder; use codex_core::config_loader::ConfigLayerStackOrdering; +use codex_core::config_loader::RequirementSource; use codex_core::features::Feature; use codex_core::models_manager::manager::RefreshStrategy; use codex_core::models_manager::model_presets::HIDE_GPT_5_1_CODEX_MAX_MIGRATION_PROMPT_CONFIG; @@ -230,6 +232,16 @@ fn emit_project_config_warnings(app_event_tx: &AppEventSender, config: &Config) ))); } +fn is_managed_requirement_constraint_error(err: &ConstraintError) -> bool { + matches!( + err, + ConstraintError::InvalidValue { + requirement_source, + .. + } if !matches!(requirement_source, RequirementSource::Unknown) + ) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct SessionSummary { usage_line: String, @@ -651,21 +663,31 @@ impl App { } fn apply_runtime_policy_overrides(&mut self, config: &mut Config) { - if let Some(policy) = self.runtime_approval_policy_override.as_ref() - && let Err(err) = config.permissions.approval_policy.set(*policy) + if let Some(policy) = self.runtime_approval_policy_override + && let Err(err) = config.permissions.approval_policy.set(policy) { tracing::warn!(%err, "failed to carry forward approval policy override"); - self.chat_widget.add_error_message(format!( - "Failed to carry forward approval policy override: {err}" - )); + if is_managed_requirement_constraint_error(&err) { + self.runtime_approval_policy_override = + Some(config.permissions.approval_policy.value()); + } else { + self.chat_widget.add_error_message(format!( + "Failed to carry forward approval policy override: {err}" + )); + } } - if let Some(policy) = self.runtime_sandbox_policy_override.as_ref() - && let Err(err) = config.permissions.sandbox_policy.set(policy.clone()) + if let Some(policy) = self.runtime_sandbox_policy_override.clone() + && let Err(err) = config.permissions.sandbox_policy.set(policy) { tracing::warn!(%err, "failed to carry forward sandbox policy override"); - self.chat_widget.add_error_message(format!( - "Failed to carry forward sandbox policy override: {err}" - )); + if is_managed_requirement_constraint_error(&err) { + self.runtime_sandbox_policy_override = + Some(config.permissions.sandbox_policy.get().clone()); + } else { + self.chat_widget.add_error_message(format!( + "Failed to carry forward sandbox policy override: {err}" + )); + } } } @@ -2156,13 +2178,17 @@ impl App { } } AppEvent::UpdateAskForApprovalPolicy(policy) => { - self.runtime_approval_policy_override = Some(policy); if let Err(err) = self.config.permissions.approval_policy.set(policy) { tracing::warn!(%err, "failed to set approval policy on app config"); - self.chat_widget - .add_error_message(format!("Failed to set approval policy: {err}")); + self.runtime_approval_policy_override = + Some(self.config.permissions.approval_policy.value()); + if !is_managed_requirement_constraint_error(&err) { + self.chat_widget + .add_error_message(format!("Failed to set approval policy: {err}")); + } return Ok(AppRunControl::Continue); } + self.runtime_approval_policy_override = Some(policy); self.chat_widget.set_approval_policy(policy); } AppEvent::UpdateSandboxPolicy(policy) => { @@ -2176,8 +2202,12 @@ impl App { if let Err(err) = self.config.permissions.sandbox_policy.set(policy) { tracing::warn!(%err, "failed to set sandbox policy on app config"); - self.chat_widget - .add_error_message(format!("Failed to set sandbox policy: {err}")); + self.runtime_sandbox_policy_override = + Some(self.config.permissions.sandbox_policy.get().clone()); + if !is_managed_requirement_constraint_error(&err) { + self.chat_widget + .add_error_message(format!("Failed to set sandbox policy: {err}")); + } return Ok(AppRunControl::Continue); } if let Err(err) = self.chat_widget.set_sandbox_policy(policy_for_chat) { @@ -2972,6 +3002,28 @@ mod tests { ); } + #[test] + fn managed_requirement_constraint_error_detects_invalid_value_with_known_source() { + let err = ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "WorkspaceWrite".to_string(), + allowed: "[ReadOnly]".to_string(), + requirement_source: RequirementSource::CloudRequirements, + }; + assert!(is_managed_requirement_constraint_error(&err)); + } + + #[test] + fn managed_requirement_constraint_error_ignores_unknown_source() { + let err = ConstraintError::InvalidValue { + field_name: "sandbox_mode", + candidate: "WorkspaceWrite".to_string(), + allowed: "[ReadOnly]".to_string(), + requirement_source: RequirementSource::Unknown, + }; + assert!(!is_managed_requirement_constraint_error(&err)); + } + #[test] fn startup_waiting_gate_holds_active_thread_events_until_primary_thread_configured() { let mut wait_for_initial_session =