diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index bfcf5522d8..10df3e01e0 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 3ad24fc2a2..d502c56c3d 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 2e8ec3b3bf..745fcb52d8 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 8b5aac5f78..5e19b60baf 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 eb84b6339e..1b52bb5100 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 e4f5b4d1a4..fdca7d7fae 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)] {