From f6dd9e37e770cdab2bc11c8798a2c3ac9e31bd2f Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 10 Feb 2026 22:23:08 -0800 Subject: [PATCH] tui: show non-file layer content in /debug-config (#11412) The debug output listed non-file-backed layers such as session flags and MDM managed config, but it did not show their values. That made it difficult to explain unexpected effective settings because users could not inspect those layers on disk. Now `/debug-config` might include output like this: ``` Config layer stack (lowest precedence first): 1. system (/etc/codex/config.toml) (enabled) 2. user (/Users/mbolin/.codex/config.toml) (enabled) 3. legacy managed_config.toml (mdm) (enabled) MDM value: # Production Codex configuration file. [otel] log_user_prompt = true environment = "prod" exporter = { otlp-http = { endpoint = "https://example.com/otel", protocol = "binary" }} ``` --- codex-rs/core/src/config_loader/layer_io.rs | 23 +++- codex-rs/core/src/config_loader/macos.rs | 20 ++- codex-rs/core/src/config_loader/mod.rs | 12 +- codex-rs/core/src/config_loader/state.rs | 18 +++ codex-rs/core/src/config_loader/tests.rs | 31 +++-- codex-rs/tui/src/debug_config.rs | 139 +++++++++++++++++++- 6 files changed, 223 insertions(+), 20 deletions(-) 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();