diff --git a/codex-rs/core/src/config_loader/layer_io.rs b/codex-rs/core/src/config_loader/layer_io.rs index f6f7d2e37..a97f28554 100644 --- a/codex-rs/core/src/config_loader/layer_io.rs +++ b/codex-rs/core/src/config_loader/layer_io.rs @@ -2,6 +2,8 @@ use super::LoaderOverrides; use super::diagnostics::config_error_from_toml; use super::diagnostics::io_error_from_config_error; #[cfg(target_os = "macos")] +use super::macos::ManagedAdminConfigLayer; +#[cfg(target_os = "macos")] use super::macos::load_managed_admin_config_layer; use codex_utils_absolute_path::AbsolutePathBuf; use std::io; @@ -19,12 +21,18 @@ pub(super) struct MangedConfigFromFile { pub file: AbsolutePathBuf, } +#[derive(Debug, Clone)] +pub(super) struct ManagedConfigFromMdm { + pub managed_config: TomlValue, + pub raw_toml: String, +} + #[derive(Debug, Clone)] pub(super) struct LoadedConfigLayers { /// If present, data read from a file such as `/etc/codex/managed_config.toml`. pub managed_config: Option, /// If present, data read from managed preferences (macOS only). - pub managed_config_from_mdm: Option, + pub managed_config_from_mdm: Option, } pub(super) async fn load_config_layers_internal( @@ -57,7 +65,9 @@ pub(super) async fn load_config_layers_internal( #[cfg(target_os = "macos")] let managed_preferences = - load_managed_admin_config_layer(managed_preferences_base64.as_deref()).await?; + load_managed_admin_config_layer(managed_preferences_base64.as_deref()) + .await? + .map(map_managed_admin_layer); #[cfg(not(target_os = "macos"))] let managed_preferences = None; @@ -68,6 +78,15 @@ pub(super) async fn load_config_layers_internal( }) } +#[cfg(target_os = "macos")] +fn map_managed_admin_layer(layer: ManagedAdminConfigLayer) -> ManagedConfigFromMdm { + let ManagedAdminConfigLayer { config, raw_toml } = layer; + ManagedConfigFromMdm { + managed_config: config, + raw_toml, + } +} + pub(super) async fn read_config_from_path( path: impl AsRef, log_missing_as_info: bool, diff --git a/codex-rs/core/src/config_loader/macos.rs b/codex-rs/core/src/config_loader/macos.rs index e3bbcc07e..b474e81ea 100644 --- a/codex-rs/core/src/config_loader/macos.rs +++ b/codex-rs/core/src/config_loader/macos.rs @@ -15,6 +15,12 @@ const MANAGED_PREFERENCES_APPLICATION_ID: &str = "com.openai.codex"; const MANAGED_PREFERENCES_CONFIG_KEY: &str = "config_toml_base64"; const MANAGED_PREFERENCES_REQUIREMENTS_KEY: &str = "requirements_toml_base64"; +#[derive(Debug, Clone)] +pub(super) struct ManagedAdminConfigLayer { + pub config: TomlValue, + pub raw_toml: String, +} + pub(super) fn managed_preferences_requirements_source() -> RequirementSource { RequirementSource::MdmManagedPreferences { domain: MANAGED_PREFERENCES_APPLICATION_ID.to_string(), @@ -24,7 +30,7 @@ pub(super) fn managed_preferences_requirements_source() -> RequirementSource { pub(crate) async fn load_managed_admin_config_layer( override_base64: Option<&str>, -) -> io::Result> { +) -> io::Result> { if let Some(encoded) = override_base64 { let trimmed = encoded.trim(); return if trimmed.is_empty() { @@ -47,7 +53,7 @@ pub(crate) async fn load_managed_admin_config_layer( } } -fn load_managed_admin_config() -> io::Result> { +fn load_managed_admin_config() -> io::Result> { load_managed_preference(MANAGED_PREFERENCES_CONFIG_KEY)? .as_deref() .map(str::trim) @@ -122,9 +128,13 @@ fn load_managed_preference(key_name: &str) -> io::Result> { Ok(Some(value)) } -fn parse_managed_config_base64(encoded: &str) -> io::Result { - match toml::from_str::(&decode_managed_preferences_base64(encoded)?) { - Ok(TomlValue::Table(parsed)) => Ok(TomlValue::Table(parsed)), +fn parse_managed_config_base64(encoded: &str) -> io::Result { + let raw_toml = decode_managed_preferences_base64(encoded)?; + match toml::from_str::(&raw_toml) { + Ok(TomlValue::Table(parsed)) => Ok(ManagedAdminConfigLayer { + config: TomlValue::Table(parsed), + raw_toml, + }), Ok(other) => { tracing::error!("Managed config TOML must have a table at the root, found {other:?}",); Err(io::Error::new( diff --git a/codex-rs/core/src/config_loader/mod.rs b/codex-rs/core/src/config_loader/mod.rs index a38baf7d4..68973718f 100644 --- a/codex-rs/core/src/config_loader/mod.rs +++ b/codex-rs/core/src/config_loader/mod.rs @@ -278,9 +278,10 @@ pub async fn load_config_layers_state( )); } if let Some(config) = managed_config_from_mdm { - layers.push(ConfigLayerEntry::new( + layers.push(ConfigLayerEntry::new_with_raw_toml( ConfigLayerSource::LegacyManagedConfigTomlFromMdm, - config, + config.managed_config, + config.raw_toml, )); } @@ -485,7 +486,12 @@ async fn load_requirements_from_legacy_scheme( } = loaded_config_layers; for (source, config) in managed_config_from_mdm - .map(|config| (RequirementSource::LegacyManagedConfigTomlFromMdm, config)) + .map(|config| { + ( + RequirementSource::LegacyManagedConfigTomlFromMdm, + config.managed_config, + ) + }) .into_iter() .chain(managed_config.map(|c| { ( diff --git a/codex-rs/core/src/config_loader/state.rs b/codex-rs/core/src/config_loader/state.rs index 847b19d7e..30afecc7c 100644 --- a/codex-rs/core/src/config_loader/state.rs +++ b/codex-rs/core/src/config_loader/state.rs @@ -27,6 +27,7 @@ pub struct LoaderOverrides { pub struct ConfigLayerEntry { pub name: ConfigLayerSource, pub config: TomlValue, + pub raw_toml: Option, pub version: String, pub disabled_reason: Option, } @@ -37,6 +38,18 @@ impl ConfigLayerEntry { Self { name, config, + raw_toml: None, + version, + disabled_reason: None, + } + } + + pub fn new_with_raw_toml(name: ConfigLayerSource, config: TomlValue, raw_toml: String) -> Self { + let version = version_for_toml(&config); + Self { + name, + config, + raw_toml: Some(raw_toml), version, disabled_reason: None, } @@ -51,6 +64,7 @@ impl ConfigLayerEntry { Self { name, config, + raw_toml: None, version, disabled_reason: Some(disabled_reason.into()), } @@ -60,6 +74,10 @@ impl ConfigLayerEntry { self.disabled_reason.is_some() } + pub fn raw_toml(&self) -> Option<&str> { + self.raw_toml.as_deref() + } + pub fn metadata(&self) -> ConfigLayerMetadata { ConfigLayerMetadata { name: self.name.clone(), diff --git a/codex-rs/core/src/config_loader/tests.rs b/codex-rs/core/src/config_loader/tests.rs index 0a6f67d85..79c657ad9 100644 --- a/codex-rs/core/src/config_loader/tests.rs +++ b/codex-rs/core/src/config_loader/tests.rs @@ -264,6 +264,7 @@ async fn returns_empty_when_all_layers_missing() { .expect("resolve user config.toml path") }, config: TomlValue::Table(toml::map::Map::new()), + raw_toml: None, version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), disabled_reason: None, }, @@ -325,18 +326,17 @@ flag = true "#, ) .expect("write managed config"); + let raw_managed_preferences = r#" +# managed profile +[nested] +value = "managed" +flag = false +"#; let overrides = LoaderOverrides { managed_config_path: Some(managed_path), managed_preferences_base64: Some( - base64::prelude::BASE64_STANDARD.encode( - r#" -[nested] -value = "managed" -flag = false -"# - .as_bytes(), - ), + base64::prelude::BASE64_STANDARD.encode(raw_managed_preferences.as_bytes()), ), macos_managed_config_requirements_base64: None, }; @@ -361,6 +361,19 @@ flag = false Some(&TomlValue::String("managed".to_string())) ); assert_eq!(nested.get("flag"), Some(&TomlValue::Boolean(false))); + let mdm_layer = state + .layers_high_to_low() + .into_iter() + .find(|layer| { + matches!( + layer.name, + super::ConfigLayerSource::LegacyManagedConfigTomlFromMdm + ) + }) + .expect("mdm layer"); + let raw = mdm_layer.raw_toml().expect("preserved mdm toml"); + assert!(raw.contains("# managed profile")); + assert!(raw.contains("value = \"managed\"")); } #[cfg(target_os = "macos")] @@ -862,6 +875,7 @@ async fn project_layer_is_added_when_dot_codex_exists_without_config_toml() -> s dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_root.join(".codex"))?, }, config: TomlValue::Table(toml::map::Map::new()), + raw_toml: None, version: version_for_toml(&TomlValue::Table(toml::map::Map::new())), disabled_reason: None, }], @@ -955,6 +969,7 @@ async fn codex_home_within_project_tree_is_not_double_loaded() -> std::io::Resul dot_codex_folder: AbsolutePathBuf::from_absolute_path(&nested_dot_codex)?, }, config: child_config.clone(), + raw_toml: None, version: version_for_toml(&child_config), disabled_reason: None, }], diff --git a/codex-rs/tui/src/debug_config.rs b/codex-rs/tui/src/debug_config.rs index 681d9c3a9..01f5cf6ca 100644 --- a/codex-rs/tui/src/debug_config.rs +++ b/codex-rs/tui/src/debug_config.rs @@ -1,6 +1,7 @@ use crate::history_cell::PlainHistoryCell; use codex_app_server_protocol::ConfigLayerSource; use codex_core::config::Config; +use codex_core::config_loader::ConfigLayerEntry; use codex_core::config_loader::ConfigLayerStack; use codex_core::config_loader::ConfigLayerStackOrdering; use codex_core::config_loader::NetworkConstraints; @@ -11,6 +12,7 @@ use codex_core::config_loader::WebSearchModeRequirement; use codex_core::protocol::SessionNetworkProxyRuntime; use ratatui::style::Stylize; use ratatui::text::Line; +use toml::Value as TomlValue; pub(crate) fn new_debug_config_output( config: &Config, @@ -71,6 +73,7 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { "enabled" }; lines.push(format!(" {}. {source} ({status})", index + 1).into()); + lines.extend(render_non_file_layer_details(layer)); if let Some(reason) = &layer.disabled_reason { lines.push(format!(" reason: {reason}").dim().into()); } @@ -169,6 +172,80 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec> { lines } +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::System { .. } + | ConfigLayerSource::User { .. } + | ConfigLayerSource::Project { .. } + | ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. } => Vec::new(), + } +} + +fn render_session_flag_details(config: &TomlValue) -> Vec> { + let mut pairs = Vec::new(); + flatten_toml_key_values(config, None, &mut pairs); + + if pairs.is_empty() { + return vec![" - ".dim().into()]; + } + + pairs + .into_iter() + .map(|(key, value)| format!(" - {key} = {value}").into()) + .collect() +} + +fn render_mdm_layer_details(layer: &ConfigLayerEntry) -> Vec> { + 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()]; + } + + if value.contains('\n') { + let mut lines = vec![" MDM value:".into()]; + lines.extend(value.lines().map(|line| format!(" {line}").into())); + lines + } else { + vec![format!(" MDM value: {value}").into()] + } +} + +fn flatten_toml_key_values( + value: &TomlValue, + prefix: Option<&str>, + out: &mut Vec<(String, String)>, +) { + match value { + TomlValue::Table(table) => { + let mut entries = table.iter().collect::>(); + entries.sort_by_key(|(key, _)| key.as_str()); + for (key, child) in entries { + let next_prefix = if let Some(prefix) = prefix { + format!("{prefix}.{key}") + } else { + key.to_string() + }; + flatten_toml_key_values(child, Some(&next_prefix), out); + } + } + _ => { + let key = prefix.unwrap_or("").to_string(); + out.push((key, format_toml_value(value))); + } + } +} + +fn format_toml_value(value: &TomlValue) -> String { + value.to_string() +} + fn requirement_line( name: &str, value: String, @@ -205,7 +282,7 @@ fn normalize_allowed_web_search_modes( fn format_config_layer_source(source: &ConfigLayerSource) -> String { match source { ConfigLayerSource::Mdm { domain, key } => { - format!("mdm ({domain}:{key})") + format!("MDM ({domain}:{key})") } ConfigLayerSource::System { file } => { format!("system ({})", file.as_path().display()) @@ -224,7 +301,7 @@ fn format_config_layer_source(source: &ConfigLayerSource) -> String { format!("legacy managed_config.toml ({})", file.as_path().display()) } ConfigLayerSource::LegacyManagedConfigTomlFromMdm => { - "legacy managed_config.toml (mdm)".to_string() + "legacy managed_config.toml (MDM)".to_string() } } } @@ -494,6 +571,64 @@ mod tests { assert!(!rendered.contains(" - rules:")); } + #[test] + fn debug_config_output_lists_session_flag_key_value_pairs() { + let session_flags = toml::from_str::( + r#" +model = "gpt-5" +[sandbox_workspace_write] +network_access = true +writable_roots = ["/tmp"] +"#, + ) + .expect("session flags"); + + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new( + ConfigLayerSource::SessionFlags, + session_flags, + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let rendered = render_to_text(&render_debug_config_lines(&stack)); + assert!(rendered.contains("session-flags (enabled)")); + assert!(rendered.contains(" - model = \"gpt-5\"")); + assert!(rendered.contains(" - sandbox_workspace_write.network_access = true")); + assert!(rendered.contains("sandbox_workspace_write.writable_roots")); + assert!(rendered.contains("/tmp")); + } + + #[test] + fn debug_config_output_shows_legacy_mdm_layer_value() { + let raw_mdm_toml = r#" +# managed by MDM +model = "managed_model" +approval_policy = "never" +"#; + let mdm_value = toml::from_str::(raw_mdm_toml).expect("MDM value"); + + let stack = ConfigLayerStack::new( + vec![ConfigLayerEntry::new_with_raw_toml( + ConfigLayerSource::LegacyManagedConfigTomlFromMdm, + mdm_value, + raw_mdm_toml.to_string(), + )], + ConfigRequirements::default(), + ConfigRequirementsToml::default(), + ) + .expect("config layer stack"); + + let rendered = render_to_text(&render_debug_config_lines(&stack)); + assert!(rendered.contains("legacy managed_config.toml (MDM) (enabled)")); + assert!(rendered.contains("MDM value:")); + assert!(rendered.contains("# managed by MDM")); + assert!(rendered.contains("model = \"managed_model\"")); + assert!(rendered.contains("approval_policy = \"never\"")); + } + #[test] fn debug_config_output_normalizes_empty_web_search_mode_list() { let mut requirements = ConfigRequirements::default();