Add outbound network diagnostics scaffolding

This commit is contained in:
canvrno-oai
2026-05-27 17:35:24 +00:00
parent 155905cf72
commit beb17da0ca
7 changed files with 626 additions and 2 deletions

View File

@@ -5,6 +5,7 @@ mod default_client;
mod error;
mod request;
mod retry;
mod route_diagnostics;
mod sse;
mod telemetry;
mod transport;
@@ -34,6 +35,19 @@ pub use crate::retry::RetryOn;
pub use crate::retry::RetryPolicy;
pub use crate::retry::backoff;
pub use crate::retry::run_with_retry;
pub use crate::route_diagnostics::CODEX_NETWORK_DIAGNOSTICS_ENV;
pub use crate::route_diagnostics::CODEX_SYSTEM_PROXY_ENV;
pub use crate::route_diagnostics::RedactedProxyEndpoint;
pub use crate::route_diagnostics::RouteDecision;
pub use crate::route_diagnostics::RouteDiagnostic;
pub use crate::route_diagnostics::RouteFailureClass;
pub use crate::route_diagnostics::RouteSource;
pub use crate::route_diagnostics::RouteTarget;
pub use crate::route_diagnostics::SystemProxyEnvOverride;
pub use crate::route_diagnostics::emit_auth_http_status;
pub use crate::route_diagnostics::emit_auth_network_environment_snapshot;
pub use crate::route_diagnostics::emit_auth_transport_failure;
pub use crate::route_diagnostics::network_diagnostics_enabled;
pub use crate::sse::sse_stream;
pub use crate::telemetry::RequestTelemetry;
pub use crate::transport::ByteStream;

View File

@@ -0,0 +1,444 @@
//! Redacted route diagnostics shared by resolver-aware HTTP clients.
//!
//! This module keeps route values side-effect free; explicitly opt-in helpers emit logs. It gives the upcoming system
//! proxy resolver a common vocabulary for "what route did we choose?" without
//! changing any client routing in this phase. Values stored here must be safe to
//! emit in structured logs: proxy credentials, PAC URLs, request URLs, and token
//! material are never retained.
use std::fmt;
/// Environment kill switch reserved for system proxy discovery.
///
/// Values such as `off`, `false`, `0`, `no`, or `disabled` disable system/PAC
/// discovery while still allowing explicit environment proxies to be honored by
/// future resolver-aware clients.
pub const CODEX_SYSTEM_PROXY_ENV: &str = "CODEX_SYSTEM_PROXY";
/// Opt-in switch for sanitized network diagnostics during auth flows.
///
/// Set to `1`, `true`, `on`, or `yes` to emit one-shot diagnostic events from
/// call sites that explicitly opt in. Values are never logged.
pub const CODEX_NETWORK_DIAGNOSTICS_ENV: &str = "CODEX_NETWORK_DIAGNOSTICS";
fn env_flag_enabled(name: &str) -> bool {
std::env::var(name)
.ok()
.as_deref()
.map(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "on" | "yes"
)
})
.unwrap_or(false)
}
/// Returns whether opt-in network diagnostics are enabled for this process.
pub fn network_diagnostics_enabled() -> bool {
env_flag_enabled(/* name */ CODEX_NETWORK_DIAGNOSTICS_ENV)
}
fn env_present(name: &str) -> bool {
std::env::var_os(name).is_some_and(|value| !value.is_empty())
}
fn proxy_env_present(upper: &str, lower: &str) -> bool {
env_present(upper) || env_present(lower)
}
/// Emit a sanitized auth-network environment snapshot when diagnostics are opt-in.
///
/// This intentionally records only presence bits and coarse override state, never
/// proxy values, CA paths, URLs, headers, or tokens.
pub fn emit_auth_network_environment_snapshot(operation: &'static str) {
if !network_diagnostics_enabled() {
return;
}
let system_override = SystemProxyEnvOverride::from_env();
let system_proxy_state = match system_override {
SystemProxyEnvOverride::Default => "default",
SystemProxyEnvOverride::Disabled => "disabled",
};
tracing::info!(
target_class = "auth",
operation = operation,
http_proxy_present =
proxy_env_present(/* upper */ "HTTP_PROXY", /* lower */ "http_proxy"),
https_proxy_present = proxy_env_present(
/* upper */ "HTTPS_PROXY",
/* lower */ "https_proxy"
),
all_proxy_present =
proxy_env_present(/* upper */ "ALL_PROXY", /* lower */ "all_proxy"),
no_proxy_present =
proxy_env_present(/* upper */ "NO_PROXY", /* lower */ "no_proxy"),
codex_system_proxy = system_proxy_state,
custom_ca_present = env_present(/* name */ "CODEX_CA_CERTIFICATE")
|| env_present(/* name */ "SSL_CERT_FILE"),
"opt-in auth network diagnostic snapshot"
);
}
fn classify_reqwest_error(error: &reqwest::Error) -> RouteFailureClass {
if error.is_timeout() {
return RouteFailureClass::ConnectTimeout;
}
if let Some(status) = error.status()
&& status.as_u16() == 407
{
return RouteFailureClass::ProxyAuthenticationRequired;
}
let rendered = error.to_string().to_ascii_lowercase();
if rendered.contains("tls") || rendered.contains("certificate") || rendered.contains("cert") {
return RouteFailureClass::TlsError;
}
if error.is_connect() {
return RouteFailureClass::ResolverError;
}
RouteFailureClass::Other
}
/// Emit a sanitized auth transport failure classification when diagnostics are opt-in.
pub fn emit_auth_transport_failure(operation: &'static str, error: &reqwest::Error) {
if !network_diagnostics_enabled() {
return;
}
let failure = classify_reqwest_error(error);
tracing::info!(
target_class = "auth",
operation = operation,
failure = %failure,
is_timeout = error.is_timeout(),
is_connect = error.is_connect(),
status_present = error.status().is_some(),
status = error.status().map(|status| status.as_u16()).unwrap_or(0),
"opt-in auth network transport diagnostic"
);
}
/// Emit a sanitized auth HTTP status diagnostic when diagnostics are opt-in.
pub fn emit_auth_http_status(operation: &'static str, status: reqwest::StatusCode) {
if !network_diagnostics_enabled() {
return;
}
let failure = if status.as_u16() == 407 {
RouteFailureClass::ProxyAuthenticationRequired
} else {
RouteFailureClass::Other
};
tracing::info!(
target_class = "auth",
operation = operation,
status = status.as_u16(),
failure = %failure,
"opt-in auth network HTTP status diagnostic"
);
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SystemProxyEnvOverride {
Default,
Disabled,
}
impl SystemProxyEnvOverride {
pub fn from_value(value: Option<&str>) -> Self {
let Some(value) = value else {
return Self::Default;
};
match value.trim().to_ascii_lowercase().as_str() {
"off" | "false" | "0" | "no" | "disabled" => Self::Disabled,
_ => Self::Default,
}
}
pub fn from_env() -> Self {
Self::from_value(std::env::var(CODEX_SYSTEM_PROXY_ENV).ok().as_deref())
}
pub const fn system_discovery_enabled(self) -> bool {
matches!(self, Self::Default)
}
}
/// High-level client path being routed. Keep this coarse to avoid leaking URLs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteTarget {
Auth,
Api,
WebSocket,
Other,
}
impl fmt::Display for RouteTarget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Auth => "auth",
Self::Api => "api",
Self::WebSocket => "wss",
Self::Other => "other",
})
}
}
/// Source that produced a route decision.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteSource {
ConfigOverride,
Env,
System,
Direct,
Disabled,
Unavailable,
}
impl fmt::Display for RouteSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::ConfigOverride => "config_override",
Self::Env => "env",
Self::System => "system",
Self::Direct => "direct",
Self::Disabled => "disabled",
Self::Unavailable => "unavailable",
})
}
}
/// Coarse failure class suitable for logs and support bundles.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteFailureClass {
PacUnavailable,
ConnectTimeout,
ProxyAuthenticationRequired,
TlsError,
InvalidProxyConfig,
UnsupportedProxyScheme,
ResolverError,
Other,
}
impl fmt::Display for RouteFailureClass {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::PacUnavailable => "pac_unavailable",
Self::ConnectTimeout => "connect_timeout",
Self::ProxyAuthenticationRequired => "proxy_407",
Self::TlsError => "tls_error",
Self::InvalidProxyConfig => "invalid_proxy_config",
Self::UnsupportedProxyScheme => "unsupported_proxy_scheme",
Self::ResolverError => "resolver_error",
Self::Other => "other",
})
}
}
/// A proxy endpoint rendered without credentials, hostnames, paths, or query strings.
#[derive(Clone, PartialEq, Eq)]
pub struct RedactedProxyEndpoint(String);
impl RedactedProxyEndpoint {
pub fn parse(input: &str) -> Self {
// Avoid a URL parser dependency here: diagnostics must never echo input,
// so a conservative scheme/authority splitter is sufficient. Anything
// outside the common absolute-URL shape is rendered as invalid.
let Some((scheme, rest)) = input.split_once("://") else {
return Self("<invalid-proxy-url>".to_string());
};
if scheme.is_empty()
|| !scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'-' | b'.'))
{
return Self("<invalid-proxy-url>".to_string());
}
let authority = rest
.split(['/', '?', '#'])
.next()
.filter(|authority| !authority.is_empty());
let Some(authority) = authority else {
return Self("<invalid-proxy-url>".to_string());
};
// Drop credentials if present. We only inspect the post-@ authority for
// a numeric port; the host itself is never copied into the output.
let hostport = authority
.rsplit_once('@')
.map_or(authority, |(_, tail)| tail);
let port = redacted_port_suffix(hostport).unwrap_or_default();
let scheme = scheme.to_ascii_lowercase();
Self(format!("{scheme}://<redacted-host>{port}"))
}
pub fn redacted(&self) -> &str {
&self.0
}
}
fn redacted_port_suffix(hostport: &str) -> Option<String> {
if hostport.starts_with('[') {
let end = hostport.find(']')?;
let suffix = &hostport[end + 1..];
if let Some(port) = suffix.strip_prefix(':')
&& !port.is_empty()
&& port.bytes().all(|b| b.is_ascii_digit())
{
return Some(format!(":{port}"));
}
return None;
}
let (host, port) = hostport.rsplit_once(':')?;
// Treat unbracketed IPv6 or empty host/port as no parseable port.
if host.is_empty() || host.contains(':') || port.is_empty() {
return None;
}
if port.bytes().all(|b| b.is_ascii_digit()) {
Some(format!(":{port}"))
} else {
None
}
}
impl fmt::Debug for RedactedProxyEndpoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl fmt::Display for RedactedProxyEndpoint {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
/// Redacted route decision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteDecision {
Direct,
Proxy(RedactedProxyEndpoint),
Unavailable(RouteFailureClass),
}
impl fmt::Display for RouteDecision {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Direct => f.write_str("direct"),
Self::Proxy(endpoint) => write!(f, "proxy({endpoint})"),
Self::Unavailable(reason) => write!(f, "unavailable({reason})"),
}
}
}
/// One safe diagnostic event for a resolver/client decision.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RouteDiagnostic {
pub target: RouteTarget,
pub source: RouteSource,
pub decision: RouteDecision,
pub failure: Option<RouteFailureClass>,
pub custom_ca_configured: bool,
}
impl RouteDiagnostic {
pub const fn direct(
target: RouteTarget,
source: RouteSource,
custom_ca_configured: bool,
) -> Self {
Self {
target,
source,
decision: RouteDecision::Direct,
failure: None,
custom_ca_configured,
}
}
pub fn proxy(
target: RouteTarget,
source: RouteSource,
proxy_url: &str,
custom_ca_configured: bool,
) -> Self {
Self {
target,
source,
decision: RouteDecision::Proxy(RedactedProxyEndpoint::parse(proxy_url)),
failure: None,
custom_ca_configured,
}
}
pub const fn unavailable(
target: RouteTarget,
source: RouteSource,
failure: RouteFailureClass,
custom_ca_configured: bool,
) -> Self {
Self {
target,
source,
decision: RouteDecision::Unavailable(failure),
failure: Some(failure),
custom_ca_configured,
}
}
/// Emit a redacted structured debug event. Callers should add request IDs in
/// their own span rather than passing URLs or tokens here.
pub fn emit_debug(&self) {
let failure = self
.failure
.map(|failure| failure.to_string())
.unwrap_or_else(|| "none".to_string());
tracing::debug!(
route_target = %self.target,
source = %self.source,
decision = %self.decision,
failure = %failure,
custom_ca_configured = self.custom_ca_configured,
"outbound route diagnostic"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_proxy_credentials_host_path_and_query() {
let endpoint = RedactedProxyEndpoint::parse(
"http://user:secret@proxy.internal.example:8080/pac?token=secret",
);
assert_eq!(endpoint.redacted(), "http://<redacted-host>:8080");
assert!(!format!("{endpoint:?}").contains("secret"));
assert!(!format!("{endpoint}").contains("proxy.internal"));
}
#[test]
fn invalid_proxy_url_is_not_echoed() {
let endpoint = RedactedProxyEndpoint::parse("not a url with password=secret");
assert_eq!(endpoint.redacted(), "<invalid-proxy-url>");
}
#[test]
fn system_proxy_env_override_accepts_disable_spellings() {
for value in ["off", " OFF ", "false", "0", "no", "disabled"] {
assert_eq!(
SystemProxyEnvOverride::from_value(/* value */ Some(value)),
SystemProxyEnvOverride::Disabled
);
}
assert_eq!(
SystemProxyEnvOverride::from_value(/* value */ None),
SystemProxyEnvOverride::Default
);
assert_eq!(
SystemProxyEnvOverride::from_value(/* value */ Some("auto")),
SystemProxyEnvOverride::Default
);
}
}

View File

@@ -16,6 +16,7 @@ use crate::types::History;
use crate::types::MarketplaceConfig;
use crate::types::McpServerConfig;
use crate::types::MemoriesToml;
use crate::types::NetworkConfigToml;
use crate::types::Notice;
use crate::types::OAuthCredentialsStoreMode;
use crate::types::OtelConfigToml;
@@ -359,6 +360,12 @@ pub struct ConfigToml {
/// Base URL override for the built-in `openai` model provider.
pub openai_base_url: Option<String>,
/// Outbound networking/proxy selection settings.
///
/// This section is parsed early so resolver-aware clients can share one
/// spelling; it does not by itself change routing behavior.
pub network: Option<NetworkConfigToml>,
/// Machine-local realtime audio device preferences used by realtime voice.
#[serde(default)]
pub audio: Option<RealtimeAudioToml>,
@@ -982,6 +989,47 @@ mod tests {
);
}
#[test]
fn network_config_accepts_reserved_proxy_spelling() {
let config: ConfigToml = toml::from_str(
r#"
[network]
proxy_mode = "system"
proxy_url = "http://proxy.example:8080"
"#,
)
.expect("network config should deserialize");
let network = config.network.expect("network config should be present");
assert_eq!(
network
.proxy_mode
.expect("proxy mode should be set")
.as_str(),
"system"
);
assert_eq!(
network.proxy_url.as_deref(),
Some("http://proxy.example:8080")
);
}
#[test]
fn network_config_debug_redacts_proxy_url() {
let config: ConfigToml = toml::from_str(
r#"
[network]
proxy_url = "http://user:secret@proxy.internal:8080"
"#,
)
.expect("network config should deserialize");
let rendered = format!("{:?}", config.network.expect("network config"));
assert!(rendered.contains("<redacted-proxy-url>"));
assert!(!rendered.contains("secret"));
assert!(!rendered.contains("proxy.internal"));
}
#[test]
fn forced_chatgpt_workspace_id_rejects_comma_separated_string() {
let err = toml::from_str::<ConfigToml>(&format!(

View File

@@ -64,6 +64,7 @@ const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[
"apps_mcp_product_sku",
"model_provider",
"model_providers",
"network",
"notify",
"profile",
"profiles",

View File

@@ -58,6 +58,70 @@ const fn default_enabled() -> bool {
true
}
/// How Codex should choose an outbound proxy when a future resolver is available.
///
/// This is intentionally configuration-only for now: it establishes the stable
/// spelling used by the resolver work without changing any HTTP routing by
/// itself.
#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum NetworkProxyMode {
/// Prefer explicit proxy configuration, then OS/system discovery, then direct.
#[default]
Auto,
/// Only honor conventional environment/configured proxy values.
Env,
/// Prefer OS/system proxy discovery (for example PAC/WPAD) when supported.
System,
/// Do not use a proxy for Codex-managed outbound clients.
Direct,
}
impl NetworkProxyMode {
pub const fn as_str(self) -> &'static str {
match self {
Self::Auto => "auto",
Self::Env => "env",
Self::System => "system",
Self::Direct => "direct",
}
}
}
impl fmt::Display for NetworkProxyMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
/// Optional outbound networking settings.
///
/// `proxy_url` is reserved for a concrete proxy URL (for example
/// `http://proxy.example:8080`). It is deliberately not a PAC/WPAD URL, and
/// callers must redact credentials before logging it.
#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq, JsonSchema)]
pub struct NetworkConfigToml {
/// Proxy selection mode. Defaults to `auto` when omitted.
#[serde(default)]
pub proxy_mode: Option<NetworkProxyMode>,
/// Explicit concrete proxy URL override for future resolver-aware clients.
#[serde(default)]
pub proxy_url: Option<String>,
}
impl fmt::Debug for NetworkConfigToml {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("NetworkConfigToml")
.field("proxy_mode", &self.proxy_mode)
.field(
"proxy_url",
&self.proxy_url.as_ref().map(|_| "<redacted-proxy-url>"),
)
.finish()
}
}
/// Preferred layout for the resume/fork session picker.
#[derive(Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "kebab-case")]

View File

@@ -1576,6 +1576,27 @@
},
"type": "object"
},
"NetworkConfigToml": {
"additionalProperties": false,
"description": "Optional outbound networking settings.\n\n`proxy_url` is reserved for a concrete proxy URL (for example `http://proxy.example:8080`). It is deliberately not a PAC/WPAD URL, and callers must redact credentials before logging it.",
"properties": {
"proxy_mode": {
"allOf": [
{
"$ref": "#/definitions/NetworkProxyMode"
}
],
"default": null,
"description": "Proxy selection mode. Defaults to `auto` when omitted."
},
"proxy_url": {
"default": null,
"description": "Explicit concrete proxy URL override for future resolver-aware clients.",
"type": "string"
}
},
"type": "object"
},
"NetworkDomainPermissionToml": {
"enum": [
"allow",
@@ -1760,6 +1781,16 @@
],
"type": "string"
},
"NetworkProxyMode": {
"description": "How Codex should choose an outbound proxy when a future resolver is available.\n\nThis is intentionally configuration-only for now: it establishes the stable spelling used by the resolver work without changing any HTTP routing by itself.",
"enum": [
"auto",
"env",
"system",
"direct"
],
"type": "string"
},
"NetworkProxyModeToml": {
"enum": [
"limited",
@@ -4785,6 +4816,14 @@
],
"description": "Optional verbosity control for GPT-5 models (Responses API `text.verbosity`)."
},
"network": {
"allOf": [
{
"$ref": "#/definitions/NetworkConfigToml"
}
],
"description": "Outbound networking/proxy selection settings.\n\nThis section is parsed early so resolver-aware clients can share one spelling; it does not by itself change routing behavior."
},
"notice": {
"allOf": [
{

View File

@@ -38,6 +38,9 @@ use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_client::build_reqwest_client_with_custom_ca;
use codex_client::emit_auth_http_status;
use codex_client::emit_auth_network_environment_snapshot;
use codex_client::emit_auth_transport_failure;
use codex_config::types::AuthCredentialsStoreMode;
use codex_utils_template::Template;
use rand::RngCore;
@@ -725,6 +728,7 @@ pub(crate) async fn exchange_code_for_tokens(
refresh_token: String,
}
emit_auth_network_environment_snapshot(/* operation */ "oauth_token_exchange");
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
info!(
@@ -748,6 +752,7 @@ pub(crate) async fn exchange_code_for_tokens(
let resp = match resp {
Ok(resp) => resp,
Err(error) => {
emit_auth_transport_failure(/* operation */ "oauth_token_exchange", &error);
let error = redact_sensitive_error_url(error);
error!(
is_timeout = error.is_timeout(),
@@ -762,6 +767,7 @@ pub(crate) async fn exchange_code_for_tokens(
let status = resp.status();
if !status.is_success() {
emit_auth_http_status(/* operation */ "oauth_token_exchange", status);
let body = resp.text().await.map_err(io::Error::other)?;
let detail = parse_token_endpoint_error(&body);
warn!(
@@ -1128,6 +1134,7 @@ pub(crate) async fn obtain_api_key(
struct ExchangeResp {
access_token: String,
}
emit_auth_network_environment_snapshot(/* operation */ "api_key_exchange");
let client = build_reqwest_client_with_custom_ca(reqwest::Client::builder())?;
let token_endpoint = format!("{}/oauth/token", issuer.trim_end_matches('/'));
let resp = client
@@ -1142,9 +1149,16 @@ pub(crate) async fn obtain_api_key(
urlencoding::encode("urn:ietf:params:oauth:token-type:id_token")
))
.send()
.await
.map_err(io::Error::other)?;
.await;
let resp = match resp {
Ok(resp) => resp,
Err(error) => {
emit_auth_transport_failure(/* operation */ "api_key_exchange", &error);
return Err(io::Error::other(error));
}
};
if !resp.status().is_success() {
emit_auth_http_status(/* operation */ "api_key_exchange", resp.status());
return Err(io::Error::other(format!(
"api key exchange failed with status {}",
resp.status()