From 95e2060d9e6b2581d271297937cd878a8c3e0bdc Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Mon, 15 Jun 2026 23:19:02 +0000 Subject: [PATCH 1/2] Enforce managed OTEL configuration --- codex-rs/config/src/config_requirements.rs | 17 +++ .../config/src/requirements_layers/layer.rs | 4 + .../config/src/requirements_layers/mod.rs | 1 + .../config/src/requirements_layers/otel.rs | 81 ++++++++++ .../config/src/requirements_layers/stack.rs | 8 +- .../src/requirements_layers/stack_tests.rs | 83 ++++++++++ codex-rs/core/src/config/config_tests.rs | 93 +++++++++++ codex-rs/core/src/config/mod.rs | 1 + codex-rs/core/src/config/otel.rs | 144 ++++++++++++++++++ codex-rs/core/src/config/requirements.rs | 5 + 10 files changed, 434 insertions(+), 3 deletions(-) create mode 100644 codex-rs/config/src/requirements_layers/otel.rs diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 12164550052b..43c075bea346 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -22,6 +22,7 @@ use crate::mcp_types::AppToolApproval; use crate::permissions_toml::PermissionProfileToml; use crate::types::AuthCredentialsStoreMode; use crate::types::FeedbackConfigToml; +use crate::types::OtelConfigToml; use crate::types::WindowsSandboxModeToml; #[derive(Debug, Clone, PartialEq, Eq)] @@ -152,6 +153,7 @@ pub struct ConfigRequirements { pub log_dir: Option>, pub model_catalog_json: Option>, pub check_for_update_on_startup: Option>, + pub otel: Option>, pub allow_login_shell: Option>, pub feedback: Option>, pub approval_policy: ConstrainedWithSource, @@ -189,6 +191,7 @@ impl Default for ConfigRequirements { log_dir: None, model_catalog_json: None, check_for_update_on_startup: None, + otel: None, allow_login_shell: None, feedback: None, approval_policy: ConstrainedWithSource::new( @@ -854,6 +857,7 @@ pub struct ConfigRequirementsToml { pub log_dir: Option, pub model_catalog_json: Option, pub check_for_update_on_startup: Option, + pub otel: Option, pub allow_login_shell: Option, pub feedback: Option, pub allowed_approval_policies: Option>, @@ -920,6 +924,7 @@ pub struct ConfigRequirementsWithSources { pub log_dir: Option>, pub model_catalog_json: Option>, pub check_for_update_on_startup: Option>, + pub otel: Option>, pub allow_login_shell: Option>, pub feedback: Option>, pub allowed_approval_policies: Option>>, @@ -972,6 +977,7 @@ impl ConfigRequirementsWithSources { log_dir: _, model_catalog_json: _, check_for_update_on_startup: _, + otel: _, allow_login_shell: _, feedback: _, allowed_approval_policies: _, @@ -1019,6 +1025,7 @@ impl ConfigRequirementsWithSources { log_dir, model_catalog_json, check_for_update_on_startup, + otel, allow_login_shell, feedback, allowed_approval_policies, @@ -1063,6 +1070,7 @@ impl ConfigRequirementsWithSources { log_dir, model_catalog_json, check_for_update_on_startup, + otel, allow_login_shell, feedback, allowed_approval_policies, @@ -1096,6 +1104,7 @@ impl ConfigRequirementsWithSources { log_dir: log_dir.map(|sourced| sourced.value), model_catalog_json: model_catalog_json.map(|sourced| sourced.value), check_for_update_on_startup: check_for_update_on_startup.map(|sourced| sourced.value), + otel: otel.map(|sourced| sourced.value), allow_login_shell: allow_login_shell.map(|sourced| sourced.value), feedback: feedback.map(|sourced| sourced.value), allowed_approval_policies: allowed_approval_policies.map(|sourced| sourced.value), @@ -1196,6 +1205,10 @@ impl ConfigRequirementsToml { && self.log_dir.is_none() && self.model_catalog_json.is_none() && self.check_for_update_on_startup.is_none() + && self + .otel + .as_ref() + .is_none_or(|otel| otel == &OtelConfigToml::default()) && self.allow_login_shell.is_none() && self .feedback @@ -1263,6 +1276,7 @@ impl TryFrom for ConfigRequirements { log_dir, model_catalog_json, check_for_update_on_startup, + otel, allow_login_shell, feedback, allowed_approval_policies, @@ -1602,6 +1616,7 @@ impl TryFrom for ConfigRequirements { log_dir, model_catalog_json, check_for_update_on_startup, + otel, allow_login_shell, feedback, approval_policy, @@ -1703,6 +1718,7 @@ mod tests { log_dir, model_catalog_json, check_for_update_on_startup, + otel, allow_login_shell, feedback, allowed_approval_policies, @@ -1743,6 +1759,7 @@ mod tests { .map(|value| Sourced::new(value, RequirementSource::Unknown)), check_for_update_on_startup: check_for_update_on_startup .map(|value| Sourced::new(value, RequirementSource::Unknown)), + otel: otel.map(|value| Sourced::new(value, RequirementSource::Unknown)), allow_login_shell: allow_login_shell .map(|value| Sourced::new(value, RequirementSource::Unknown)), feedback: feedback.map(|value| Sourced::new(value, RequirementSource::Unknown)), diff --git a/codex-rs/config/src/requirements_layers/layer.rs b/codex-rs/config/src/requirements_layers/layer.rs index d92024e11708..6f16cf1c1355 100644 --- a/codex-rs/config/src/requirements_layers/layer.rs +++ b/codex-rs/config/src/requirements_layers/layer.rs @@ -2,6 +2,7 @@ use crate::ConfigRequirementsToml; use crate::ManagedHooksRequirementsToml; use crate::RequirementSource; use crate::RequirementsExecPolicyToml; +use crate::types::OtelConfigToml; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use toml::Value as TomlValue; @@ -80,6 +81,7 @@ impl ComposableRequirementsLayer { domain_fields: DomainMergedRequirementsFields { rules: requirements.rules, hooks: requirements.hooks, + otel: requirements.otel, permissions: requirements.permissions, }, }) @@ -90,6 +92,7 @@ impl ComposableRequirementsLayer { pub(super) struct DomainMergedRequirementsFields { pub(super) rules: Option, pub(super) hooks: Option, + pub(super) otel: Option, pub(super) permissions: Option, } @@ -163,6 +166,7 @@ fn toml_value_from_serializable( fn strip_special_fields(layer_toml: &mut TomlValue) { remove_top_level_field(layer_toml, "rules"); remove_top_level_field(layer_toml, "hooks"); + remove_top_level_field(layer_toml, "otel"); remove_nested_field_and_prune_empty(layer_toml, &["permissions", "filesystem", "deny_read"]); } diff --git a/codex-rs/config/src/requirements_layers/mod.rs b/codex-rs/config/src/requirements_layers/mod.rs index 52a23a29db0e..5452cf31200b 100644 --- a/codex-rs/config/src/requirements_layers/mod.rs +++ b/codex-rs/config/src/requirements_layers/mod.rs @@ -1,5 +1,6 @@ mod hooks; mod layer; +mod otel; mod permissions; mod rules; mod stack; diff --git a/codex-rs/config/src/requirements_layers/otel.rs b/codex-rs/config/src/requirements_layers/otel.rs new file mode 100644 index 000000000000..1f0552c5c191 --- /dev/null +++ b/codex-rs/config/src/requirements_layers/otel.rs @@ -0,0 +1,81 @@ +use crate::RequirementSource; +use crate::Sourced; +use crate::types::OtelConfigToml; +use std::collections::BTreeMap; + +/// Merges one OTEL requirements layer into an output assembled high-to-low. +/// +/// `log_user_prompt` and `environment` use the first value supplied by a +/// higher-priority layer. Each exporter is also selected as a complete value: +/// for example, the endpoint from one layer is never combined with the headers +/// or TLS settings from another. Span attributes and tracestate are merged by +/// key instead. +pub(super) fn merge( + output: &mut Option>, + incoming: Option, + source: &RequirementSource, +) { + let Some(incoming) = incoming.filter(|value| value != &OtelConfigToml::default()) else { + return; + }; + let Some(output) = output.as_mut() else { + *output = Some(Sourced::new(incoming, source.clone())); + return; + }; + + let OtelConfigToml { + log_user_prompt, + environment, + exporter, + trace_exporter, + metrics_exporter, + span_attributes, + tracestate, + } = incoming; + + macro_rules! fill_exact_leaf { + ($field:ident) => { + if output.value.$field.is_none() { + output.value.$field = $field; + } + }; + } + + fill_exact_leaf!(log_user_prompt); + fill_exact_leaf!(environment); + fill_exact_leaf!(exporter); + fill_exact_leaf!(trace_exporter); + fill_exact_leaf!(metrics_exporter); + merge_map(&mut output.value.span_attributes, span_attributes); + merge_nested_map(&mut output.value.tracestate, tracestate); + super::stack::merge_output_source(&mut output.source, source); +} + +fn merge_map( + output: &mut Option>, + incoming: Option>, +) { + let Some(incoming) = incoming else { + return; + }; + let output = output.get_or_insert_default(); + for (key, value) in incoming { + output.entry(key).or_insert(value); + } +} + +fn merge_nested_map( + output: &mut Option>>, + incoming: Option>>, +) { + let Some(incoming) = incoming else { + return; + }; + let output = output.get_or_insert_default(); + for (member, fields) in incoming { + let output_fields = output.entry(member).or_default(); + for (key, value) in fields { + output_fields.entry(key).or_insert(value); + } + } +} diff --git a/codex-rs/config/src/requirements_layers/stack.rs b/codex-rs/config/src/requirements_layers/stack.rs index fd28cf7c96ab..11dfb3af5e38 100644 --- a/codex-rs/config/src/requirements_layers/stack.rs +++ b/codex-rs/config/src/requirements_layers/stack.rs @@ -6,6 +6,7 @@ //! //! A few fields carry domain-specific meaning that raw TOML replacement would //! break: +//! - `otel` combines recursively merged metadata with atomic exporter leaves. //! - `remote_sandbox_config` is evaluated within each layer before merging. //! - `rules.prefix_rules` append high-priority rules first. //! - `hooks` append high-priority event groups first while failing closed on @@ -132,11 +133,11 @@ impl RequirementsLayerStack { let mut hooks = HookMergeState::new(hook_directory_field); let mut hooks_output = None; let mut deny_read = DenyReadMergeState::default(); - // Regular TOML fields are folded low-to-high like config. These custom - // fields append or union values, so process them high-to-low to keep - // priority order visible in the output. + // Domain-specific fields are processed high-to-low so exact leaves and + // keyed values naturally retain the highest-priority contribution. for layer in layers.iter().rev() { let domain_fields = &layer.domain_fields; + super::otel::merge(&mut output.otel, domain_fields.otel.clone(), &layer.source); super::rules::merge(&mut rules, domain_fields.rules.clone(), &layer.source); hooks.merge( &mut hooks_output, @@ -181,6 +182,7 @@ fn populate_merged_regular_fields_with_sources( log_dir, model_catalog_json, check_for_update_on_startup, + otel: _, allow_login_shell, feedback, allowed_approval_policies, diff --git a/codex-rs/config/src/requirements_layers/stack_tests.rs b/codex-rs/config/src/requirements_layers/stack_tests.rs index 80bdfc27e384..f9b50412c432 100644 --- a/codex-rs/config/src/requirements_layers/stack_tests.rs +++ b/codex-rs/config/src/requirements_layers/stack_tests.rs @@ -991,3 +991,86 @@ api = false )) ); } + +#[test] +fn otel_exporters_replace_atomically_across_layers() { + let high_source = RequirementSource::EnterpriseManaged { + id: "req_high".to_string(), + name: "High".to_string(), + }; + let low_source = RequirementSource::EnterpriseManaged { + id: "req_low".to_string(), + name: "Low".to_string(), + }; + let composed = compose_requirements_for_hostname( + vec![ + RequirementsLayerEntry::from_toml( + low_source.clone(), + r#" +[otel] +log_user_prompt = true +environment = "low" +exporter = { otlp-http = { endpoint = "https://logs.low.example", protocol = "binary", headers = { Authorization = "old-secret" } } } +trace_exporter = { otlp-http = { endpoint = "https://traces.low.example", protocol = "json", headers = { Trace = "old-trace" } } } +metrics_exporter = { otlp-grpc = { endpoint = "https://metrics.low.example", headers = { Metrics = "old-metrics" } } } + +[otel.span_attributes] +region = "us" +team = "low" + +[otel.tracestate.vendor] +low = "one" +shared = "low" +"#, + ), + RequirementsLayerEntry::from_toml( + high_source.clone(), + r#" +[otel] +environment = "high" +exporter = { otlp-grpc = { endpoint = "https://logs.high.example" } } +trace_exporter = { otlp-http = { endpoint = "https://traces.high.example", protocol = "binary" } } +metrics_exporter = "none" + +[otel.span_attributes] +team = "high" + +[otel.tracestate.vendor] +high = "two" +shared = "high" +"#, + ), + ], + /*hostname*/ None, + ) + .expect("compose requirements") + .expect("requirements present"); + + assert_eq!( + composed.otel, + Some(Sourced::new( + expected_requirements( + r#" +[otel] +log_user_prompt = true +environment = "high" +exporter = { otlp-grpc = { endpoint = "https://logs.high.example" } } +trace_exporter = { otlp-http = { endpoint = "https://traces.high.example", protocol = "binary" } } +metrics_exporter = "none" + +[otel.span_attributes] +region = "us" +team = "high" + +[otel.tracestate.vendor] +high = "two" +low = "one" +shared = "high" +"#, + ) + .otel + .expect("expected OTEL requirements"), + RequirementSource::composite([high_source, low_source]), + )) + ); +} diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c1e0bdcb1f03..3ad24fc2a285 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -8338,6 +8338,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() log_dir: None, model_catalog_json: None, check_for_update_on_startup: None, + otel: None, allow_login_shell: None, feedback: None, allowed_approval_policies: None, @@ -11048,3 +11049,95 @@ secret_auth_storage = true ); Ok(()) } + +#[tokio::test] +async fn managed_otel_requirements_merge_keyed_metadata() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + r#" +[otel] +environment = "configured" + +[otel.span_attributes] +configured = "kept" +managed = "old" +"#, + )?; + let config = load_with_enterprise_requirement( + &codex_home, + r#" +[otel] +environment = "managed" + +[otel.span_attributes] +managed = "required" +"#, + ) + .await?; + + assert_eq!(config.otel.environment, "managed"); + assert_eq!( + config.otel.span_attributes, + BTreeMap::from([ + ("configured".to_string(), "kept".to_string()), + ("managed".to_string(), "required".to_string()), + ]) + ); + Ok(()) +} + +#[tokio::test] +async fn invalid_required_otel_trace_metadata_fails_closed() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let err = load_with_enterprise_requirement( + &codex_home, + "[otel.span_attributes]\n\"\" = \"missing-key\"\n", + ) + .await + .expect_err("invalid managed OTEL metadata should fail startup"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("invalid required `otel.span_attributes`") + ); + Ok(()) +} + +#[tokio::test] +async fn required_otel_tracestate_survives_invalid_user_merge() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let configured_overflow = "u".repeat(/*n*/ 120); + std::fs::write( + codex_home.path().join(CONFIG_TOML_FILE), + format!( + r#" +[otel.tracestate.vendor] +required = "configured" +keep = "kept" +zz_overflow = {configured_overflow:?} +"# + ), + )?; + let required_value = "r".repeat(/*n*/ 120); + let requirements = format!("[otel.tracestate.vendor]\nrequired = {required_value:?}\n"); + + let config = load_with_enterprise_requirement(&codex_home, requirements).await?; + + assert_eq!( + config.otel.tracestate, + BTreeMap::from([( + "vendor".to_string(), + BTreeMap::from([ + ("keep".to_string(), "kept".to_string()), + ("required".to_string(), required_value), + ]), + )]) + ); + assert!(config.startup_warnings.iter().any(|warning| { + warning.contains("otel.tracestate.vendor.zz_overflow") + && warning.contains("would invalidate tracestate required by") + })); + Ok(()) +} diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 30348b2a20a2..737c1ae7fadb 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -2714,6 +2714,7 @@ impl Config { log_dir: _, model_catalog_json: _, check_for_update_on_startup: _, + otel: _, allow_login_shell: _, feedback: _, approval_policy: mut constrained_approval_policy, diff --git a/codex-rs/core/src/config/otel.rs b/codex-rs/core/src/config/otel.rs index cb65d304d19a..2e8ec3b3bfd0 100644 --- a/codex-rs/core/src/config/otel.rs +++ b/codex-rs/core/src/config/otel.rs @@ -1,6 +1,8 @@ use std::collections::BTreeMap; use std::fmt::Display; +use codex_config::RequirementSource; +use codex_config::Sourced; use codex_config::types::DEFAULT_OTEL_ENVIRONMENT; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; @@ -36,6 +38,148 @@ pub(crate) fn resolve_config( } } +/// Applies required OTEL settings to user-configured OTEL settings. +/// +/// Required settings and complete exporters replace their configured values, +/// while span attributes and tracestate are merged by key. Invalid required +/// trace metadata returns an error; configured conflicts produce startup +/// warnings. +pub(super) fn apply_requirement( + configured: &mut Option, + requirement: &Sourced, + startup_warnings: &mut Vec, +) -> std::io::Result<()> { + let Sourced { + value: required, + source, + } = requirement; + validate_requirement(required)?; + + let OtelConfigToml { + log_user_prompt, + environment, + exporter, + trace_exporter, + metrics_exporter, + span_attributes, + tracestate, + } = required; + let configured = configured.get_or_insert_default(); + let mut conflict = false; + + conflict |= super::requirements::replace_required_leaf( + &mut configured.log_user_prompt, + log_user_prompt, + ); + conflict |= + super::requirements::replace_required_leaf(&mut configured.environment, environment); + conflict |= super::requirements::replace_required_leaf(&mut configured.exporter, exporter); + conflict |= + super::requirements::replace_required_leaf(&mut configured.trace_exporter, trace_exporter); + conflict |= super::requirements::replace_required_leaf( + &mut configured.metrics_exporter, + metrics_exporter, + ); + + if let Some(required) = span_attributes.as_ref() { + let configured = configured.span_attributes.get_or_insert_default(); + conflict |= required + .iter() + .any(|(key, value)| configured.get(key).is_some_and(|current| current != value)); + configured.extend(required.clone()); + } + if let Some(required) = tracestate.as_ref() { + let configured_tracestate = configured.tracestate.take().unwrap_or_default(); + conflict |= required.iter().any(|(member, required_fields)| { + configured_tracestate + .get(member) + .is_some_and(|configured_fields| { + required_fields.iter().any(|(key, value)| { + configured_fields + .get(key) + .is_some_and(|current| current != value) + }) + }) + }); + configured.tracestate = Some(merge_required_tracestate( + configured_tracestate, + required.clone(), + source, + startup_warnings, + )); + } + + super::requirements::push_structured_requirement_override_warning( + "otel", + conflict, + source, + startup_warnings, + ); + Ok(()) +} + +fn validate_requirement(requirement: &OtelConfigToml) -> std::io::Result<()> { + if let Some(span_attributes) = requirement.span_attributes.as_ref() { + codex_otel::validate_span_attributes(span_attributes).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid required `otel.span_attributes`: {err}"), + ) + })?; + } + if let Some(tracestate) = requirement.tracestate.as_ref() { + codex_otel::validate_tracestate_entries(tracestate).map_err(|err| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid required `otel.tracestate`: {err}"), + ) + })?; + } + Ok(()) +} + +/// Merges user-configured tracestate into a validated required base. +/// +/// Required member fields always win. Other configured fields are admitted one +/// at a time only when the complete candidate remains W3C-valid; rejected +/// fields produce startup warnings rather than erasing required metadata. +fn merge_required_tracestate( + configured: BTreeMap>, + required: BTreeMap>, + source: &RequirementSource, + startup_warnings: &mut Vec, +) -> BTreeMap> { + let mut effective = required.clone(); + for (member_key, configured_fields) in configured { + for (field_key, value) in configured_fields { + if required + .get(&member_key) + .is_some_and(|required_fields| required_fields.contains_key(&field_key)) + { + continue; + } + + let mut candidate = effective.clone(); + candidate + .entry(member_key.clone()) + .or_default() + .insert(field_key.clone(), value); + match codex_otel::validate_tracestate_entries(&candidate) { + Ok(()) => effective = candidate, + Err(err) => { + let config_key = format!("otel.tracestate.{member_key}.{field_key}"); + let message = format!( + "Ignoring configured `{config_key}` because it would invalidate tracestate required by {source}: {err}" + ); + tracing::warn!("{message}"); + startup_warnings.push(message); + } + } + } + } + effective +} + fn resolve_span_attributes( span_attributes: Option>, startup_warnings: &mut Vec, diff --git a/codex-rs/core/src/config/requirements.rs b/codex-rs/core/src/config/requirements.rs index 0c7204fb657d..5ff571d7e7ff 100644 --- a/codex-rs/core/src/config/requirements.rs +++ b/codex-rs/core/src/config/requirements.rs @@ -6,6 +6,8 @@ use codex_config::types::AuthCredentialsStoreMode; use codex_config::types::FeedbackConfigToml; use codex_protocol::config_types::ForcedLoginMethod; +use super::otel; + /// Runtime values that cannot be applied by mutating [`ConfigToml`] alone. pub(super) struct AppliedConfigRequirements { pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, @@ -40,6 +42,9 @@ pub(super) fn apply_to_config( apply_exact!(model_catalog_json); apply_exact!(check_for_update_on_startup); apply_exact!(allow_login_shell); + if let Some(requirement) = requirements.otel.as_ref() { + otel::apply_requirement(&mut config.otel, requirement, startup_warnings)?; + } apply_feedback_requirement( &mut config.feedback, requirements.feedback.as_ref(), From 4657ed38e28fb3cc1ed0ae3a0162bbeb301c9f73 Mon Sep 17 00:00:00 2001 From: Abhinav Vedmala Date: Tue, 16 Jun 2026 06:47:30 +0000 Subject: [PATCH 2/2] Fail closed for required OTEL exporters --- codex-rs/app-server-client/src/lib.rs | 1 + codex-rs/core/src/config/config_tests.rs | 29 +++++++++++++++++++++++ codex-rs/core/src/config/otel.rs | 30 ++++++++++++++++++++++++ codex-rs/core/src/otel_init.rs | 13 ++++++++++ codex-rs/exec/src/lib.rs | 9 +++++++ codex-rs/tui/src/lib.rs | 17 ++++++++++++++ 6 files changed, 99 insertions(+) diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bfcf5522d842..10df3e01e005 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -53,6 +53,7 @@ use codex_config::ThreadConfigLoader; use codex_config::config_toml::ConfigToml; use codex_core::config::Config; pub use codex_core::otel_init::build_provider as build_otel_provider; +pub use codex_core::otel_init::has_required_exporter as has_required_otel_exporter; use codex_core::personality_migration::PersonalityMigrationStatus; use codex_core::personality_migration::maybe_migrate_personality; pub use codex_exec_server::EnvironmentManager; diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 3ad24fc2a285..d502c56c3d65 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -11105,6 +11105,35 @@ async fn invalid_required_otel_trace_metadata_fails_closed() -> std::io::Result< Ok(()) } +#[tokio::test] +async fn invalid_required_otel_exporter_headers_fail_closed() -> std::io::Result<()> { + for (exporter, header) in [ + ( + "otlp-http", + r#"headers = { "invalid header" = "secret" }, protocol = "binary""#, + ), + ("otlp-grpc", r#"headers = { valid = "\u007f" }"#), + ] { + let codex_home = TempDir::new()?; + let requirements = format!( + r#" +[otel] +exporter = {{ {exporter} = {{ endpoint = "https://otel.example", {header} }} }} +"#, + ); + let err = load_with_enterprise_requirement(&codex_home, requirements) + .await + .expect_err("invalid managed OTEL headers should fail startup"); + + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("invalid required `otel.exporter` header") + ); + } + Ok(()) +} + #[tokio::test] async fn required_otel_tracestate_survives_invalid_user_merge() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/otel.rs b/codex-rs/core/src/config/otel.rs index 2e8ec3b3bfd0..745fcb52d86a 100644 --- a/codex-rs/core/src/config/otel.rs +++ b/codex-rs/core/src/config/otel.rs @@ -7,6 +7,8 @@ use codex_config::types::DEFAULT_OTEL_ENVIRONMENT; use codex_config::types::OtelConfig; use codex_config::types::OtelConfigToml; use codex_config::types::OtelExporterKind; +use http::HeaderName; +use http::HeaderValue; pub(crate) fn resolve_config( config: OtelConfigToml, @@ -119,6 +121,13 @@ pub(super) fn apply_requirement( } fn validate_requirement(requirement: &OtelConfigToml) -> std::io::Result<()> { + for (field, exporter) in [ + ("exporter", requirement.exporter.as_ref()), + ("trace_exporter", requirement.trace_exporter.as_ref()), + ("metrics_exporter", requirement.metrics_exporter.as_ref()), + ] { + validate_exporter_headers(field, exporter)?; + } if let Some(span_attributes) = requirement.span_attributes.as_ref() { codex_otel::validate_span_attributes(span_attributes).map_err(|err| { std::io::Error::new( @@ -138,6 +147,27 @@ fn validate_requirement(requirement: &OtelConfigToml) -> std::io::Result<()> { Ok(()) } +fn validate_exporter_headers( + field: &str, + exporter: Option<&OtelExporterKind>, +) -> std::io::Result<()> { + let headers = match exporter { + Some(OtelExporterKind::OtlpHttp { headers, .. }) + | Some(OtelExporterKind::OtlpGrpc { headers, .. }) => headers, + Some(OtelExporterKind::None | OtelExporterKind::Statsig) | None => return Ok(()), + }; + for (name, value) in headers { + if HeaderName::from_bytes(name.as_bytes()).is_err() || HeaderValue::from_str(value).is_err() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!("invalid required `otel.{field}` header `{name}`"), + )); + } + } + Ok(()) +} + /// Merges user-configured tracestate into a validated required base. /// /// Required member fields always win. Other configured fields are admitted one diff --git a/codex-rs/core/src/otel_init.rs b/codex-rs/core/src/otel_init.rs index 8b5aac5f7850..5e19b60baf34 100644 --- a/codex-rs/core/src/otel_init.rs +++ b/codex-rs/core/src/otel_init.rs @@ -10,6 +10,19 @@ use codex_otel::OtelSettings; use codex_otel::OtelTlsConfig as OtelTlsSettings; use std::error::Error; +pub fn has_required_exporter(config: &Config) -> bool { + config + .config_layer_stack + .requirements() + .otel + .as_ref() + .is_some_and(|requirement| { + requirement.value.exporter.is_some() + || requirement.value.trace_exporter.is_some() + || requirement.value.metrics_exporter.is_some() + }) +} + /// Build an OpenTelemetry provider from the app Config. /// /// Returns `None` when OTEL export is disabled. diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index eb84b6339e96..1b52bb5100c3 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -484,6 +484,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result std::process::exit(1); } + let required_otel_exporter = codex_core::otel_init::has_required_exporter(&config); let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { codex_core::otel_init::build_provider( &config, @@ -493,10 +494,18 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result ) })) { Ok(Ok(otel)) => otel, + Ok(Err(e)) if required_otel_exporter => { + eprintln!("Could not create required otel exporter: {e}"); + std::process::exit(1); + } Ok(Err(e)) => { eprintln!("Could not create otel exporter: {e}"); None } + Err(_) if required_otel_exporter => { + eprintln!("Could not create required otel exporter: panicked during initialization"); + std::process::exit(1); + } Err(_) => { eprintln!("Could not create otel exporter: panicked during initialization"); None diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e4f5b4d1a479..fdca7d7fae36 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -1060,6 +1060,7 @@ pub async fn run_main( remove_legacy_tui_log_file(config.codex_home.as_path()); let otel_originator = originator().value; + let required_otel_exporter = codex_app_server_client::has_required_otel_exporter(&config); let otel = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { codex_app_server_client::build_otel_provider( &config, @@ -1069,6 +1070,13 @@ pub async fn run_main( ) })) { Ok(Ok(otel)) => otel, + Ok(Err(e)) if required_otel_exporter => { + #[allow(clippy::print_stderr)] + { + eprintln!("Could not create required otel exporter: {e}"); + } + std::process::exit(1); + } Ok(Err(e)) => { #[allow(clippy::print_stderr)] { @@ -1076,6 +1084,15 @@ pub async fn run_main( } None } + Err(_) if required_otel_exporter => { + #[allow(clippy::print_stderr)] + { + eprintln!( + "Could not create required otel exporter: panicked during initialization" + ); + } + std::process::exit(1); + } Err(_) => { #[allow(clippy::print_stderr)] {