diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index ca4407ce43..8fa184a873 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -20,6 +20,7 @@ use crate::ConstraintError; use crate::ManagedHooksRequirementsToml; use crate::mcp_types::AppToolApproval; use crate::permissions_toml::PermissionProfileToml; +use crate::types::FeedbackConfigToml; use crate::types::WindowsSandboxModeToml; #[derive(Debug, Clone, PartialEq, Eq)] @@ -142,10 +143,17 @@ impl std::ops::DerefMut for ConstrainedWithSource { /// normalization. #[derive(Debug, Clone, PartialEq)] pub struct ConfigRequirements { + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: Option>, + pub feedback: Option>, pub approval_policy: ConstrainedWithSource, pub approvals_reviewer: ConstrainedWithSource, pub permission_profile: ConstrainedWithSource, pub windows_sandbox_mode: ConstrainedWithSource>, + pub windows_sandbox_private_desktop: Option>, pub web_search_mode: ConstrainedWithSource, pub allow_managed_hooks_only: Option>, pub allow_appshots: Option>, @@ -168,6 +176,12 @@ pub struct ConfigRequirements { impl Default for ConfigRequirements { fn default() -> Self { Self { + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, approval_policy: ConstrainedWithSource::new( Constrained::allow_any_from_default(), /*source*/ None, @@ -184,6 +198,7 @@ impl Default for ConfigRequirements { Constrained::allow_any(/*initial_value*/ None), /*source*/ None, ), + windows_sandbox_private_desktop: None, web_search_mode: ConstrainedWithSource::new( Constrained::allow_any(WebSearchMode::Cached), /*source*/ None, @@ -716,11 +731,12 @@ impl ComputerUseRequirementsToml { #[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub struct WindowsRequirementsToml { pub allowed_sandbox_implementations: Option>, + pub sandbox_private_desktop: Option, } impl WindowsRequirementsToml { pub fn is_empty(&self) -> bool { - self.allowed_sandbox_implementations.is_none() + self.allowed_sandbox_implementations.is_none() && self.sandbox_private_desktop.is_none() } } @@ -821,6 +837,12 @@ pub(crate) fn merge_app_requirements_descending( /// Base config deserialized from system `requirements.toml` or MDM. #[derive(Deserialize, Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsToml { + pub sqlite_home: Option, + pub log_dir: Option, + pub model_catalog_json: Option, + pub check_for_update_on_startup: Option, + pub allow_login_shell: Option, + pub feedback: Option, pub allowed_approval_policies: Option>, pub allowed_approvals_reviewers: Option>, pub allowed_sandbox_modes: Option>, @@ -877,6 +899,12 @@ impl std::ops::Deref for Sourced { #[derive(Debug, Clone, Default, PartialEq)] pub struct ConfigRequirementsWithSources { + pub sqlite_home: Option>, + pub log_dir: Option>, + pub model_catalog_json: Option>, + pub check_for_update_on_startup: Option>, + pub allow_login_shell: Option>, + pub feedback: Option>, pub allowed_approval_policies: Option>>, pub allowed_approvals_reviewers: Option>>, pub allowed_sandbox_modes: Option>>, @@ -919,6 +947,12 @@ impl ConfigRequirementsWithSources { // Destructure without `..` so adding fields to `ConfigRequirementsToml` // forces this merge logic to be updated. let ConfigRequirementsToml { + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + check_for_update_on_startup: _, + allow_login_shell: _, + feedback: _, allowed_approval_policies: _, allowed_approvals_reviewers: _, allowed_sandbox_modes: _, @@ -956,6 +990,12 @@ impl ConfigRequirementsWithSources { other, source, { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -990,6 +1030,12 @@ impl ConfigRequirementsWithSources { pub fn into_toml(self) -> ConfigRequirementsToml { let ConfigRequirementsWithSources { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1013,6 +1059,12 @@ impl ConfigRequirementsWithSources { guardian_policy_config, } = self; ConfigRequirementsToml { + sqlite_home: sqlite_home.map(|sourced| sourced.value), + log_dir: log_dir.map(|sourced| sourced.value), + model_catalog_json: model_catalog_json.map(|sourced| sourced.value), + check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value), + allow_login_shell: allow_login_shell.map(|sourced| sourced.value), + feedback: feedback.map(|sourced| sourced.value), allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), allowed_approvals_reviewers: allowed_approvals_reviewers.map(|sourced| sourced.value), allowed_sandbox_modes: allowed_sandbox_modes.map(|sourced| sourced.value), @@ -1103,7 +1155,16 @@ impl ConfigRequirementsToml { } pub fn is_empty(&self) -> bool { - self.allowed_approval_policies.is_none() + self.sqlite_home.is_none() + && self.log_dir.is_none() + && self.model_catalog_json.is_none() + && self.check_for_update_on_startup.is_none() + && self.allow_login_shell.is_none() + && self + .feedback + .as_ref() + .is_none_or(|feedback| feedback == &FeedbackConfigToml::default()) + && self.allowed_approval_policies.is_none() && self.allowed_approvals_reviewers.is_none() && self.allowed_sandbox_modes.is_none() && self.allowed_permission_profiles.is_none() @@ -1157,6 +1218,12 @@ impl TryFrom for ConfigRequirements { // config loading and requirements API projection. The normalized // constraints below only need the compiled PermissionProfile envelope. let ConfigRequirementsWithSources { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -1277,42 +1344,60 @@ impl TryFrom for ConfigRequirements { /*source*/ None, ), }; - let windows_sandbox_mode = match windows { + let (windows_sandbox_mode, windows_sandbox_private_desktop) = match windows { Some(Sourced { value: WindowsRequirementsToml { - allowed_sandbox_implementations: Some(implementations), + allowed_sandbox_implementations, + sandbox_private_desktop, }, source: requirement_source, }) => { - if implementations.is_empty() { - return Err(ConstraintError::empty_field( - "windows.allowed_sandbox_implementations", - )); - } - // Prefer elevated when both Windows sandbox implementations are allowed. - let initial_value = if implementations.contains(&WindowsSandboxModeToml::Elevated) { - WindowsSandboxModeToml::Elevated - } else { - WindowsSandboxModeToml::Unelevated - }; + let sandbox_private_desktop = sandbox_private_desktop + .map(|value| Sourced::new(value, requirement_source.clone())); + let sandbox_mode = match allowed_sandbox_implementations { + Some(implementations) => { + if implementations.is_empty() { + return Err(ConstraintError::empty_field( + "windows.allowed_sandbox_implementations", + )); + } + // Prefer elevated when both Windows sandbox implementations are allowed. + let initial_value = + if implementations.contains(&WindowsSandboxModeToml::Elevated) { + WindowsSandboxModeToml::Elevated + } else { + WindowsSandboxModeToml::Unelevated + }; - let requirement_source_for_error = requirement_source.clone(); - let constrained = - Constrained::new(Some(initial_value), move |candidate| match candidate { - Some(candidate) if implementations.contains(candidate) => Ok(()), - _ => Err(ConstraintError::InvalidValue { - field_name: "windows.sandbox", - candidate: format!("{candidate:?}"), - allowed: format!("{implementations:?}"), - requirement_source: requirement_source_for_error.clone(), - }), - })?; - ConstrainedWithSource::new(constrained, Some(requirement_source)) + let requirement_source_for_error = requirement_source.clone(); + let constrained = Constrained::new( + Some(initial_value), + move |candidate| match candidate { + Some(candidate) if implementations.contains(candidate) => Ok(()), + _ => Err(ConstraintError::InvalidValue { + field_name: "windows.sandbox", + candidate: format!("{candidate:?}"), + allowed: format!("{implementations:?}"), + requirement_source: requirement_source_for_error.clone(), + }), + }, + )?; + ConstrainedWithSource::new(constrained, Some(requirement_source)) + } + None => ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + }; + (sandbox_mode, sandbox_private_desktop) } - Some(_) | None => ConstrainedWithSource::new( - Constrained::allow_any(/*initial_value*/ None), - /*source*/ None, + None => ( + ConstrainedWithSource::new( + Constrained::allow_any(/*initial_value*/ None), + /*source*/ None, + ), + None, ), }; let exec_policy = match rules { @@ -1437,10 +1522,17 @@ impl TryFrom for ConfigRequirements { }); let guardian_policy_config_source = guardian_policy_config.map(|sourced| sourced.source); Ok(ConfigRequirements { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, approval_policy, approvals_reviewer, permission_profile, windows_sandbox_mode, + windows_sandbox_private_desktop, web_search_mode, allow_managed_hooks_only, allow_appshots, diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index a7bcab279f..b76762dbd7 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -173,6 +173,12 @@ fn populate_merged_regular_fields_with_sources( // Destructure without `..` so every new requirements field must choose // whether it belongs in the regular TOML merge path or in a special merger. let ConfigRequirementsToml { + sqlite_home, + log_dir, + model_catalog_json, + check_for_update_on_startup, + allow_login_shell, + feedback, allowed_approval_policies, allowed_approvals_reviewers, allowed_sandbox_modes, @@ -197,6 +203,15 @@ fn populate_merged_regular_fields_with_sources( guardian_policy_config, } = requirements; + set_sourced!(sqlite_home, &["sqlite_home"]); + set_sourced!(log_dir, &["log_dir"]); + set_sourced!(model_catalog_json, &["model_catalog_json"]); + set_sourced!( + check_for_update_on_startup, + &["check_for_update_on_startup"] + ); + set_sourced!(allow_login_shell, &["allow_login_shell"]); + set_sourced!(feedback, &["feedback"]); set_sourced!(allowed_approval_policies, &["allowed_approval_policies"]); set_sourced!( allowed_approvals_reviewers, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 38f04d23b6..d431e00877 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8330,6 +8330,12 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() let fixture = create_test_fixture()?; let requirements_toml = codex_config::ConfigRequirementsToml { + sqlite_home: None, + log_dir: None, + model_catalog_json: None, + check_for_update_on_startup: None, + allow_login_shell: None, + feedback: None, allowed_approval_policies: None, allowed_approvals_reviewers: None, allowed_sandbox_modes: None, @@ -10848,3 +10854,77 @@ fn test_tui_notification_condition_rejects_unknown_value() { "unexpected error: {err}" ); } + +async fn load_with_enterprise_requirement( + codex_home: &TempDir, + requirements: impl Into, +) -> std::io::Result { + ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .fallback_cwd(Some(codex_home.path().to_path_buf())) + .cloud_config_bundle( + CloudConfigBundleFixture::loader_with_enterprise_requirement(requirements), + ) + .build() + .await +} + +#[tokio::test] +async fn exact_requirements_override_only_managed_leaves() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let catalog_path = codex_home.path().join("required-models.json"); + let mut catalog = bundled_models_response() + .unwrap_or_else(|err| panic!("bundled models.json should parse: {err}")); + catalog.models = catalog.models.into_iter().take(1).collect(); + std::fs::write( + &catalog_path, + serde_json::to_string(&catalog).expect("serialize catalog"), + )?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +check_for_update_on_startup = true +allow_login_shell = true + +[feedback] +enabled = true + +[windows] +sandbox_private_desktop = true +"#, + )?; + + let required_sqlite_home = codex_home.path().join("required-state"); + let required_log_dir = codex_home.path().join("required-logs"); + let requirements = format!( + r#" +sqlite_home = {:?} +log_dir = {:?} +model_catalog_json = {:?} +check_for_update_on_startup = false +allow_login_shell = false + +[feedback] +enabled = false + +[windows] +sandbox_private_desktop = false +"#, + required_sqlite_home.display(), + required_log_dir.display(), + catalog_path.display(), + ); + let config = load_with_enterprise_requirement(&codex_home, requirements).await?; + + assert_eq!(config.sqlite_home, required_sqlite_home); + assert_eq!(config.log_dir, required_log_dir); + assert_eq!(config.model_catalog, Some(catalog)); + assert!(!config.check_for_update_on_startup); + assert!(!config.permissions.allow_login_shell); + assert!(!config.feedback_enabled); + assert!(!config.permissions.windows_sandbox_private_desktop); + assert!(config.startup_warnings.iter().any(|warning| { + warning.contains("Configured value for `check_for_update_on_startup` is overridden") + })); + Ok(()) +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 1dd4117e65..8ba4437851 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -145,6 +145,7 @@ mod managed_features; mod network_proxy_spec; mod otel; mod permissions; +mod requirements; mod resolved_permission_profile; #[cfg(test)] mod schema; @@ -2642,7 +2643,7 @@ impl Config { pub(crate) async fn load_config_with_layer_stack( fs: &dyn ExecutorFileSystem, - cfg: ConfigToml, + mut cfg: ConfigToml, overrides: ConfigOverrides, codex_home: AbsolutePathBuf, config_layer_stack: ConfigLayerStack, @@ -2658,13 +2659,30 @@ impl Config { validate_model_providers(&cfg.model_providers) .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; - // Ensure that every field of ConfigRequirements is applied to the final - // Config. + let mut startup_warnings = config_layer_stack + .startup_warnings() + .unwrap_or_default() + .to_vec(); + requirements::apply_to_config( + &mut cfg, + config_layer_stack.requirements(), + &mut startup_warnings, + )?; + + // Destructure every field to ensure ConfigRequirements additions are + // either applied above or handled while constructing the final Config. let ConfigRequirements { + sqlite_home: _, + log_dir: _, + model_catalog_json: _, + check_for_update_on_startup: _, + allow_login_shell: _, + feedback: _, approval_policy: mut constrained_approval_policy, approvals_reviewer: mut constrained_approvals_reviewer, permission_profile: mut constrained_permission_profile, windows_sandbox_mode: mut constrained_windows_sandbox_mode, + windows_sandbox_private_desktop: _, web_search_mode: mut constrained_web_search_mode, allow_managed_hooks_only: _, allow_appshots: _, @@ -2681,11 +2699,6 @@ impl Config { guardian_policy_config_source: _, } = config_layer_stack.requirements().clone(); - let mut startup_warnings = config_layer_stack - .startup_warnings() - .unwrap_or_default() - .to_vec(); - // Destructure ConfigOverrides fully to ensure all overrides are applied. let ConfigOverrides { model, diff --git a/codex-rs/core/src/config/requirements.rs b/codex-rs/core/src/config/requirements.rs new file mode 100644 index 0000000000..21eb64b5a4 --- /dev/null +++ b/codex-rs/core/src/config/requirements.rs @@ -0,0 +1,125 @@ +use codex_config::ConfigRequirements; +use codex_config::RequirementSource; +use codex_config::Sourced; +use codex_config::config_toml::ConfigToml; +use codex_config::types::FeedbackConfigToml; + +/// Applies managed requirements to regular config before final config construction. +/// +/// Managed values replace their configured counterparts, and conflicts produce +/// source-aware startup warnings. +pub(super) fn apply_to_config( + config: &mut ConfigToml, + requirements: &ConfigRequirements, + startup_warnings: &mut Vec, +) -> std::io::Result<()> { + macro_rules! apply_exact { + ($field:ident) => { + apply_exact_requirement( + stringify!($field), + &mut config.$field, + requirements.$field.as_ref(), + startup_warnings, + ); + }; + } + + apply_exact!(sqlite_home); + apply_exact!(log_dir); + apply_exact!(model_catalog_json); + apply_exact!(check_for_update_on_startup); + apply_exact!(allow_login_shell); + apply_feedback_requirement( + &mut config.feedback, + requirements.feedback.as_ref(), + startup_warnings, + ); + if let Some(requirement) = requirements.windows_sandbox_private_desktop.as_ref() { + apply_exact_requirement( + "windows.sandbox_private_desktop", + &mut config + .windows + .get_or_insert_default() + .sandbox_private_desktop, + Some(requirement), + startup_warnings, + ); + } + + Ok(()) +} + +fn apply_exact_requirement( + field_name: &'static str, + configured_value: &mut Option, + requirement: Option<&Sourced>, + startup_warnings: &mut Vec, +) where + T: Clone + PartialEq + std::fmt::Debug, +{ + let Some(Sourced { value, source }) = requirement else { + return; + }; + if configured_value + .as_ref() + .is_some_and(|configured| configured != value) + { + tracing::warn!( + ?source, + ?value, + "configured value is overridden by an exact requirement for {field_name}" + ); + startup_warnings.push(format!( + "Configured value for `{field_name}` is overridden by the required value {value:?} from {source}." + )); + } + *configured_value = Some(value.clone()); +} + +pub(super) fn replace_required_leaf( + configured: &mut Option, + required: &Option, +) -> bool { + let Some(required) = required else { + return false; + }; + let conflict = configured + .as_ref() + .is_some_and(|configured| configured != required); + *configured = Some(required.clone()); + conflict +} + +fn apply_feedback_requirement( + configured: &mut Option, + requirement: Option<&Sourced>, + startup_warnings: &mut Vec, +) { + let Some(Sourced { value, source }) = requirement else { + return; + }; + let FeedbackConfigToml { enabled } = value; + let configured = configured.get_or_insert_default(); + let conflict = replace_required_leaf(&mut configured.enabled, enabled); + push_structured_requirement_override_warning("feedback", conflict, source, startup_warnings); +} + +/// Emits one source-aware warning when a structured requirement replaces one +/// or more configured values. +pub(super) fn push_structured_requirement_override_warning( + field_name: &str, + conflict: bool, + source: &RequirementSource, + startup_warnings: &mut Vec, +) { + if !conflict { + return; + } + tracing::warn!( + ?source, + "configured values are overridden by requirements for {field_name}" + ); + startup_warnings.push(format!( + "Configured values under `{field_name}` are overridden by requirements from {source}." + )); +} diff --git a/codex-rs/tui/src/chatwidget/tests/permissions.rs b/codex-rs/tui/src/chatwidget/tests/permissions.rs index a9f6f7abe1..bd40737986 100644 --- a/codex-rs/tui/src/chatwidget/tests/permissions.rs +++ b/codex-rs/tui/src/chatwidget/tests/permissions.rs @@ -54,6 +54,7 @@ fn windows_sandbox_requirements_stack( let requirements_toml = codex_config::ConfigRequirementsToml { windows: Some(codex_config::WindowsRequirementsToml { allowed_sandbox_implementations: Some(allowed_sandbox_implementations), + sandbox_private_desktop: None, }), ..Default::default() }; diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index faacca458a..8cd32fa010 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -790,6 +790,7 @@ mod tests { enforce_residency: Some(ResidencyRequirement::Us), network: None, permissions: None, + ..ConfigRequirementsToml::default() }; let user_file = if cfg!(windows) { @@ -1147,6 +1148,7 @@ approval_policy = "never" enforce_residency: None, network: None, permissions: None, + ..ConfigRequirementsToml::default() }; let stack = ConfigLayerStack::new(Vec::new(), requirements, requirements_toml) diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 3ec8bef7c0..f092a92fd7 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1124,7 +1124,12 @@ pub async fn run_main( .config_layer_stack .effective_config() .as_table() - .is_some_and(|table| table.contains_key("log_dir")); + .is_some_and(|table| table.contains_key("log_dir")) + || config + .config_layer_stack + .requirements_toml() + .log_dir + .is_some(); #[allow(clippy::print_stderr)] match check_execpolicy_for_warnings(&config.config_layer_stack).await {