From 9f75a0d395c996fca73bdfbac2ad3968044c69df Mon Sep 17 00:00:00 2001 From: Joe Florencio Date: Wed, 20 May 2026 14:17:57 -0700 Subject: [PATCH] Add cloud managed config layer support Introduce an explicit enterprise-managed config layer source and the client-side machinery to materialize cloud-delivered config TOML fragments into the normal config stack. The new ConfigLayerSource::EnterpriseManaged variant carries the backend layer id and display name so diagnostics and debug output can point admins at the exact cloud layer that needs fixing. Add codex_config::cloud_config_layers to build config layers from delivered fragments. The composition keeps backend layer order deterministic, resolves relative path settings against a supplied base directory for consistency with existing MDM-delivered config semantics, and stores the raw TOML with that base directory on ConfigLayerEntry so typed diagnostics can reparse non-file layers without relying on a synthetic filesystem path. Keep this v1 pull-based and snapshot-oriented. The bundle loader/cache work can feed these helpers, but this change does not introduce dynamic refresh or announce/push semantics. Consumers continue to read the config state they are already handed. Tighten provenance and diagnostics for non-file layers: enterprise-managed layers render as enterprise-managed config values in debug output, syntax/type errors use the layer display name, and synthetic hook source paths include the enterprise layer name/id when a filesystem path is needed for existing hook metadata surfaces. Split hook provenance semantically by adding HookSource::CloudManagedConfig. Hooks delivered through enterprise-managed config layers now report cloud_managed_config / cloudManagedConfig, while hooks delivered through requirements remain CloudRequirements. The TUI labels the new source as Cloud-managed config, and analytics/core metric mappings were updated to include the new source. Regenerate app-server protocol JSON and TypeScript schema fixtures for the new ConfigLayerSource and HookSource wire values. Verification: just write-app-server-schema; cargo test -p codex-app-server-protocol; cargo test -p codex-hooks hook_metadata_for_config_layer_source; cargo test -p codex-core hook_run_metric_tags; cargo test -p codex-analytics hook_run_metadata; just fmt; just fix -p codex-protocol -p codex-app-server-protocol -p codex-hooks -p codex-analytics -p codex-core -p codex-tui. --- codex-rs/analytics/src/events.rs | 1 + .../schema/json/ServerNotification.json | 1 + .../codex_app_server_protocol.schemas.json | 28 ++ .../codex_app_server_protocol.v2.schemas.json | 28 ++ .../schema/json/v2/ConfigReadResponse.json | 27 ++ .../schema/json/v2/ConfigWriteResponse.json | 27 ++ .../json/v2/HookCompletedNotification.json | 1 + .../json/v2/HookStartedNotification.json | 1 + .../schema/json/v2/HooksListResponse.json | 1 + .../schema/typescript/v2/ConfigLayerSource.ts | 12 +- .../schema/typescript/v2/HookSource.ts | 2 +- .../src/protocol/v2/config.rs | 14 + .../src/protocol/v2/hook.rs | 1 + .../app-server/src/config_manager_service.rs | 3 + codex-rs/config/src/cloud_config_layers.rs | 312 ++++++++++++++++++ codex-rs/config/src/diagnostics.rs | 44 ++- codex-rs/config/src/lib.rs | 5 + codex-rs/config/src/loader/mod.rs | 11 +- codex-rs/config/src/state.rs | 33 +- codex-rs/core-skills/src/loader.rs | 1 + codex-rs/core/src/hook_runtime.rs | 1 + codex-rs/hooks/src/engine/discovery.rs | 11 + codex-rs/protocol/src/protocol.rs | 1 + .../tui/src/bottom_pane/hooks_browser_view.rs | 2 + codex-rs/tui/src/debug_config.rs | 76 ++++- 25 files changed, 624 insertions(+), 20 deletions(-) create mode 100644 codex-rs/config/src/cloud_config_layers.rs diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index 87cb165ac2..4fae72d5b3 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -1005,6 +1005,7 @@ fn analytics_hook_source(source: HookSource) -> &'static str { HookSource::SessionFlags => "session_flags", HookSource::Plugin => "plugin", HookSource::CloudRequirements => "cloud_requirements", + HookSource::CloudManagedConfig => "cloud_managed_config", HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", HookSource::Unknown => "unknown", diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 90899cb152..5c593da8d8 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2002,6 +2002,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 4c6d5a6eee..86e6b7fa4e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -7557,6 +7557,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { @@ -10036,6 +10063,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e74323745e..3154bb9268 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -3926,6 +3926,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { @@ -6516,6 +6543,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json index 4a104b3bd5..e714c0975d 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigReadResponse.json @@ -498,6 +498,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json index 37816463b6..d1a2254a92 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ConfigWriteResponse.json @@ -73,6 +73,33 @@ "title": "SystemConfigLayerSource", "type": "object" }, + { + "description": "Enterprise-managed config layer delivered by the cloud config bundle.", + "properties": { + "id": { + "description": "Stable identifier for the delivered layer.", + "type": "string" + }, + "name": { + "description": "Admin-facing name for the delivered layer. This is surfaced in diagnostics so users know which cloud layer needs administrator attention.", + "type": "string" + }, + "type": { + "enum": [ + "enterpriseManaged" + ], + "title": "EnterpriseManagedConfigLayerSourceType", + "type": "string" + } + }, + "required": [ + "id", + "name", + "type" + ], + "title": "EnterpriseManagedConfigLayerSource", + "type": "object" + }, { "description": "User config layer from $CODEX_HOME/config.toml. This layer is special in that it is expected to be: - writable by the user - generally outside the workspace directory", "properties": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json index 088da47484..8684bf9ae5 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookCompletedNotification.json @@ -166,6 +166,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json index 38395cded9..5b2750d780 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HookStartedNotification.json @@ -166,6 +166,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json index c58a5c767d..c3288ee0fd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/HooksListResponse.json @@ -130,6 +130,7 @@ "sessionFlags", "plugin", "cloudRequirements", + "cloudManagedConfig", "legacyManagedConfigFile", "legacyManagedConfigMdm", "unknown" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts index 08cb8c6bfd..963c331b5d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ConfigLayerSource.ts @@ -8,7 +8,17 @@ export type ConfigLayerSource = { "type": "mdm", domain: string, key: string, } * This is the path to the system config.toml file, though it is not * guaranteed to exist. */ -file: AbsolutePathBuf, } | { "type": "user", +file: AbsolutePathBuf, } | { "type": "enterpriseManaged", +/** + * Stable identifier for the delivered layer. + */ +id: string, +/** + * Admin-facing name for the delivered layer. This is surfaced in + * diagnostics so users know which cloud layer needs administrator + * attention. + */ +name: string, } | { "type": "user", /** * This is the path to the user's config.toml file, though it is not * guaranteed to exist. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts index 98bbe1e412..5c3cb2eabf 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/HookSource.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown"; +export type HookSource = "system" | "user" | "project" | "mdm" | "sessionFlags" | "plugin" | "cloudRequirements" | "cloudManagedConfig" | "legacyManagedConfigFile" | "legacyManagedConfigMdm" | "unknown"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs index fffe213811..038a75c1b9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs @@ -42,6 +42,19 @@ pub enum ConfigLayerSource { file: AbsolutePathBuf, }, + /// Enterprise-managed config layer delivered by the cloud config bundle. + #[serde(rename_all = "camelCase")] + #[ts(rename_all = "camelCase")] + EnterpriseManaged { + /// Stable identifier for the delivered layer. + id: String, + + /// Admin-facing name for the delivered layer. This is surfaced in + /// diagnostics so users know which cloud layer needs administrator + /// attention. + name: String, + }, + /// User config layer from $CODEX_HOME/config.toml. This layer is special /// in that it is expected to be: /// - writable by the user @@ -89,6 +102,7 @@ impl ConfigLayerSource { match self { ConfigLayerSource::Mdm { .. } => 0, ConfigLayerSource::System { .. } => 10, + ConfigLayerSource::EnterpriseManaged { .. } => 15, ConfigLayerSource::User { profile, .. } => { if profile.is_some() { 21 diff --git a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs index 6e6c1ef7d4..b8f47276a6 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/hook.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/hook.rs @@ -48,6 +48,7 @@ v2_enum_from_core!( SessionFlags, Plugin, CloudRequirements, + CloudManagedConfig, LegacyManagedConfigFile, LegacyManagedConfigMdm, Unknown, diff --git a/codex-rs/app-server/src/config_manager_service.rs b/codex-rs/app-server/src/config_manager_service.rs index d2465d32dd..4b42c28aa9 100644 --- a/codex-rs/app-server/src/config_manager_service.rs +++ b/codex-rs/app-server/src/config_manager_service.rs @@ -624,6 +624,9 @@ fn override_message(layer: &ConfigLayerSource) -> String { ConfigLayerSource::System { file } => { format!("Overridden by managed config (system): {}", file.display()) } + ConfigLayerSource::EnterpriseManaged { id: _, name } => { + format!("Overridden by enterprise-managed config: {name}") + } ConfigLayerSource::Project { dot_codex_folder } => format!( "Overridden by project config: {}/{CONFIG_TOML_FILE}", dot_codex_folder.display(), diff --git a/codex-rs/config/src/cloud_config_layers.rs b/codex-rs/config/src/cloud_config_layers.rs new file mode 100644 index 0000000000..3735da73bf --- /dev/null +++ b/codex-rs/config/src/cloud_config_layers.rs @@ -0,0 +1,312 @@ +use crate::ConfigLayerEntry; +use crate::ConfigLayerSource; +use crate::TomlValue; +use crate::loader::resolve_relative_paths_in_config_toml; +use codex_utils_absolute_path::AbsolutePathBuf; +use std::fmt; +use std::io; +use thiserror::Error; + +/// Config fragment delivered by the cloud config bundle. +/// +/// 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. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloudConfigFragment { + pub id: String, + pub name: String, + pub contents: String, +} + +impl CloudConfigFragment { + fn source_ref(&self) -> CloudConfigFragmentSource { + CloudConfigFragmentSource { + id: self.id.clone(), + name: self.name.clone(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CloudConfigFragmentSource { + pub id: String, + pub name: String, +} + +impl fmt::Display for CloudConfigFragmentSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} ({})", self.name, self.id) + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum CloudConfigLayerError { + #[error("failed to parse cloud config fragment {fragment}: {message}")] + Parse { + fragment: CloudConfigFragmentSource, + message: String, + }, + #[error("invalid cloud config fragment {fragment}: {message}")] + Invalid { + fragment: CloudConfigFragmentSource, + message: String, + }, +} + +pub fn cloud_config_layers_from_fragments( + fragments: impl IntoIterator, + base_dir: &AbsolutePathBuf, +) -> Result, CloudConfigLayerError> { + let mut layers = Vec::new(); + for fragment in fragments { + let source_ref = fragment.source_ref(); + let raw_toml = fragment.contents; + let value: TomlValue = + toml::from_str(&raw_toml).map_err(|err| CloudConfigLayerError::Parse { + fragment: source_ref.clone(), + message: err.to_string(), + })?; + let resolved = + resolve_relative_paths_in_config_toml(value, base_dir.as_path()).map_err(|err| { + CloudConfigLayerError::Invalid { + fragment: source_ref.clone(), + message: err.to_string(), + } + })?; + layers.push(ConfigLayerEntry::new_with_raw_toml( + ConfigLayerSource::EnterpriseManaged { + id: fragment.id, + name: fragment.name, + }, + resolved, + raw_toml, + base_dir.clone(), + )); + } + + // Bundle fragments arrive highest-priority first, while ConfigLayerStack + // folds lowest-priority to highest-priority. + layers.reverse(); + Ok(layers) +} + +impl From for io::Error { + fn from(error: CloudConfigLayerError) -> Self { + io::Error::new(io::ErrorKind::InvalidData, error) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CONFIG_TOML_FILE; + use crate::ConfigLayerStack; + use crate::ConfigLayerStackOrdering; + use crate::ConfigRequirements; + use crate::ConfigRequirementsToml; + use crate::config_toml::ConfigToml; + use crate::first_layer_config_error_from_entries; + use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_absolute_path::test_support::PathBufExt; + use codex_utils_absolute_path::test_support::test_path_buf; + use pretty_assertions::assert_eq; + use std::path::Path; + + fn fragment(id: &str, name: &str, contents: &str) -> CloudConfigFragment { + CloudConfigFragment { + id: id.to_string(), + name: name.to_string(), + contents: contents.to_string(), + } + } + + fn toml(contents: &str) -> TomlValue { + toml::from_str(contents).expect("test TOML should parse") + } + + fn base_dir() -> AbsolutePathBuf { + test_path_buf("/var/lib/codex").abs() + } + + #[test] + fn layers_are_returned_in_stack_order() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![ + fragment("high", "High priority", "model = \"cloud-high\""), + fragment("low", "Low priority", "model_provider = \"cloud-low\""), + ], + &base_dir, + ) + .expect("cloud config layers should compose"); + + assert_eq!( + layers + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::EnterpriseManaged { + id: "low".to_string(), + name: "Low priority".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "high".to_string(), + name: "High priority".to_string(), + }, + ] + ); + } + + #[test] + fn enterprise_layers_precede_user_and_override_system() { + let base_dir = base_dir(); + let mut layers = vec![ConfigLayerEntry::new( + ConfigLayerSource::System { + file: test_path_buf("/etc/codex/config.toml").abs(), + }, + toml( + r#" +model = "system" +model_provider = "system" +review_model = "system-review" +"#, + ), + )]; + layers.extend( + cloud_config_layers_from_fragments( + vec![ + fragment("high", "High priority", "model_provider = \"cloud-high\""), + fragment("low", "Low priority", "review_model = \"cloud-low-review\""), + ], + &base_dir, + ) + .expect("cloud config layers should compose"), + ); + layers.push(ConfigLayerEntry::new( + ConfigLayerSource::User { + file: test_path_buf("/home/alice/.codex/config.toml").abs(), + profile: None, + }, + toml("model = \"user\""), + )); + + let stack = ConfigLayerStack::new( + layers, + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("stack should be ordered"); + + assert_eq!( + stack + .get_layers( + ConfigLayerStackOrdering::LowestPrecedenceFirst, + /*include_disabled*/ false + ) + .iter() + .map(|layer| layer.name.clone()) + .collect::>(), + vec![ + ConfigLayerSource::System { + file: test_path_buf("/etc/codex/config.toml").abs(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "low".to_string(), + name: "Low priority".to_string(), + }, + ConfigLayerSource::EnterpriseManaged { + id: "high".to_string(), + name: "High priority".to_string(), + }, + ConfigLayerSource::User { + file: test_path_buf("/home/alice/.codex/config.toml").abs(), + profile: None, + }, + ] + ); + assert_eq!( + stack.effective_config(), + toml( + r#" +model = "user" +model_provider = "cloud-high" +review_model = "cloud-low-review" +"#, + ) + ); + } + + #[test] + fn relative_absolute_path_fields_resolve_against_base_dir() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![fragment( + "cfg_123", + "Base policy", + "model_instructions_file = \"instructions.md\"", + )], + &base_dir, + ) + .expect("relative paths should match existing MDM semantics"); + + let path = layers[0] + .config + .get("model_instructions_file") + .and_then(TomlValue::as_str) + .expect("path should be present"); + let expected = + AbsolutePathBuf::resolve_path_against_base("instructions.md", base_dir.as_path()); + assert_eq!(path, expected.to_string_lossy()); + } + + #[test] + fn home_relative_path_fields_are_allowed_and_resolved() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![fragment( + "cfg_123", + "Base policy", + "model_instructions_file = \"~/instructions.md\"", + )], + &base_dir, + ) + .expect("home-relative paths should be accepted"); + + let path = layers[0] + .config + .get("model_instructions_file") + .and_then(TomlValue::as_str) + .expect("path should be present"); + let expected = + AbsolutePathBuf::resolve_path_against_base("~/instructions.md", base_dir.as_path()); + assert_eq!(path, expected.to_string_lossy()); + } + + #[tokio::test] + async fn raw_toml_diagnostics_use_enterprise_layer_name() { + let base_dir = base_dir(); + let layers = cloud_config_layers_from_fragments( + vec![fragment( + "cfg_123", + "Base policy", + "model_instructions_file = \"instructions.md\"\nmodel = 1", + )], + &base_dir, + ) + .expect("cloud config layers should parse"); + + let error = first_layer_config_error_from_entries::(&layers, CONFIG_TOML_FILE) + .await + .expect("invalid raw TOML should produce a layer diagnostic"); + + assert_eq!( + error.path, + Path::new("enterprise-managed config Base policy (cfg_123)").to_path_buf() + ); + assert_eq!(error.range.start.line, 2); + assert_eq!(error.range.start.column, 9); + assert!(error.message.contains("invalid type: integer `1`")); + } +} diff --git a/codex-rs/config/src/diagnostics.rs b/codex-rs/config/src/diagnostics.rs index 3fc593dcdf..3906ba5c4a 100644 --- a/codex-rs/config/src/diagnostics.rs +++ b/codex-rs/config/src/diagnostics.rs @@ -89,7 +89,6 @@ impl std::error::Error for ConfigLoadError { #[derive(Clone, Copy)] pub(crate) enum ConfigDiagnosticSource<'a> { Path(&'a Path), - #[cfg(any(target_os = "macos", test))] DisplayName(&'a str), } @@ -97,7 +96,6 @@ impl ConfigDiagnosticSource<'_> { pub(crate) fn to_path_buf(self) -> PathBuf { match self { ConfigDiagnosticSource::Path(path) => path.to_path_buf(), - #[cfg(any(target_os = "macos", test))] ConfigDiagnosticSource::DisplayName(name) => PathBuf::from(name), } } @@ -201,6 +199,27 @@ where I: IntoIterator, { for layer in layers { + if let Some(contents) = layer.raw_toml() { + let source_name = display_name_for_layer(layer, config_toml_file); + let Some(base_dir) = layer.raw_toml_base_dir() else { + tracing::debug!( + "Skipping raw TOML diagnostics for {source_name} because it has no base directory" + ); + continue; + }; + // Match the base directory used when the raw non-file layer was + // parsed into the runtime layer so diagnostics resolve relative + // path fields with the same semantics. + let _absolute_path_base = AbsolutePathBufGuard::new(base_dir.as_path()); + if let Some(error) = config_error_from_typed_toml_for_source::( + ConfigDiagnosticSource::DisplayName(&source_name), + contents, + ) { + return Some(error); + } + continue; + } + let Some(path) = config_path_for_layer(layer, config_toml_file) else { continue; }; @@ -235,11 +254,32 @@ fn config_path_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> Op } ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => Some(file.to_path_buf()), ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::EnterpriseManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => None, } } +fn display_name_for_layer(layer: &ConfigLayerEntry, config_toml_file: &str) -> String { + match &layer.name { + ConfigLayerSource::EnterpriseManaged { id, name } => { + format!("enterprise-managed config {name} ({id})") + } + ConfigLayerSource::Mdm { domain, key } => format!("managed policy {domain}:{key}"), + ConfigLayerSource::SessionFlags => "session flags".to_string(), + ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { + "legacy managed configuration from MDM".to_string() + } + ConfigLayerSource::System { file } + | ConfigLayerSource::User { file, .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromFile { file } => file.display().to_string(), + ConfigLayerSource::Project { dot_codex_folder } => dot_codex_folder + .join(config_toml_file) + .display() + .to_string(), + } +} + pub(crate) fn text_range_from_span(contents: &str, span: std::ops::Range) -> TextRange { let start = position_for_offset(contents, span.start); let end_index = if span.end > span.start { diff --git a/codex-rs/config/src/lib.rs b/codex-rs/config/src/lib.rs index c3b951cc42..68d123cf3e 100644 --- a/codex-rs/config/src/lib.rs +++ b/codex-rs/config/src/lib.rs @@ -1,3 +1,4 @@ +mod cloud_config_layers; mod cloud_requirements; mod cloud_requirements_composition; mod config_requirements; @@ -29,6 +30,10 @@ pub mod types; pub const CONFIG_TOML_FILE: &str = "config.toml"; +pub use cloud_config_layers::CloudConfigFragment; +pub use cloud_config_layers::CloudConfigFragmentSource; +pub use cloud_config_layers::CloudConfigLayerError; +pub use cloud_config_layers::cloud_config_layers_from_fragments; pub use cloud_requirements::CloudRequirementsLoadError; pub use cloud_requirements::CloudRequirementsLoadErrorCode; pub use cloud_requirements::CloudRequirementsLoader; diff --git a/codex-rs/config/src/loader/mod.rs b/codex-rs/config/src/loader/mod.rs index e8972ce5ad..f98ba768d7 100644 --- a/codex-rs/config/src/loader/mod.rs +++ b/codex-rs/config/src/loader/mod.rs @@ -366,13 +366,18 @@ pub async fn load_config_layers_state( // paths, starting with `./`, but a path starting with `~/` _is_ a // supported use case. Because resolve_relative_paths_in_config_toml() // relies on AbsolutePathBufGuard to resolve `~/`, we must supply a - // value for base_dir, so codex_home is as good a value as any. - let managed_config = - resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?; + // value for base_dir. Preserve that same base on the layer so later + // raw-TOML diagnostics parse with the same path semantics. + let raw_toml_base_dir = AbsolutePathBuf::from_absolute_path(codex_home)?; + let managed_config = resolve_relative_paths_in_config_toml( + config.managed_config, + raw_toml_base_dir.as_path(), + )?; layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, managed_config, config.raw_toml, + raw_toml_base_dir, )); } diff --git a/codex-rs/config/src/state.rs b/codex-rs/config/src/state.rs index 3ec0bfb314..e4d03c3d3c 100644 --- a/codex-rs/config/src/state.rs +++ b/codex-rs/config/src/state.rs @@ -97,33 +97,47 @@ impl LoaderOverrides { pub struct ConfigLayerEntry { pub name: ConfigLayerSource, pub config: TomlValue, - pub raw_toml: Option, pub version: String, pub disabled_reason: Option, + raw_toml: Option, hooks_config_folder_override: Option, } +#[derive(Debug, Clone, PartialEq)] +struct RawTomlLayer { + contents: String, + base_dir: AbsolutePathBuf, +} + impl ConfigLayerEntry { pub fn new(name: ConfigLayerSource, config: TomlValue) -> Self { let version = version_for_toml(&config); Self { name, config, - raw_toml: None, version, disabled_reason: None, + raw_toml: None, hooks_config_folder_override: None, } } - pub fn new_with_raw_toml(name: ConfigLayerSource, config: TomlValue, raw_toml: String) -> Self { + pub fn new_with_raw_toml( + name: ConfigLayerSource, + config: TomlValue, + raw_toml: String, + raw_toml_base_dir: AbsolutePathBuf, + ) -> Self { let version = version_for_toml(&config); Self { name, config, - raw_toml: Some(raw_toml), version, disabled_reason: None, + raw_toml: Some(RawTomlLayer { + contents: raw_toml, + base_dir: raw_toml_base_dir, + }), hooks_config_folder_override: None, } } @@ -137,9 +151,9 @@ impl ConfigLayerEntry { Self { name, config, - raw_toml: None, version, disabled_reason: Some(disabled_reason.into()), + raw_toml: None, hooks_config_folder_override: None, } } @@ -149,7 +163,13 @@ impl ConfigLayerEntry { } pub fn raw_toml(&self) -> Option<&str> { - self.raw_toml.as_deref() + self.raw_toml + .as_ref() + .map(|raw_toml| raw_toml.contents.as_str()) + } + + pub fn raw_toml_base_dir(&self) -> Option<&AbsolutePathBuf> { + self.raw_toml.as_ref().map(|raw_toml| &raw_toml.base_dir) } pub(crate) fn with_hooks_config_folder_override( @@ -181,6 +201,7 @@ impl ConfigLayerEntry { match &self.name { ConfigLayerSource::Mdm { .. } => None, ConfigLayerSource::System { file } => file.parent(), + ConfigLayerSource::EnterpriseManaged { .. } => None, ConfigLayerSource::User { file, .. } => file.parent(), ConfigLayerSource::Project { dot_codex_folder } => Some(dot_codex_folder.clone()), ConfigLayerSource::SessionFlags => None, diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 00d1cbba14..78185764c3 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -339,6 +339,7 @@ fn skill_roots_from_layer_stack_inner( }); } ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::EnterpriseManaged { .. } | ConfigLayerSource::SessionFlags | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {} diff --git a/codex-rs/core/src/hook_runtime.rs b/codex-rs/core/src/hook_runtime.rs index 6452e9d5ad..54dbbb6cba 100644 --- a/codex-rs/core/src/hook_runtime.rs +++ b/codex-rs/core/src/hook_runtime.rs @@ -701,6 +701,7 @@ fn hook_run_metric_tags(run: &HookRunSummary) -> [(&'static str, &'static str); HookSource::SessionFlags => "session_flags", HookSource::Plugin => "plugin", HookSource::CloudRequirements => "cloud_requirements", + HookSource::CloudManagedConfig => "cloud_managed_config", HookSource::LegacyManagedConfigFile => "legacy_managed_config_file", HookSource::LegacyManagedConfigMdm => "legacy_managed_config_mdm", HookSource::Unknown => "unknown", diff --git a/codex-rs/hooks/src/engine/discovery.rs b/codex-rs/hooks/src/engine/discovery.rs index 6eafbd3d63..e92845e514 100644 --- a/codex-rs/hooks/src/engine/discovery.rs +++ b/codex-rs/hooks/src/engine/discovery.rs @@ -364,6 +364,9 @@ fn config_toml_source_path(layer: &ConfigLayerEntry) -> AbsolutePathBuf { ConfigLayerSource::Mdm { domain, key } => { synthetic_layer_path(&format!("/{CONFIG_TOML_FILE}")) } + ConfigLayerSource::EnterpriseManaged { id, name } => synthetic_layer_path(&format!( + "/{CONFIG_TOML_FILE}" + )), ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { synthetic_layer_path("/managed_config.toml") } @@ -589,6 +592,7 @@ fn hook_metadata_for_config_layer_source(source: &ConfigLayerSource) -> (HookSou ConfigLayerSource::User { .. } => (HookSource::User, false), ConfigLayerSource::Project { .. } => (HookSource::Project, false), ConfigLayerSource::Mdm { .. } => (HookSource::Mdm, true), + ConfigLayerSource::EnterpriseManaged { .. } => (HookSource::CloudManagedConfig, true), ConfigLayerSource::SessionFlags => (HookSource::SessionFlags, false), ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => { (HookSource::LegacyManagedConfigFile, true) @@ -994,6 +998,13 @@ mod tests { }), (HookSource::Mdm, true), ); + assert_eq!( + super::hook_metadata_for_config_layer_source(&ConfigLayerSource::EnterpriseManaged { + id: "cfg_123".to_string(), + name: "Base policy".to_string(), + }), + (HookSource::CloudManagedConfig, true), + ); assert_eq!( super::hook_metadata_for_config_layer_source(&ConfigLayerSource::SessionFlags), (HookSource::SessionFlags, false), diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index d3b8a9e820..d75b77d86f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1374,6 +1374,7 @@ pub enum HookSource { SessionFlags, Plugin, CloudRequirements, + CloudManagedConfig, LegacyManagedConfigFile, LegacyManagedConfigMdm, #[default] diff --git a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs index ce31abf404..bd6e25f7ca 100644 --- a/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs +++ b/codex-rs/tui/src/bottom_pane/hooks_browser_view.rs @@ -778,6 +778,7 @@ fn detail_source_value(hook: &HookMetadata) -> String { HookSource::System | HookSource::Mdm | HookSource::CloudRequirements + | HookSource::CloudManagedConfig | HookSource::LegacyManagedConfigFile | HookSource::LegacyManagedConfigMdm => config_source_label(hook.source).to_string(), _ => format!( @@ -797,6 +798,7 @@ fn config_source_label(source: HookSource) -> &'static str { HookSource::SessionFlags => "Session flags", HookSource::Plugin => unreachable!("plugin hooks are handled by summary_source"), HookSource::CloudRequirements => "Admin config", + HookSource::CloudManagedConfig => "Cloud-managed config", HookSource::LegacyManagedConfigFile => "Admin config", HookSource::LegacyManagedConfigMdm => "Admin config", HookSource::Unknown => "Unknown source", diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 4c40e04455..1ff763920f 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -267,9 +267,9 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { fn render_non_file_layer_details(layer: &ConfigLayerEntry) -> Vec> { match &layer.name { ConfigLayerSource::SessionFlags => render_session_flag_details(&layer.config), - ConfigLayerSource::Mdm { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { - render_mdm_layer_details(layer) - } + ConfigLayerSource::Mdm { .. } + | ConfigLayerSource::EnterpriseManaged { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => render_non_file_layer_value(layer), ConfigLayerSource::System { .. } | ConfigLayerSource::User { .. } | ConfigLayerSource::Project { .. } @@ -308,21 +308,36 @@ fn format_managed_hooks_requirements(hooks: &ManagedHooksRequirementsToml) -> St join_or_empty(parts) } -fn render_mdm_layer_details(layer: &ConfigLayerEntry) -> Vec> { +fn render_non_file_layer_value(layer: &ConfigLayerEntry) -> Vec> { + let label = non_file_layer_value_label(&layer.name); let value = layer .raw_toml() .map(ToString::to_string) .unwrap_or_else(|| format_toml_value(&layer.config)); if value.is_empty() { - return vec![" MDM value: ".dim().into()]; + return vec![format!(" {label}: ").dim().into()]; } if value.contains('\n') { - let mut lines = vec![" MDM value:".into()]; + let mut lines = vec![format!(" {label}:").into()]; lines.extend(value.lines().map(|line| format!(" {line}").into())); lines } else { - vec![format!(" MDM value: {value}").into()] + vec![format!(" {label}: {value}").into()] + } +} + +fn non_file_layer_value_label(source: &ConfigLayerSource) -> &'static str { + match source { + ConfigLayerSource::Mdm { .. } | ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { + "MDM value" + } + ConfigLayerSource::EnterpriseManaged { .. } => "Enterprise-managed config value", + ConfigLayerSource::SessionFlags + | ConfigLayerSource::System { .. } + | ConfigLayerSource::User { .. } + | ConfigLayerSource::Project { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => "Layer value", } } @@ -396,6 +411,9 @@ fn format_config_layer_source(source: &ConfigLayerSource) -> String { ConfigLayerSource::System { file } => { format!("system ({})", file.as_path().display()) } + ConfigLayerSource::EnterpriseManaged { id, name } => { + format!("enterprise-managed ({name}, {id})") + } ConfigLayerSource::User { file, .. } => { format!("user ({})", file.as_path().display()) } @@ -896,12 +914,18 @@ model = "managed_model" approval_policy = "never" "#; let mdm_value = toml::from_str::(raw_mdm_toml).expect("MDM value"); + let mdm_base_dir = if cfg!(windows) { + absolute_path("C:\\codex") + } else { + absolute_path("/var/lib/codex") + }; let stack = ConfigLayerStack::new( vec![ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, mdm_value, raw_mdm_toml.to_string(), + mdm_base_dir, )], ConfigRequirements::default(), ConfigRequirementsToml::default(), @@ -916,6 +940,44 @@ approval_policy = "never" assert!(rendered.contains("approval_policy = \"never\"")); } + #[test] + fn debug_config_output_shows_enterprise_managed_layer_value() { + let raw_cloud_toml = r#" +# managed by cloud +model = "enterprise_model" +approval_policy = "never" +"#; + let cloud_value = toml::from_str::(raw_cloud_toml).expect("cloud value"); + let cloud_base_dir = if cfg!(windows) { + absolute_path("C:\\codex") + } else { + absolute_path("/var/lib/codex") + }; + + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new_with_raw_toml( + ConfigLayerSource::EnterpriseManaged { + id: "cfg_123".to_string(), + name: "Base policy".to_string(), + }, + cloud_value, + raw_cloud_toml.to_string(), + cloud_base_dir, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let rendered = render_to_text(&render_debug_config_lines(&stack)); + assert!(rendered.contains("enterprise-managed (Base policy, cfg_123) (enabled)")); + assert!(rendered.contains("Enterprise-managed config value:")); + assert!(!rendered.contains("MDM value:")); + assert!(rendered.contains("# managed by cloud")); + assert!(rendered.contains("model = \"enterprise_model\"")); + assert!(rendered.contains("approval_policy = \"never\"")); + } + #[test] fn debug_config_output_normalizes_empty_web_search_mode_list() { let requirements = ConfigRequirements {