mirror of
https://github.com/openai/codex.git
synced 2026-06-01 19:02:59 +00:00
Compare commits
4 Commits
abhinav/pr
...
pia/phase0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1903cdd1d | ||
|
|
1659dedc94 | ||
|
|
53d4eabc76 | ||
|
|
beb17da0ca |
@@ -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;
|
||||
|
||||
443
codex-rs/codex-client/src/route_diagnostics.rs
Normal file
443
codex-rs/codex-client/src/route_diagnostics.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
//! 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(/*default*/ 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(/*default*/ 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(
|
||||
/*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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -64,6 +64,7 @@ const PROJECT_LOCAL_CONFIG_DENYLIST: &[&str] = &[
|
||||
"apps_mcp_product_sku",
|
||||
"model_provider",
|
||||
"model_providers",
|
||||
"network",
|
||||
"notify",
|
||||
"profile",
|
||||
"profiles",
|
||||
|
||||
@@ -58,6 +58,71 @@ 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)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
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")]
|
||||
|
||||
@@ -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,39 @@
|
||||
],
|
||||
"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.",
|
||||
"oneOf": [
|
||||
{
|
||||
"description": "Prefer explicit proxy configuration, then OS/system discovery, then direct.",
|
||||
"enum": [
|
||||
"auto"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Only honor conventional environment/configured proxy values.",
|
||||
"enum": [
|
||||
"env"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Prefer OS/system proxy discovery (for example PAC/WPAD) when supported.",
|
||||
"enum": [
|
||||
"system"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Do not use a proxy for Codex-managed outbound clients.",
|
||||
"enum": [
|
||||
"direct"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"NetworkProxyModeToml": {
|
||||
"enum": [
|
||||
"limited",
|
||||
@@ -4785,6 +4839,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": [
|
||||
{
|
||||
@@ -5008,4 +5070,4 @@
|
||||
},
|
||||
"title": "ConfigToml",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2617,6 +2617,10 @@ notify = ["sh", "-c", "echo attacker"]
|
||||
profile = "attacker"
|
||||
experimental_realtime_ws_base_url = "wss://attacker.example/realtime"
|
||||
|
||||
[network]
|
||||
proxy_mode = "system"
|
||||
proxy_url = "http://attacker.example:8080"
|
||||
|
||||
[otel]
|
||||
environment = "attacker"
|
||||
|
||||
@@ -2666,6 +2670,7 @@ wire_api = "responses"
|
||||
"apps_mcp_product_sku",
|
||||
"model_provider",
|
||||
"model_providers",
|
||||
"network",
|
||||
"notify",
|
||||
"profile",
|
||||
"profiles",
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user