diff --git a/codex-rs/cloud-config/src/backend.rs b/codex-rs/cloud-config/src/backend.rs index cb99316a70..d99fd694ee 100644 --- a/codex-rs/cloud-config/src/backend.rs +++ b/codex-rs/cloud-config/src/backend.rs @@ -110,9 +110,11 @@ pub(crate) fn bundle_from_response(response: ConfigBundleResponse) -> CloudConfi CloudConfigBundle { config_toml: CloudConfigTomlBundle { enterprise_managed: config_toml, + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: requirements_toml, + ..Default::default() }, } } diff --git a/codex-rs/cloud-config/src/cache_tests.rs b/codex-rs/cloud-config/src/cache_tests.rs index 28899f930d..1ccfdb5901 100644 --- a/codex-rs/cloud-config/src/cache_tests.rs +++ b/codex-rs/cloud-config/src/cache_tests.rs @@ -16,6 +16,7 @@ fn test_bundle() -> CloudConfigBundle { name: "Base config".to_string(), contents: "model = \"gpt-5\"".to_string(), }], + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: vec![CloudRequirementsFragment { @@ -23,6 +24,7 @@ fn test_bundle() -> CloudConfigBundle { name: "Base requirements".to_string(), contents: "allowed_approval_policies = [\"never\"]".to_string(), }], + ..Default::default() }, } } diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 85c3940bbb..999c818ac9 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -197,9 +197,11 @@ fn test_bundle() -> CloudConfigBundle { CloudConfigBundle { config_toml: CloudConfigTomlBundle { enterprise_managed: vec![test_config_fragment()], + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: vec![test_requirements_fragment()], + ..Default::default() }, } } @@ -228,6 +230,7 @@ fn invalid_config_bundle() -> CloudConfigBundle { name: "Invalid config".to_string(), contents: "model = [".to_string(), }], + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle::default(), } @@ -340,6 +343,7 @@ fn bundle_shape_tag_describes_sorted_enterprise_sources() { bundle_shape_tag(Some(&CloudConfigBundle { config_toml: CloudConfigTomlBundle { enterprise_managed: vec![test_config_fragment()], + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle::default(), })), @@ -350,6 +354,7 @@ fn bundle_shape_tag_describes_sorted_enterprise_sources() { config_toml: CloudConfigTomlBundle::default(), requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: vec![test_requirements_fragment()], + ..Default::default() }, })), "enterprise_requirements" @@ -358,9 +363,11 @@ fn bundle_shape_tag_describes_sorted_enterprise_sources() { bundle_shape_tag(Some(&CloudConfigBundle { config_toml: CloudConfigTomlBundle { enterprise_managed: vec![test_config_fragment()], + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: vec![test_requirements_fragment()], + ..Default::default() }, })), "enterprise_config,enterprise_requirements" @@ -602,6 +609,7 @@ async fn get_bundle_ignores_cache_for_different_auth_identity() { name: "Replacement requirements".to_string(), contents: "allowed_approval_policies = [\"on-request\"]".to_string(), }], + ..Default::default() }, }; let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( @@ -931,6 +939,7 @@ async fn get_bundle_does_not_use_cache_when_auth_identity_is_incomplete() { name: "Replacement requirements".to_string(), contents: "allowed_approval_policies = [\"on-request\"]".to_string(), }], + ..Default::default() }, }; let fetcher = Arc::new(SequenceBundleClient::new(vec![Ok( @@ -996,6 +1005,7 @@ async fn refresh_from_remote_updates_cached_bundle() { name: "Replacement requirements".to_string(), contents: "allowed_approval_policies = [\"on-request\"]".to_string(), }], + ..Default::default() }, }; let codex_home = tempdir().expect("tempdir"); @@ -1067,6 +1077,7 @@ fn bundle_response_conversion_preserves_fragment_order() { contents: "model = \"low\"".to_string(), }, ], + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: vec![CloudRequirementsFragment { @@ -1074,6 +1085,7 @@ fn bundle_response_conversion_preserves_fragment_order() { name: "High requirements".to_string(), contents: "allowed_approval_policies = [\"never\"]".to_string(), }], + ..Default::default() }, } ); diff --git a/codex-rs/cloud-config/src/validation.rs b/codex-rs/cloud-config/src/validation.rs index ef5ed06e1a..ba7f08a165 100644 --- a/codex-rs/cloud-config/src/validation.rs +++ b/codex-rs/cloud-config/src/validation.rs @@ -20,6 +20,7 @@ pub(crate) fn validate_bundle( let CloudConfigBundleLayers { enterprise_managed_config: _, enterprise_managed_requirements, + .. } = bundle_layers; compose_requirements(enterprise_managed_requirements).map_err(|err| { diff --git a/codex-rs/config/src/cloud_config_bundle.rs b/codex-rs/config/src/cloud_config_bundle.rs index cfece32a01..05a098e063 100644 --- a/codex-rs/config/src/cloud_config_bundle.rs +++ b/codex-rs/config/src/cloud_config_bundle.rs @@ -5,11 +5,14 @@ //! layer entries while preserving each bucket's insertion semantics. use crate::CloudConfigFragment; +use crate::CloudManagedLayer; use crate::ConfigLayerEntry; use crate::RequirementSource; use crate::RequirementsLayerEntry; use crate::cloud_config_layers::CloudConfigLayerError; use crate::cloud_config_layers::cloud_config_layers_from_fragments_strict; +use crate::cloud_config_layers::cloud_managed_config_layers_from_fragments; +use crate::cloud_config_layers::cloud_managed_config_layers_from_fragments_strict; use crate::cloud_config_layers_from_fragments; use codex_utils_absolute_path::AbsolutePathBuf; use futures::future::BoxFuture; @@ -35,23 +38,64 @@ impl CloudConfigBundle { } = self; let CloudConfigTomlBundle { enterprise_managed: config_enterprise_managed, + managed_layers: config_managed_layers, } = config_toml; let CloudRequirementsTomlBundle { enterprise_managed: requirements_enterprise_managed, + managed_layers: requirements_managed_layers, } = requirements_toml; - config_enterprise_managed.is_empty() && requirements_enterprise_managed.is_empty() + config_enterprise_managed.is_empty() + && config_managed_layers.baseline.is_empty() + && config_managed_layers.system_overlay.is_empty() + && requirements_enterprise_managed.is_empty() + && requirements_managed_layers.baseline.is_empty() + && requirements_managed_layers.system_overlay.is_empty() } } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct CloudConfigTomlBundle { pub enterprise_managed: Vec, + #[serde( + default, + skip_serializing_if = "CloudConfigTomlManagedLayers::is_empty" + )] + pub managed_layers: CloudConfigTomlManagedLayers, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudConfigTomlManagedLayers { + pub baseline: Vec, + pub system_overlay: Vec, +} + +impl CloudConfigTomlManagedLayers { + fn is_empty(&self) -> bool { + self.baseline.is_empty() && self.system_overlay.is_empty() + } } #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct CloudRequirementsTomlBundle { pub enterprise_managed: Vec, + #[serde( + default, + skip_serializing_if = "CloudRequirementsTomlManagedLayers::is_empty" + )] + pub managed_layers: CloudRequirementsTomlManagedLayers, +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +pub struct CloudRequirementsTomlManagedLayers { + pub baseline: Vec, + pub system_overlay: Vec, +} + +impl CloudRequirementsTomlManagedLayers { + fn is_empty(&self) -> bool { + self.baseline.is_empty() && self.system_overlay.is_empty() + } } #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -67,9 +111,17 @@ pub struct CloudRequirementsFragment { /// inserted relative to local/system/user layers. #[derive(Clone, Debug)] pub struct CloudConfigBundleLayers { + /// Baseline config layers in `ConfigLayerStack` order. + pub baseline_config: Vec, + /// System-overlay config layers in `ConfigLayerStack` order. + pub system_overlay_config: Vec, /// Enterprise-managed config layers in `ConfigLayerStack` order. pub enterprise_managed_config: Vec, - /// Enterprise-managed requirements layers in requirements layer merge order. + /// Baseline requirements layers in requirements merge order. + pub baseline_requirements: Vec, + /// System-overlay requirements layers in requirements merge order. + pub system_overlay_requirements: Vec, + /// Enterprise-managed requirements layers in requirements merge order. pub enterprise_managed_requirements: Vec, } @@ -99,43 +151,97 @@ impl CloudConfigBundleLayers { config_toml: CloudConfigTomlBundle { enterprise_managed: config_enterprise_managed, + managed_layers: + CloudConfigTomlManagedLayers { + baseline: config_baseline, + system_overlay: config_system_overlay, + }, }, requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: requirements_enterprise_managed, + managed_layers: + CloudRequirementsTomlManagedLayers { + baseline: requirements_baseline, + system_overlay: requirements_system_overlay, + }, }, } = bundle; + let parse_managed_config_fragments = |fragments, layer| { + if strict_config { + cloud_managed_config_layers_from_fragments_strict(fragments, base_dir, layer) + } else { + cloud_managed_config_layers_from_fragments(fragments, base_dir, layer) + } + }; + let baseline_config = + parse_managed_config_fragments(config_baseline, CloudManagedLayer::Baseline)?; let enterprise_managed_config = if strict_config { cloud_config_layers_from_fragments_strict(config_enterprise_managed, base_dir)? } else { cloud_config_layers_from_fragments(config_enterprise_managed, base_dir)? }; + let system_overlay_config = parse_managed_config_fragments( + config_system_overlay, + CloudManagedLayer::SystemOverlay, + )?; - let mut enterprise_managed_requirements = requirements_enterprise_managed - .into_iter() - .map(|fragment| { - RequirementsLayerEntry::from_toml( - RequirementSource::EnterpriseManaged { - id: fragment.id, - name: fragment.name, - }, - fragment.contents, - ) - .with_base_dir(base_dir.clone()) - }) - .collect::>(); - // Bundle fragments arrive highest-priority first, while requirements - // layers are merged lowest-priority to highest-priority. - enterprise_managed_requirements.reverse(); + let baseline_requirements = + requirements_layers_from_fragments(requirements_baseline, base_dir, |id, name| { + RequirementSource::CloudManaged { + layer: CloudManagedLayer::Baseline, + id, + name, + } + }); + let enterprise_managed_requirements = requirements_layers_from_fragments( + requirements_enterprise_managed, + base_dir, + |id, name| RequirementSource::EnterpriseManaged { id, name }, + ); + let system_overlay_requirements = requirements_layers_from_fragments( + requirements_system_overlay, + base_dir, + |id, name| RequirementSource::CloudManaged { + layer: CloudManagedLayer::SystemOverlay, + id, + name, + }, + ); Ok(Self { + baseline_config, enterprise_managed_config, + system_overlay_config, + baseline_requirements, enterprise_managed_requirements, + system_overlay_requirements, }) } } +fn requirements_layers_from_fragments( + fragments: Vec, + base_dir: &AbsolutePathBuf, + source_for_fragment: impl Fn(String, String) -> RequirementSource, +) -> Vec { + let mut layers = fragments + .into_iter() + .map(|fragment| { + RequirementsLayerEntry::from_toml( + source_for_fragment(fragment.id, fragment.name), + fragment.contents, + ) + .with_base_dir(base_dir.clone()) + }) + .collect::>(); + // Bundle fragments arrive highest-priority first, while requirements + // layers are merged lowest-priority to highest-priority. + layers.reverse(); + layers +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CloudConfigBundleLoadErrorCode { Auth, diff --git a/codex-rs/config/src/cloud_config_bundle_tests.rs b/codex-rs/config/src/cloud_config_bundle_tests.rs index 714aca6bc6..5122966501 100644 --- a/codex-rs/config/src/cloud_config_bundle_tests.rs +++ b/codex-rs/config/src/cloud_config_bundle_tests.rs @@ -1,6 +1,7 @@ use super::*; use crate::ConfigLayerSource; use crate::ConfigRequirementsToml; +use crate::Sourced; use crate::compose_requirements; use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; @@ -9,6 +10,22 @@ use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; use tempfile::tempdir; +fn config_fragment(id: &str) -> CloudConfigFragment { + CloudConfigFragment { + id: id.to_string(), + name: id.to_string(), + contents: format!("model = \"{id}\""), + } +} + +fn requirements_fragment(id: &str) -> CloudRequirementsFragment { + CloudRequirementsFragment { + id: id.to_string(), + name: id.to_string(), + contents: format!("guardian_policy_config = \"{id}\""), + } +} + #[tokio::test] async fn shared_future_runs_once() { let counter = Arc::new(AtomicUsize::new(0)); @@ -42,6 +59,7 @@ fn bundle_layers_preserve_enterprise_managed_bucket_order() { contents: "model = \"low\"".to_string(), }, ], + ..Default::default() }, requirements_toml: CloudRequirementsTomlBundle { enterprise_managed: vec![ @@ -56,6 +74,7 @@ fn bundle_layers_preserve_enterprise_managed_bucket_order() { contents: "allowed_approval_policies = [\"never\"]".to_string(), }, ], + ..Default::default() }, }, &base_dir, @@ -91,6 +110,86 @@ fn bundle_layers_preserve_enterprise_managed_bucket_order() { ); } +#[test] +fn bundle_layers_preserve_managed_bucket_order_and_provenance() { + let tempdir = tempdir().expect("tempdir"); + let base_dir = AbsolutePathBuf::from_absolute_path(tempdir.path()).expect("absolute path"); + let layers = CloudConfigBundleLayers::from_bundle( + CloudConfigBundle { + config_toml: CloudConfigTomlBundle { + managed_layers: CloudConfigTomlManagedLayers { + baseline: vec![ + config_fragment("baseline_high"), + config_fragment("baseline_low"), + ], + system_overlay: vec![ + config_fragment("overlay_high"), + config_fragment("overlay_low"), + ], + }, + ..Default::default() + }, + requirements_toml: CloudRequirementsTomlBundle { + managed_layers: CloudRequirementsTomlManagedLayers { + baseline: vec![requirements_fragment("baseline_req")], + system_overlay: vec![ + requirements_fragment("overlay_req_high"), + requirements_fragment("overlay_req_low"), + ], + }, + ..Default::default() + }, + }, + &base_dir, + ) + .expect("bundle should be converted into layers"); + + assert_eq!( + layers + .baseline_config + .iter() + .chain(&layers.system_overlay_config) + .map(|entry| match &entry.name { + ConfigLayerSource::CloudManaged { layer, id, .. } => (*layer, id.as_str()), + source => panic!("unexpected config layer source: {source:?}"), + }) + .collect::>(), + vec![ + (CloudManagedLayer::Baseline, "baseline_low"), + (CloudManagedLayer::Baseline, "baseline_high"), + (CloudManagedLayer::SystemOverlay, "overlay_low"), + (CloudManagedLayer::SystemOverlay, "overlay_high"), + ] + ); + + let requirements = compose_requirements( + layers + .baseline_requirements + .into_iter() + .chain(layers.system_overlay_requirements), + ) + .expect("requirements should compose") + .expect("requirements should be present"); + assert_eq!( + requirements.guardian_policy_config, + Some(Sourced::new( + "overlay_req_high".to_string(), + RequirementSource::CloudManaged { + layer: CloudManagedLayer::SystemOverlay, + id: "overlay_req_high".to_string(), + name: "overlay_req_high".to_string(), + }, + )) + ); + assert_eq!( + requirements.into_toml(), + ConfigRequirementsToml { + guardian_policy_config: Some("overlay_req_high".to_string()), + ..Default::default() + } + ); +} + #[test] fn bundle_layers_can_strict_validate_enterprise_managed_config() { let tempdir = tempdir().expect("tempdir"); @@ -103,10 +202,9 @@ fn bundle_layers_can_strict_validate_enterprise_managed_config() { name: "Cloud config".to_string(), contents: "unknown_key = true".to_string(), }], + ..Default::default() }, - requirements_toml: CloudRequirementsTomlBundle { - enterprise_managed: Vec::new(), - }, + requirements_toml: CloudRequirementsTomlBundle::default(), }, &base_dir, ) diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs index f6fcd40b4a..81666feff5 100644 --- a/codex-rs/config/src/cloud_config_layers.rs +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -4,6 +4,7 @@ //! fragment, resolves relative path fields against the cloud config base //! directory, and returns layers in `ConfigLayerStack` order. +use crate::CloudManagedLayer; use crate::ConfigLayerEntry; use crate::ConfigLayerSource; use crate::TomlValue; @@ -22,7 +23,7 @@ use thiserror::Error; /// /// The bundle orders fragments from highest precedence to lowest precedence. /// This module returns config layers in stack order, so callers can append the -/// result between system and user config without re-sorting. +/// result at the bucket's stack position without re-sorting. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] pub struct CloudConfigFragment { pub id: String, @@ -69,19 +70,56 @@ pub fn cloud_config_layers_from_fragments( fragments: impl IntoIterator, base_dir: &AbsolutePathBuf, ) -> Result, CloudConfigLayerError> { - cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ false) + cloud_config_layers_from_fragments_impl( + fragments, + base_dir, + |id, name| ConfigLayerSource::EnterpriseManaged { id, name }, + /*strict_config*/ false, + ) } pub(crate) fn cloud_config_layers_from_fragments_strict( fragments: impl IntoIterator, base_dir: &AbsolutePathBuf, ) -> Result, CloudConfigLayerError> { - cloud_config_layers_from_fragments_impl(fragments, base_dir, /*strict_config*/ true) + cloud_config_layers_from_fragments_impl( + fragments, + base_dir, + |id, name| ConfigLayerSource::EnterpriseManaged { id, name }, + /*strict_config*/ true, + ) +} + +pub(crate) fn cloud_managed_config_layers_from_fragments( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, + layer: CloudManagedLayer, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl( + fragments, + base_dir, + move |id, name| ConfigLayerSource::CloudManaged { layer, id, name }, + /*strict_config*/ false, + ) +} + +pub(crate) fn cloud_managed_config_layers_from_fragments_strict( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, + layer: CloudManagedLayer, +) -> Result, CloudConfigLayerError> { + cloud_config_layers_from_fragments_impl( + fragments, + base_dir, + move |id, name| ConfigLayerSource::CloudManaged { layer, id, name }, + /*strict_config*/ true, + ) } fn cloud_config_layers_from_fragments_impl( fragments: impl IntoIterator, base_dir: &AbsolutePathBuf, + source_for_fragment: impl Fn(String, String) -> ConfigLayerSource, strict_config: bool, ) -> Result, CloudConfigLayerError> { let mut layers = Vec::new(); @@ -104,10 +142,7 @@ fn cloud_config_layers_from_fragments_impl( } })?; layers.push(ConfigLayerEntry::new_with_raw_toml( - ConfigLayerSource::EnterpriseManaged { - id: fragment.id, - name: fragment.name, - }, + source_for_fragment(fragment.id, fragment.name), resolved, raw_toml, base_dir.clone(), diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index 51e5023dfa..9e8409bcc1 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -39,8 +39,10 @@ pub use cloud_config_bundle::CloudConfigBundleLoadError; pub use cloud_config_bundle::CloudConfigBundleLoadErrorCode; pub use cloud_config_bundle::CloudConfigBundleLoader; pub use cloud_config_bundle::CloudConfigTomlBundle; +pub use cloud_config_bundle::CloudConfigTomlManagedLayers; pub use cloud_config_bundle::CloudRequirementsFragment; pub use cloud_config_bundle::CloudRequirementsTomlBundle; +pub use cloud_config_bundle::CloudRequirementsTomlManagedLayers; pub use cloud_config_layers::CloudConfigFragment; pub use cloud_config_layers::CloudConfigFragmentSource; pub use cloud_config_layers::CloudConfigLayerError; diff --git a/codex-rs/config/src/loader/README.md b/codex-rs/config/src/loader/README.md index b711ebbb61..dcc7cd9e13 100644 --- a/codex-rs/config/src/loader/README.md +++ b/codex-rs/config/src/loader/README.md @@ -31,8 +31,10 @@ Precedence is **top overrides bottom**: 4. `Project` config (`.codex/config.toml`) 5. `User` profile config, when present 6. `User` config (`config.toml`) -7. `EnterpriseManaged` cloud-managed config bundle layers -8. `System` config (`/etc/codex/config.toml` or the Windows system config path) +7. `CloudManaged(SystemOverlay)` config bundle layers +8. `EnterpriseManaged` cloud-managed config bundle layers +9. `System` config (`/etc/codex/config.toml` or the Windows system config path) +10. `CloudManaged(Baseline)` config bundle layers `ConfigLayerStack` stores layers in the opposite order internally: lowest precedence first, highest precedence last, so later layers override earlier diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index 00110bc08b..3392c389b3 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -82,9 +82,11 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// composed with config-style TOML merging plus field-specific handling for /// hooks, rules, deny-read permissions, and remote sandbox config: /// -/// - system `/etc/codex/requirements.toml` (Unix) or +/// - baseline: cloud-managed baseline fragments +/// - system: `/etc/codex/requirements.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\requirements.toml` (Windows) /// - cloud: enterprise-managed cloud config bundle requirements +/// - overlay: cloud-managed system-overlay fragments /// - legacy: managed_config.toml reinterpreted as requirements.toml /// - admin: managed preferences (*) /// @@ -93,10 +95,12 @@ async fn first_layer_config_error_from_entries(layers: &[ConfigLayerEntry]) -> O /// /// Configuration is built up from multiple layers in the following order: /// +/// - baseline: cloud-managed baseline fragments /// - admin: managed preferences (*) /// - system `/etc/codex/config.toml` (Unix) or /// `%ProgramData%\OpenAI\Codex\config.toml` (Windows) -/// - cloud enterprise-managed cloud config bundle fragments +/// - cloud: enterprise-managed cloud config bundle fragments +/// - overlay: cloud-managed system-overlay fragments /// - user `${CODEX_HOME}/config.toml` /// - profile `${CODEX_HOME}/.config.toml`, when selected /// - cwd `${PWD}/config.toml` (loaded but disabled when the directory is untrusted) @@ -132,10 +136,14 @@ pub async fn load_config_layers_state( let ignore_user_and_project_exec_policy_rules = overrides.ignore_user_and_project_exec_policy_rules; let mut requirements_layers = Vec::new(); - let mut bundle_requirements_layers = Vec::new(); + let mut cloud_baseline_requirements_layers = Vec::new(); + let mut enterprise_managed_requirements_layers = Vec::new(); + let mut cloud_system_overlay_requirements_layers = Vec::new(); let mut system_requirements_layer = None; let managed_preferences_requirements_layer; - let mut cloud_config_layers = Vec::new(); + let mut cloud_baseline_config_layers = Vec::new(); + let mut enterprise_managed_config_layers = Vec::new(); + let mut cloud_system_overlay_config_layers = Vec::new(); if !ignore_managed_requirements { if let Some(bundle) = cloud_config_bundle.get().await.map_err(io::Error::other)? { @@ -146,11 +154,19 @@ pub async fn load_config_layers_state( CloudConfigBundleLayers::from_bundle(bundle, &cloud_config_base_dir)? }; let CloudConfigBundleLayers { + baseline_config, + system_overlay_config, enterprise_managed_config, + baseline_requirements, + system_overlay_requirements, enterprise_managed_requirements, } = bundle_layers; - bundle_requirements_layers = enterprise_managed_requirements; - cloud_config_layers = enterprise_managed_config; + cloud_baseline_requirements_layers = baseline_requirements; + enterprise_managed_requirements_layers = enterprise_managed_requirements; + cloud_system_overlay_requirements_layers = system_overlay_requirements; + cloud_baseline_config_layers = baseline_config; + enterprise_managed_config_layers = enterprise_managed_config; + cloud_system_overlay_config_layers = system_overlay_config; } #[cfg(target_os = "macos")] @@ -178,8 +194,10 @@ pub async fn load_config_layers_state( layer_io::load_config_layers_internal(fs, codex_home, overrides.clone(), strict_config) .await?; if !ignore_managed_requirements { + requirements_layers.extend(cloud_baseline_requirements_layers); requirements_layers.extend(system_requirements_layer); - requirements_layers.extend(bundle_requirements_layers); + requirements_layers.extend(enterprise_managed_requirements_layers); + requirements_layers.extend(cloud_system_overlay_requirements_layers); // Continue to support the legacy `managed_config.toml` locations as // requirements layers for backwards compatibility. requirements_layers.extend(requirements_layers_from_legacy_scheme( @@ -235,8 +253,10 @@ pub async fn load_config_layers_state( }, ) .await?; + layers.extend(cloud_baseline_config_layers); layers.push(system_layer); - layers.extend(cloud_config_layers); + layers.extend(enterprise_managed_config_layers); + layers.extend(cloud_system_overlay_config_layers); // Add the base user config layer. When profile-v2 is selected, add the // profile config as a second user layer on top so the profile only needs to diff --git a/codex-rs/config/src/loader/tests.rs b/codex-rs/config/src/loader/tests.rs index 068e29aa6e..daede88ddc 100644 --- a/codex-rs/config/src/loader/tests.rs +++ b/codex-rs/config/src/loader/tests.rs @@ -1,4 +1,8 @@ use super::*; +use crate::CloudManagedLayer; +use crate::ConfigLayerStackOrdering; +use crate::ConfigRequirementsToml; +use crate::test_support::CloudConfigBundleFixture; use codex_file_system::CopyOptions; use codex_file_system::CreateDirectoryOptions; use codex_file_system::ExecutorFileSystemFuture; @@ -104,6 +108,120 @@ impl ExecutorFileSystem for TestFileSystem { } } +#[tokio::test] +async fn managed_bundle_layers_follow_contract_precedence() { + let tmp = tempdir().expect("tempdir"); + let system_config_path = tmp.path().join("system-config.toml"); + let system_requirements_path = tmp.path().join("requirements.toml"); + std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n") + .expect("write user config"); + std::fs::write( + &system_config_path, + "model = \"system\"\nmodel_provider = \"system\"\nreview_model = \"system\"\n", + ) + .expect("write system config"); + std::fs::write( + &system_requirements_path, + "allow_remote_control = true\nguardian_policy_config = \"system\"\n", + ) + .expect("write system requirements"); + + let mut overrides = LoaderOverrides::with_managed_config_path_for_tests( + tmp.path().join("missing-managed-config.toml"), + ); + overrides.system_config_path = Some(system_config_path.clone()); + let cloud_config_bundle = CloudConfigBundleFixture::default() + .add_managed_config( + CloudManagedLayer::Baseline, + "model = \"baseline\"\nmodel_provider = \"baseline\"", + ) + .add_enterprise_config("model_provider = \"enterprise\"\nreview_model = \"enterprise\"") + .add_managed_config( + CloudManagedLayer::SystemOverlay, + "model_provider = \"overlay\"\nreview_model = \"overlay\"", + ) + .add_managed_requirement( + CloudManagedLayer::Baseline, + "allow_appshots = true\nguardian_policy_config = \"baseline\"", + ) + .add_enterprise_requirement( + "allow_managed_hooks_only = true\nguardian_policy_config = \"enterprise\"", + ) + .add_managed_requirement( + CloudManagedLayer::SystemOverlay, + "guardian_policy_config = \"overlay\"", + ) + .into_loader(); + + let stack = load_config_layers_state( + &TestFileSystem, + tmp.path(), + /*cwd*/ None, + &[], + ConfigLoadOptions { + loader_overrides: overrides, + cloud_config_bundle, + ..Default::default() + }, + &crate::NoopThreadConfigLoader, + ) + .await + .expect("managed layers should load"); + + assert_eq!( + stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false, + ) + .into_iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::CloudManaged { + layer: CloudManagedLayer::Baseline, + id: "managed_cfg_1".to_string(), + name: "baseline config 1".to_string(), + }, + ConfigLayerSource::System { + file: AbsolutePathBuf::from_absolute_path(&system_config_path) + .expect("absolute system config path"), + }, + ConfigLayerSource::EnterpriseManaged { + id: "cfg_1".to_string(), + name: "Base config".to_string(), + }, + ConfigLayerSource::CloudManaged { + layer: CloudManagedLayer::SystemOverlay, + id: "managed_cfg_1".to_string(), + name: "system-overlay config 1".to_string(), + }, + ConfigLayerSource::User { + file: AbsolutePathBuf::resolve_path_against_base(CONFIG_TOML_FILE, tmp.path()), + profile: None, + }, + ] + ); + assert_eq!( + stack.effective_config(), + TomlValue::Table(toml::toml! { + model = "user" + model_provider = "overlay" + review_model = "overlay" + }) + ); + assert_eq!( + stack.requirements_toml(), + &ConfigRequirementsToml { + allow_managed_hooks_only: Some(true), + allow_appshots: Some(true), + allow_remote_control: Some(true), + guardian_policy_config: Some("overlay".to_string()), + ..Default::default() + } + ); +} + #[tokio::test] async fn profile_v2_rejects_matching_legacy_profile_in_base_user_config() { let tmp = tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/test_support.rs b/codex-rs/config/src/test_support.rs index 44627f3892..9b2970f88f 100644 --- a/codex-rs/config/src/test_support.rs +++ b/codex-rs/config/src/test_support.rs @@ -5,6 +5,7 @@ use crate::CloudConfigBundle; use crate::CloudConfigBundleLoader; use crate::CloudConfigFragment; +use crate::CloudManagedLayer; use crate::CloudRequirementsFragment; #[derive(Debug, Clone, Default)] @@ -65,6 +66,48 @@ impl CloudConfigBundleFixture { self } + pub fn add_managed_requirement( + mut self, + layer: CloudManagedLayer, + contents: impl Into, + ) -> Self { + let fragments = match layer { + CloudManagedLayer::Baseline => { + &mut self.bundle.requirements_toml.managed_layers.baseline + } + CloudManagedLayer::SystemOverlay => { + &mut self.bundle.requirements_toml.managed_layers.system_overlay + } + }; + let index = fragments.len() + 1; + fragments.push(CloudRequirementsFragment { + id: format!("managed_req_{index}"), + name: format!("{layer} requirements {index}"), + contents: contents.into(), + }); + self + } + + pub fn add_managed_config( + mut self, + layer: CloudManagedLayer, + contents: impl Into, + ) -> Self { + let fragments = match layer { + CloudManagedLayer::Baseline => &mut self.bundle.config_toml.managed_layers.baseline, + CloudManagedLayer::SystemOverlay => { + &mut self.bundle.config_toml.managed_layers.system_overlay + } + }; + let index = fragments.len() + 1; + fragments.push(CloudConfigFragment { + id: format!("managed_cfg_{index}"), + name: format!("{layer} config {index}"), + contents: contents.into(), + }); + self + } + pub fn into_bundle(self) -> CloudConfigBundle { self.bundle }