mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Improve codex doctor network diagnostics (#38918)
## What changed - Probe the configured Responses inference endpoint with Codex's route-aware HTTP client, including configured proxy and custom CA behavior. - Classify TLS, proxy authentication, proxy configuration, resolution, and timeout failures into actionable diagnostics. - Report privacy-safe macOS system proxy state and suggest enabling `respect_system_proxy` when a configured proxy is otherwise unused. - Preserve system-root fallback when a custom CA is invalid, and account for provider-specific API key configuration when selecting the probe route. ## Testing - Cover inference endpoint selection, invalid custom CA fallback, proxy authentication failures, TLS classification, and macOS proxy reporting. GitOrigin-RevId: c30a3ca5a332119e2b2f8c65341ef9b2251a28a2
This commit is contained in:
Generated
+1
@@ -3379,6 +3379,7 @@ dependencies = [
|
||||
"codex-utils-rustls-provider",
|
||||
"futures",
|
||||
"http 1.4.0",
|
||||
"native-tls",
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
"pretty_assertions",
|
||||
|
||||
+152
-93
@@ -39,6 +39,10 @@ use codex_core::config::ConfigBuilder;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_features::FEATURES;
|
||||
use codex_http_client::ClientRouteClass;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
use codex_http_client::RouteAwareClientPool;
|
||||
use codex_install_context::CodexPackageLayout;
|
||||
use codex_install_context::InstallContext;
|
||||
use codex_install_context::InstallMethod;
|
||||
@@ -63,12 +67,14 @@ use codex_tui::Cli as TuiCli;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
use http::HeaderMap;
|
||||
use http::HeaderValue;
|
||||
use http::Method;
|
||||
use serde::Serialize;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod background;
|
||||
mod disk;
|
||||
mod git;
|
||||
mod network;
|
||||
mod output;
|
||||
mod progress;
|
||||
mod runtime;
|
||||
@@ -376,6 +382,11 @@ async fn build_report(
|
||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
|
||||
let auth_manager = auth_manager_result.as_ref().ok().cloned();
|
||||
let reachability_plan = provider_reachability_plan(config);
|
||||
#[cfg(target_os = "macos")]
|
||||
let reachability_url = reachability_plan
|
||||
.endpoints
|
||||
.first()
|
||||
.map(|endpoint| endpoint.url.clone());
|
||||
let (
|
||||
config_check,
|
||||
auth_check,
|
||||
@@ -409,7 +420,9 @@ async fn build_report(
|
||||
})
|
||||
},
|
||||
async { run_sync_check("updates", progress.clone(), || updates_check(config)) },
|
||||
async { run_sync_check("network", progress.clone(), network_check) },
|
||||
async {
|
||||
run_sync_check("network", progress.clone(), || network::check(Some(config)))
|
||||
},
|
||||
run_async_check(
|
||||
"websocket",
|
||||
progress.clone(),
|
||||
@@ -449,6 +462,12 @@ async fn build_report(
|
||||
provider_reachability_check(reachability_plan),
|
||||
),
|
||||
);
|
||||
#[cfg(target_os = "macos")]
|
||||
let reachability_check = network::with_system_proxy_remediation(
|
||||
reachability_check,
|
||||
config,
|
||||
reachability_url.as_deref(),
|
||||
);
|
||||
checks.extend([
|
||||
config_check,
|
||||
auth_check,
|
||||
@@ -488,7 +507,11 @@ async fn build_report(
|
||||
.remediation("Fix the reported config error, then rerun codex doctor.")
|
||||
})
|
||||
},
|
||||
async { run_sync_check("network", progress.clone(), network_check) },
|
||||
async {
|
||||
run_sync_check("network", progress.clone(), || {
|
||||
network::check(/*config*/ None)
|
||||
})
|
||||
},
|
||||
async {
|
||||
run_sync_check("terminal", progress.clone(), || {
|
||||
terminal_check(command.no_color)
|
||||
@@ -1472,42 +1495,6 @@ fn stored_auth_issues(
|
||||
issues
|
||||
}
|
||||
|
||||
fn network_check() -> DoctorCheck {
|
||||
let mut details = Vec::new();
|
||||
push_proxy_env_details(&mut details);
|
||||
|
||||
let mut status = CheckStatus::Ok;
|
||||
let mut summary = "network-related environment looks readable".to_string();
|
||||
for name in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
if let Some(raw) = env::var_os(name) {
|
||||
let path = PathBuf::from(raw);
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
if let Err(err) = read_probe_file(&path) {
|
||||
status = CheckStatus::Warning;
|
||||
summary = "custom CA env var points at an unreadable file".to_string();
|
||||
details.push(format!("{name}: {} ({err})", path.display()));
|
||||
} else {
|
||||
details.push(format!("{name}: readable file {}", path.display()));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
status = CheckStatus::Warning;
|
||||
summary = "custom CA env var does not point at a file".to_string();
|
||||
details.push(format!("{name}: not a file {}", path.display()));
|
||||
}
|
||||
Err(err) => {
|
||||
status = CheckStatus::Warning;
|
||||
summary = "custom CA env var points at an unreadable path".to_string();
|
||||
details.push(format!("{name}: {} ({err})", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DoctorCheck::new("network.env", "network", status, summary).details(details)
|
||||
}
|
||||
|
||||
fn push_proxy_env_details(details: &mut Vec<String>) {
|
||||
let present_proxy_vars = PROXY_ENV_VARS
|
||||
.iter()
|
||||
@@ -2579,6 +2566,7 @@ fn fallback_state_check() -> DoctorCheck {
|
||||
struct ReachabilityPlan {
|
||||
description: String,
|
||||
endpoints: Vec<ReachabilityEndpoint>,
|
||||
http_client_factory: HttpClientFactory,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -2616,10 +2604,12 @@ fn provider_reachability_plan(config: &Config) -> ReachabilityPlan {
|
||||
.flatten();
|
||||
let mode = provider_auth_reachability_mode_from_auth(
|
||||
config.model_provider.requires_openai_auth,
|
||||
config.model_provider.env_key.as_deref(),
|
||||
config.model_provider.base_url.as_deref(),
|
||||
env_var_present,
|
||||
stored_auth.as_ref(),
|
||||
);
|
||||
provider_reachability_plan_from_parts(
|
||||
let mut plan = provider_reachability_plan_from_parts(
|
||||
mode,
|
||||
&config.model_provider_id,
|
||||
&config.model_provider.name,
|
||||
@@ -2627,7 +2617,9 @@ fn provider_reachability_plan(config: &Config) -> ReachabilityPlan {
|
||||
config.model_provider.query_params.as_ref(),
|
||||
config.model_provider.is_amazon_bedrock(),
|
||||
&config.chatgpt_base_url,
|
||||
)
|
||||
);
|
||||
plan.http_client_factory = config.http_client_factory();
|
||||
plan
|
||||
}
|
||||
|
||||
fn default_reachability_plan() -> ReachabilityPlan {
|
||||
@@ -2644,13 +2636,19 @@ fn default_reachability_plan() -> ReachabilityPlan {
|
||||
|
||||
fn provider_auth_reachability_mode_from_auth(
|
||||
requires_openai_auth: bool,
|
||||
provider_env_key: Option<&str>,
|
||||
provider_base_url: Option<&str>,
|
||||
env_var_present: impl Fn(&str) -> bool,
|
||||
stored_auth: Option<&AuthDotJson>,
|
||||
) -> ProviderAuthReachabilityMode {
|
||||
if !requires_openai_auth {
|
||||
return ProviderAuthReachabilityMode::NotRequired;
|
||||
}
|
||||
if env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR) {
|
||||
if provider_base_url.is_some_and(|url| !url.trim().is_empty())
|
||||
&& provider_env_key
|
||||
.is_some_and(|env_key| !env_key.trim().is_empty() && env_var_present(env_key))
|
||||
|| env_var_present(CODEX_API_KEY_ENV_VAR)
|
||||
{
|
||||
return ProviderAuthReachabilityMode::ApiKey;
|
||||
}
|
||||
if env_var_present(CODEX_ACCESS_TOKEN_ENV_VAR) {
|
||||
@@ -2686,35 +2684,29 @@ fn provider_reachability_plan_from_parts(
|
||||
should_probe_models_route(provider_name, url, is_amazon_bedrock)
|
||||
.then(|| provider_url_for_path(url, "models", provider_query_params))
|
||||
});
|
||||
let endpoints = match mode {
|
||||
ProviderAuthReachabilityMode::ApiKey => vec![ReachabilityEndpoint {
|
||||
let endpoints = match (mode, provider_base_url) {
|
||||
(ProviderAuthReachabilityMode::ApiKey, _) | (_, Some(_)) => vec![ReachabilityEndpoint {
|
||||
label: format!("{provider_id} API"),
|
||||
url: provider_base_url
|
||||
.unwrap_or("https://api.openai.com/v1")
|
||||
.to_string(),
|
||||
url: provider_url_for_path(
|
||||
provider_base_url.unwrap_or("https://api.openai.com/v1"),
|
||||
"responses",
|
||||
provider_query_params,
|
||||
),
|
||||
required: true,
|
||||
route_probe_url: provider_route_probe_url,
|
||||
}],
|
||||
ProviderAuthReachabilityMode::Chatgpt => vec![ReachabilityEndpoint {
|
||||
(ProviderAuthReachabilityMode::Chatgpt, None) => vec![ReachabilityEndpoint {
|
||||
label: "ChatGPT".to_string(),
|
||||
url: chatgpt_base_url.to_string(),
|
||||
url: provider_url_for_path(chatgpt_base_url, "codex/responses", provider_query_params),
|
||||
required: true,
|
||||
route_probe_url: None,
|
||||
}],
|
||||
ProviderAuthReachabilityMode::NotRequired => provider_base_url
|
||||
.map(|url| {
|
||||
vec![ReachabilityEndpoint {
|
||||
label: format!("{provider_id} API"),
|
||||
url: url.to_string(),
|
||||
required: true,
|
||||
route_probe_url: provider_route_probe_url,
|
||||
}]
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
(ProviderAuthReachabilityMode::NotRequired, None) => Vec::new(),
|
||||
};
|
||||
ReachabilityPlan {
|
||||
description: mode.description().to_string(),
|
||||
endpoints,
|
||||
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2765,15 +2757,20 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
|
||||
.details(details);
|
||||
}
|
||||
|
||||
let client = RouteAwareClientPool::with_chatgpt_cloudflare_cookies_without_request_logging(
|
||||
plan.http_client_factory,
|
||||
ClientRouteClass::Api,
|
||||
)
|
||||
.with_legacy_custom_ca_fallback();
|
||||
let mut failures = Vec::new();
|
||||
let mut optional_failures = Vec::new();
|
||||
let mut route_failures = Vec::new();
|
||||
let mut route_warnings = Vec::new();
|
||||
let mut issues = Vec::new();
|
||||
for endpoint in plan.endpoints {
|
||||
match http_probe_url(&endpoint.url).await {
|
||||
match network::probe_status(&client, &endpoint.url, Method::HEAD, default_headers()).await {
|
||||
Ok(status) => details.push(format!(
|
||||
"{} base URL: {} reachable ({status})",
|
||||
"{} inference URL: {} reachable (HTTP {status})",
|
||||
endpoint.label, endpoint.url
|
||||
)),
|
||||
Err(err) => {
|
||||
@@ -2783,7 +2780,7 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
|
||||
"optional"
|
||||
};
|
||||
details.push(format!(
|
||||
"{} base URL: {} {err} ({requirement})",
|
||||
"{} inference URL: {} {err} ({requirement})",
|
||||
endpoint.label, endpoint.url
|
||||
));
|
||||
if endpoint.required {
|
||||
@@ -2798,7 +2795,7 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
|
||||
let Some(route_probe_url) = endpoint.route_probe_url.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
match provider_route_probe_url(route_probe_url).await {
|
||||
match provider_route_probe_url(&client, route_probe_url).await {
|
||||
RouteProbeOutcome::Ok(status) => {
|
||||
details.push(format!(
|
||||
"{} route probe: {route_probe_url} route exists ({status})",
|
||||
@@ -2876,8 +2873,8 @@ enum RouteProbeOutcome {
|
||||
TransportError(String),
|
||||
}
|
||||
|
||||
async fn provider_route_probe_url(url: &str) -> RouteProbeOutcome {
|
||||
match http_get_probe_status_with_timeout(url, Duration::from_secs(3)).await {
|
||||
async fn provider_route_probe_url(client: &RouteAwareClientPool, url: &str) -> RouteProbeOutcome {
|
||||
match network::probe_status(client, url, Method::GET, default_headers()).await {
|
||||
Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => {
|
||||
RouteProbeOutcome::Ok(format!("HTTP {status}"))
|
||||
}
|
||||
@@ -2907,10 +2904,6 @@ fn provider_reachability_outcome(
|
||||
}
|
||||
}
|
||||
|
||||
async fn http_probe_url(url: &str) -> Result<String, String> {
|
||||
http_probe_url_with_timeout(url, Duration::from_secs(3)).await
|
||||
}
|
||||
|
||||
async fn mcp_http_probe_url(url: &str) -> Result<String, String> {
|
||||
mcp_http_probe_url_with_timeout(url, Duration::from_secs(3)).await
|
||||
}
|
||||
@@ -3652,6 +3645,8 @@ mod tests {
|
||||
assert_eq!(
|
||||
provider_auth_reachability_mode_from_auth(
|
||||
/*requires_openai_auth*/ true,
|
||||
/*provider_env_key*/ None,
|
||||
/*provider_base_url*/ None,
|
||||
|_| false,
|
||||
Some(&api_key_auth),
|
||||
),
|
||||
@@ -3660,11 +3655,49 @@ mod tests {
|
||||
assert_eq!(
|
||||
provider_auth_reachability_mode_from_auth(
|
||||
/*requires_openai_auth*/ true,
|
||||
|name| name == OPENAI_API_KEY_ENV_VAR,
|
||||
/*provider_env_key*/ None,
|
||||
/*provider_base_url*/ None,
|
||||
|name| name == CODEX_API_KEY_ENV_VAR,
|
||||
/*stored_auth*/ None,
|
||||
),
|
||||
ProviderAuthReachabilityMode::ApiKey
|
||||
);
|
||||
|
||||
let chatgpt_auth = AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
..api_key_auth
|
||||
};
|
||||
assert_eq!(
|
||||
provider_auth_reachability_mode_from_auth(
|
||||
/*requires_openai_auth*/ true,
|
||||
/*provider_env_key*/ None,
|
||||
Some("https://custom.example/v1"),
|
||||
|name| name == OPENAI_API_KEY_ENV_VAR,
|
||||
Some(&chatgpt_auth),
|
||||
),
|
||||
ProviderAuthReachabilityMode::Chatgpt
|
||||
);
|
||||
assert_eq!(
|
||||
provider_auth_reachability_mode_from_auth(
|
||||
/*requires_openai_auth*/ true,
|
||||
Some(OPENAI_API_KEY_ENV_VAR),
|
||||
Some("https://custom.example/v1"),
|
||||
|name| name == OPENAI_API_KEY_ENV_VAR,
|
||||
Some(&chatgpt_auth),
|
||||
),
|
||||
ProviderAuthReachabilityMode::ApiKey
|
||||
);
|
||||
assert_eq!(
|
||||
provider_auth_reachability_mode_from_auth(
|
||||
/*requires_openai_auth*/ true,
|
||||
/*provider_env_key*/ None,
|
||||
/*provider_base_url*/ None,
|
||||
|name| name == CODEX_API_KEY_ENV_VAR,
|
||||
Some(&chatgpt_auth),
|
||||
),
|
||||
ProviderAuthReachabilityMode::ApiKey
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3683,10 +3716,11 @@ mod tests {
|
||||
description: "provider auth".to_string(),
|
||||
endpoints: vec![ReachabilityEndpoint {
|
||||
label: "azure API".to_string(),
|
||||
url: "https://example.openai.azure.com/openai/v1".to_string(),
|
||||
url: "https://example.openai.azure.com/openai/v1/responses".to_string(),
|
||||
required: true,
|
||||
route_probe_url: None,
|
||||
}],
|
||||
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -3695,28 +3729,38 @@ mod tests {
|
||||
fn provider_reachability_adds_models_route_probe_for_openai_compatible_base_urls() {
|
||||
let query_params = HashMap::from([("api-version".to_string(), "2026-01-01".to_string())]);
|
||||
|
||||
assert_eq!(
|
||||
provider_reachability_plan_from_parts(
|
||||
ProviderAuthReachabilityMode::NotRequired,
|
||||
"custom",
|
||||
"Custom",
|
||||
Some("https://example.com/openai/v1/"),
|
||||
Some(&query_params),
|
||||
/*is_amazon_bedrock*/ false,
|
||||
"https://chatgpt.com/backend-api/",
|
||||
),
|
||||
ReachabilityPlan {
|
||||
description: "provider auth".to_string(),
|
||||
endpoints: vec![ReachabilityEndpoint {
|
||||
label: "custom API".to_string(),
|
||||
url: "https://example.com/openai/v1/".to_string(),
|
||||
required: true,
|
||||
route_probe_url: Some(
|
||||
"https://example.com/openai/v1/models?api-version=2026-01-01".to_string()
|
||||
for (mode, description) in [
|
||||
(ProviderAuthReachabilityMode::NotRequired, "provider auth"),
|
||||
(ProviderAuthReachabilityMode::Chatgpt, "ChatGPT auth"),
|
||||
] {
|
||||
assert_eq!(
|
||||
provider_reachability_plan_from_parts(
|
||||
mode,
|
||||
"custom",
|
||||
"Custom",
|
||||
Some("https://example.com/openai/v1/"),
|
||||
Some(&query_params),
|
||||
/*is_amazon_bedrock*/ false,
|
||||
"https://chatgpt.com/backend-api/",
|
||||
),
|
||||
ReachabilityPlan {
|
||||
description: description.to_string(),
|
||||
endpoints: vec![ReachabilityEndpoint {
|
||||
label: "custom API".to_string(),
|
||||
url: "https://example.com/openai/v1/responses?api-version=2026-01-01"
|
||||
.to_string(),
|
||||
required: true,
|
||||
route_probe_url: Some(
|
||||
"https://example.com/openai/v1/models?api-version=2026-01-01"
|
||||
.to_string()
|
||||
),
|
||||
}],
|
||||
http_client_factory: HttpClientFactory::new(
|
||||
OutboundProxyPolicy::ReqwestDefault
|
||||
),
|
||||
}],
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3750,13 +3794,26 @@ mod tests {
|
||||
plan.endpoints,
|
||||
vec![ReachabilityEndpoint {
|
||||
label: "openai API".to_string(),
|
||||
url: "https://api.openai.com/v1".to_string(),
|
||||
url: "https://api.openai.com/v1/responses".to_string(),
|
||||
required: true,
|
||||
route_probe_url: Some("https://api.openai.com/v1/models".to_string()),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_reachability_chatgpt_uses_inference_endpoint() {
|
||||
assert_eq!(
|
||||
default_reachability_plan().endpoints,
|
||||
vec![ReachabilityEndpoint {
|
||||
label: "ChatGPT".to_string(),
|
||||
url: "https://chatgpt.com/backend-api/codex/responses".to_string(),
|
||||
required: true,
|
||||
route_probe_url: None,
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_reachability_outcome_reports_required_failures() {
|
||||
assert_eq!(
|
||||
@@ -3892,7 +3949,9 @@ mod tests {
|
||||
.expect("write response");
|
||||
});
|
||||
|
||||
let status = http_probe_url(&format!("http://{addr}/mcp")).await;
|
||||
let status =
|
||||
http_probe_url_with_timeout(&format!("http://{addr}/mcp"), Duration::from_secs(3))
|
||||
.await;
|
||||
server.join().expect("probe server thread should finish");
|
||||
|
||||
assert_eq!(status, Ok("HTTP 405".to_string()));
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_core::config::Config;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_http_client::MacosSystemProxyConfiguration;
|
||||
use codex_http_client::RouteAwareClientPool;
|
||||
use codex_http_client::RouteAwareRequestError;
|
||||
use codex_http_client::RouteFailureClass;
|
||||
#[cfg(target_os = "macos")]
|
||||
use codex_http_client::macos_system_proxy_configuration;
|
||||
use codex_login::default_client::create_client_without_request_logging;
|
||||
use http::HeaderMap;
|
||||
use http::Method;
|
||||
|
||||
use super::CheckStatus;
|
||||
use super::DoctorCheck;
|
||||
use super::push_proxy_env_details;
|
||||
use super::read_probe_file;
|
||||
|
||||
pub(super) fn check(config: Option<&Config>) -> DoctorCheck {
|
||||
let mut details = Vec::new();
|
||||
push_proxy_env_details(&mut details);
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let request_url = config
|
||||
.and_then(|config| config.model_provider.base_url.as_deref())
|
||||
.or_else(|| config.map(|config| config.chatgpt_base_url.as_str()))
|
||||
.unwrap_or("https://chatgpt.com/backend-api/");
|
||||
let configuration = match macos_system_proxy_configuration(request_url) {
|
||||
MacosSystemProxyConfiguration::Automatic => "automatic (PAC)",
|
||||
MacosSystemProxyConfiguration::Manual => "manual",
|
||||
MacosSystemProxyConfiguration::Direct => "direct",
|
||||
MacosSystemProxyConfiguration::Unavailable => "unavailable",
|
||||
};
|
||||
details.push(format!("system proxy: {configuration}"));
|
||||
}
|
||||
if let Some(config) = config {
|
||||
let enabled = |value| if value { "enabled" } else { "disabled" };
|
||||
details.push(format!(
|
||||
"respect system proxy: {}",
|
||||
enabled(config.respect_system_proxy)
|
||||
));
|
||||
if config.permissions.network.is_some() {
|
||||
details.push("managed proxy: configured".to_string());
|
||||
} else {
|
||||
details.push("managed proxy: not configured".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut status = CheckStatus::Ok;
|
||||
let mut summary = "network-related environment looks readable".to_string();
|
||||
for name in ["CODEX_CA_CERTIFICATE", "SSL_CERT_FILE"] {
|
||||
if let Some(raw) = env::var_os(name) {
|
||||
let path = PathBuf::from(raw);
|
||||
match std::fs::metadata(&path) {
|
||||
Ok(metadata) if metadata.is_file() => {
|
||||
if let Err(error) = read_probe_file(&path) {
|
||||
status = CheckStatus::Warning;
|
||||
summary = "custom CA env var points at an unreadable file".to_string();
|
||||
details.push(format!("{name}: {} ({error})", path.display()));
|
||||
} else {
|
||||
details.push(format!("{name}: readable file {}", path.display()));
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
status = CheckStatus::Warning;
|
||||
summary = "custom CA env var does not point at a file".to_string();
|
||||
details.push(format!("{name}: not a file {}", path.display()));
|
||||
}
|
||||
Err(error) => {
|
||||
status = CheckStatus::Warning;
|
||||
summary = "custom CA env var points at an unreadable path".to_string();
|
||||
details.push(format!("{name}: {} ({error})", path.display()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DoctorCheck::new("network.env", "network", status, summary).details(details)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) fn with_system_proxy_remediation(
|
||||
mut check: DoctorCheck,
|
||||
config: &Config,
|
||||
request_url: Option<&str>,
|
||||
) -> DoctorCheck {
|
||||
if check.status == CheckStatus::Fail
|
||||
&& !config.respect_system_proxy
|
||||
&& config.permissions.network.is_none()
|
||||
&& config
|
||||
.config_layer_stack
|
||||
.requirements()
|
||||
.feature_requirements
|
||||
.as_ref()
|
||||
.and_then(|requirements| requirements.value.entries.get("respect_system_proxy"))
|
||||
.is_none_or(|enabled| *enabled)
|
||||
&& !super::PROXY_ENV_VARS
|
||||
.iter()
|
||||
.any(|name| !name.eq_ignore_ascii_case("NO_PROXY") && super::env_var_present(name))
|
||||
&& request_url.is_some_and(|url| {
|
||||
matches!(
|
||||
macos_system_proxy_configuration(url),
|
||||
MacosSystemProxyConfiguration::Automatic | MacosSystemProxyConfiguration::Manual
|
||||
)
|
||||
})
|
||||
{
|
||||
check.remediation = Some(
|
||||
"A macOS system proxy is configured but unused. If your organization requires it, ask your administrator whether to enable the under-development feature with `codex features enable respect_system_proxy`."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
check
|
||||
}
|
||||
|
||||
pub(super) async fn probe_status(
|
||||
client: &RouteAwareClientPool,
|
||||
url: &str,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
) -> Result<u16, String> {
|
||||
let response = if env::var("CODEX_SANDBOX").as_deref() == Ok("seatbelt") {
|
||||
create_client_without_request_logging()
|
||||
.request(method, url)
|
||||
.headers(headers)
|
||||
.timeout(Duration::from_secs(8))
|
||||
.send()
|
||||
.await
|
||||
.map_err(RouteAwareRequestError::from)
|
||||
} else {
|
||||
client
|
||||
.request(method, url)
|
||||
.headers(headers)
|
||||
// Allow five seconds for PAC resolution before the three-second HTTP budget.
|
||||
.timeout(Duration::from_secs(8))
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
.map_err(|error| {
|
||||
match error.failure_class() {
|
||||
Some(RouteFailureClass::TlsError) => "TLS handshake or certificate validation failed",
|
||||
Some(RouteFailureClass::ProxyAuthenticationRequired) => "proxy authentication required",
|
||||
Some(RouteFailureClass::InvalidProxyConfig) => "invalid proxy configuration",
|
||||
Some(RouteFailureClass::ProxyResolutionUnavailable) => {
|
||||
"system proxy configuration unavailable"
|
||||
}
|
||||
Some(RouteFailureClass::ConnectTimeout) => "request timed out",
|
||||
Some(RouteFailureClass::UnsupportedProxyScheme) => "unsupported proxy configuration",
|
||||
Some(RouteFailureClass::ResolverError) => "proxy resolution failed",
|
||||
None if error.is_timeout() => "request timed out",
|
||||
None if error.is_connect() => "connect failed",
|
||||
None => "request failed",
|
||||
}
|
||||
.to_string()
|
||||
})?;
|
||||
let status = response.status().as_u16();
|
||||
if status == 407 {
|
||||
return Err("proxy authentication required (HTTP 407)".to_string());
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "network_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,37 @@
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
|
||||
use super::super::CheckStatus;
|
||||
use super::super::ProviderAuthReachabilityMode;
|
||||
use super::super::provider_reachability_check;
|
||||
use super::super::provider_reachability_plan_from_parts;
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_reachability_rejects_proxy_authentication_challenges() {
|
||||
let proxy = MockServer::start().await;
|
||||
Mock::given(method("HEAD"))
|
||||
.respond_with(ResponseTemplate::new(407))
|
||||
.mount(&proxy)
|
||||
.await;
|
||||
let plan = provider_reachability_plan_from_parts(
|
||||
ProviderAuthReachabilityMode::Chatgpt,
|
||||
"openai",
|
||||
"OpenAI",
|
||||
/*provider_base_url*/ None,
|
||||
/*provider_query_params*/ None,
|
||||
/*is_amazon_bedrock*/ false,
|
||||
&format!("{}/backend-api/", proxy.uri()),
|
||||
);
|
||||
|
||||
let check = provider_reachability_check(plan).await;
|
||||
assert_eq!(check.status, CheckStatus::Fail);
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.join(" ")
|
||||
.contains("proxy authentication required (HTTP 407)")
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use anyhow::Result;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn invalid_custom_ca_falls_back_to_system_roots() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
for (request_method, request_path) in [("HEAD", "/v1/responses"), ("GET", "/v1/models")] {
|
||||
Mock::given(method(request_method))
|
||||
.and(path(request_path))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
let certificate = codex_home.path().join("invalid-ca.pem");
|
||||
std::fs::write(&certificate, "not a certificate")?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
"model_provider = \"local\"\n[model_providers.local]\nname = \"local\"\nbase_url = \"{}/v1\"\nwire_api = \"responses\"\n",
|
||||
server.uri()
|
||||
),
|
||||
)?;
|
||||
for sandbox in [None, Some("seatbelt")] {
|
||||
let mut command = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
command
|
||||
.args(["doctor", "--json"])
|
||||
.env("CODEX_HOME", codex_home.path())
|
||||
.env("CODEX_CA_CERTIFICATE", &certificate)
|
||||
.stdin(Stdio::null());
|
||||
if let Some(sandbox) = sandbox {
|
||||
command
|
||||
.env("CODEX_SANDBOX", sandbox)
|
||||
.env("HTTP_PROXY", "http://127.0.0.1:1")
|
||||
.env("http_proxy", "http://127.0.0.1:1")
|
||||
.env("HTTPS_PROXY", "http://127.0.0.1:1")
|
||||
.env("https_proxy", "http://127.0.0.1:1")
|
||||
.env("NO_PROXY", "")
|
||||
.env("no_proxy", "");
|
||||
} else {
|
||||
command
|
||||
.env("NO_PROXY", "127.0.0.1,localhost")
|
||||
.env("no_proxy", "127.0.0.1,localhost");
|
||||
}
|
||||
let output = command
|
||||
.output()
|
||||
.context("failed to run the doctor with an invalid custom CA")?;
|
||||
let report: Value = serde_json::from_slice(&output.stdout)?;
|
||||
|
||||
assert_eq!(
|
||||
report["checks"]["network.provider_reachability"]["status"],
|
||||
json!("ok")
|
||||
);
|
||||
}
|
||||
server.verify().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn doctor_reports_macos_system_proxy_configuration_and_policy() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let report = doctor_report(codex_home.path())?;
|
||||
let details = &report["checks"]["network.env"]["details"];
|
||||
|
||||
assert_eq!(details["respect system proxy"], json!("disabled"));
|
||||
assert!(matches!(
|
||||
details["system proxy"].as_str(),
|
||||
Some("automatic (PAC)" | "manual" | "direct" | "unavailable")
|
||||
));
|
||||
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\nrespect_system_proxy = true\n",
|
||||
)?;
|
||||
let report = doctor_report(codex_home.path())?;
|
||||
assert_eq!(
|
||||
report["checks"]["network.env"]["details"]["respect system proxy"],
|
||||
json!("enabled")
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn doctor_report(codex_home: &Path) -> Result<Value> {
|
||||
let output = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?)
|
||||
.args(["doctor", "--json"])
|
||||
.env("CODEX_HOME", codex_home)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.context("failed to run the doctor")?;
|
||||
|
||||
serde_json::from_slice(&output.stdout).context("doctor did not emit a valid json report")
|
||||
}
|
||||
@@ -9,6 +9,7 @@ bytes = { workspace = true }
|
||||
codex-utils-rustls-provider = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
http = { workspace = true }
|
||||
native-tls = "0.2"
|
||||
opentelemetry = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||
rustls = { workspace = true }
|
||||
|
||||
@@ -34,11 +34,15 @@ pub use crate::error::TransportError;
|
||||
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
|
||||
pub use crate::outbound_proxy::ClientRouteClass;
|
||||
pub use crate::outbound_proxy::HttpClientFactory;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use crate::outbound_proxy::MacosSystemProxyConfiguration;
|
||||
pub use crate::outbound_proxy::OutboundProxyPolicy;
|
||||
pub use crate::outbound_proxy::OutboundProxyRoute;
|
||||
pub use crate::outbound_proxy::RouteFailureClass;
|
||||
#[doc(hidden)]
|
||||
pub use crate::outbound_proxy::cache_system_proxy_route_for_test;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use crate::outbound_proxy::macos_system_proxy_configuration;
|
||||
pub use crate::request::EncodedJsonBody;
|
||||
pub use crate::request::PreparedRequestBody;
|
||||
pub use crate::request::Request;
|
||||
|
||||
@@ -103,6 +103,26 @@ pub enum OutboundProxyPolicy {
|
||||
RespectSystemProxy,
|
||||
}
|
||||
|
||||
/// Privacy-safe macOS system proxy configuration for one outbound destination.
|
||||
#[cfg(target_os = "macos")]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MacosSystemProxyConfiguration {
|
||||
/// A PAC script or automatic proxy-discovery route applies to the destination.
|
||||
Automatic,
|
||||
/// An explicitly configured HTTP or HTTPS proxy applies.
|
||||
Manual,
|
||||
/// macOS selected a direct connection for the destination.
|
||||
Direct,
|
||||
/// The destination or system proxy settings could not be inspected.
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
/// Inspects macOS proxy configuration without executing PAC scripts or exposing proxy URLs.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn macos_system_proxy_configuration(request_url: &str) -> MacosSystemProxyConfiguration {
|
||||
macos::configuration(request_url)
|
||||
}
|
||||
|
||||
/// Resolved proxy route for a concrete outbound destination.
|
||||
///
|
||||
/// `TransportDefault` preserves the underlying transport behavior only when system-proxy support
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::ptr;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::MacosSystemProxyConfiguration;
|
||||
use super::RequestOrigin;
|
||||
use super::RouteFailureClass;
|
||||
use super::SystemProxyDecision;
|
||||
@@ -102,6 +103,39 @@ pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyD
|
||||
proxy_array_decision(&proxies, &target_url, origin)
|
||||
}
|
||||
|
||||
pub(super) fn configuration(request_url: &str) -> MacosSystemProxyConfiguration {
|
||||
let Some(target_url) = cf_url(request_url) else {
|
||||
return MacosSystemProxyConfiguration::Unavailable;
|
||||
};
|
||||
let Some(settings) = system_proxy_settings() else {
|
||||
return MacosSystemProxyConfiguration::Unavailable;
|
||||
};
|
||||
let Some(proxies) = copy_proxies_for_url(&target_url, &settings) else {
|
||||
return MacosSystemProxyConfiguration::Unavailable;
|
||||
};
|
||||
|
||||
(&proxies)
|
||||
.into_iter()
|
||||
.find_map(|proxy| {
|
||||
let proxy_type = cf_string_value(&proxy, unsafe { kCFProxyTypeKey })?;
|
||||
if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeAutoConfigurationURL })
|
||||
|| cf_string_equals(&proxy_type, unsafe {
|
||||
kCFProxyTypeAutoConfigurationJavaScript
|
||||
})
|
||||
{
|
||||
Some(MacosSystemProxyConfiguration::Automatic)
|
||||
} else if cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTP })
|
||||
|| cf_string_equals(&proxy_type, unsafe { kCFProxyTypeHTTPS })
|
||||
{
|
||||
Some(MacosSystemProxyConfiguration::Manual)
|
||||
} else {
|
||||
cf_string_equals(&proxy_type, unsafe { kCFProxyTypeNone })
|
||||
.then_some(MacosSystemProxyConfiguration::Direct)
|
||||
}
|
||||
})
|
||||
.unwrap_or(MacosSystemProxyConfiguration::Unavailable)
|
||||
}
|
||||
|
||||
fn system_proxy_settings() -> Option<CFDictionary<CFString, CFType>> {
|
||||
let store = SCDynamicStoreBuilder::new("Codex").build()?;
|
||||
store.get_proxies()
|
||||
|
||||
@@ -64,6 +64,9 @@ fn spawn_http_listener(
|
||||
Err(error) => panic!("HTTP listener should accept: {error}"),
|
||||
}
|
||||
};
|
||||
stream
|
||||
.set_nonblocking(false)
|
||||
.expect("HTTP stream should become blocking");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.expect("HTTP stream should get a read timeout");
|
||||
@@ -185,6 +188,15 @@ fn reqwest_default_route_preserves_transport_proxy_behavior() {
|
||||
assert_eq!(route, OutboundProxyRoute::TransportDefault);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn macos_proxy_configuration_rejects_invalid_destination() {
|
||||
assert_eq!(
|
||||
macos_system_proxy_configuration("not a valid destination"),
|
||||
MacosSystemProxyConfiguration::Unavailable
|
||||
);
|
||||
}
|
||||
|
||||
impl EnvSource for MapEnv {
|
||||
fn var(&self, key: &str) -> Option<String> {
|
||||
self.values.get(key).cloned()
|
||||
|
||||
@@ -25,6 +25,7 @@ use crate::HttpClientBuilder;
|
||||
use crate::HttpClientFactory;
|
||||
use crate::OutboundProxyPolicy;
|
||||
use crate::OutboundProxyRoute;
|
||||
use crate::RouteFailureClass;
|
||||
use crate::route_aware_redirect::MAX_REDIRECTS;
|
||||
use crate::route_aware_redirect::insert_referer;
|
||||
use crate::route_aware_redirect::is_redirect;
|
||||
@@ -101,6 +102,52 @@ pub enum RouteAwareRequestError {
|
||||
}
|
||||
|
||||
impl RouteAwareRequestError {
|
||||
/// Classifies transport, proxy, and certificate failures without exposing request details.
|
||||
pub fn failure_class(&self) -> Option<RouteFailureClass> {
|
||||
if self.is_timeout() {
|
||||
return Some(RouteFailureClass::ConnectTimeout);
|
||||
}
|
||||
if self.status() == Some(StatusCode::PROXY_AUTHENTICATION_REQUIRED) {
|
||||
return Some(RouteFailureClass::ProxyAuthenticationRequired);
|
||||
}
|
||||
if let Self::Route(RouteAwareClientPoolError::Resolve(error)) = self
|
||||
&& let Some(source) = error.get_ref()
|
||||
&& source.is::<rustls::Error>()
|
||||
{
|
||||
return Some(RouteFailureClass::TlsError);
|
||||
}
|
||||
|
||||
let mut source: Option<&(dyn std::error::Error + 'static)> = Some(self);
|
||||
while let Some(error) = source {
|
||||
if error.downcast_ref::<rustls::Error>().is_some()
|
||||
|| error.downcast_ref::<native_tls::Error>().is_some()
|
||||
{
|
||||
return Some(RouteFailureClass::TlsError);
|
||||
}
|
||||
if error.to_string() == "tunnel error: proxy authorization required" {
|
||||
return Some(RouteFailureClass::ProxyAuthenticationRequired);
|
||||
}
|
||||
source = error.source();
|
||||
}
|
||||
|
||||
match self {
|
||||
Self::Route(RouteAwareClientPoolError::Build(
|
||||
BuildRouteAwareHttpClientError::CustomCa(_),
|
||||
)) => Some(RouteFailureClass::TlsError),
|
||||
Self::Route(RouteAwareClientPoolError::Build(
|
||||
BuildRouteAwareHttpClientError::InvalidProxyConfig { .. },
|
||||
)) => Some(RouteFailureClass::InvalidProxyConfig),
|
||||
Self::Route(RouteAwareClientPoolError::Resolve(_)) => {
|
||||
Some(RouteFailureClass::ProxyResolutionUnavailable)
|
||||
}
|
||||
Self::Request(_)
|
||||
| Self::Build(_)
|
||||
| Self::UnsupportedRedirectScheme(_)
|
||||
| Self::TooManyRedirects
|
||||
| Self::Timeout => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> Option<StatusCode> {
|
||||
match self {
|
||||
Self::Request(error) => error.status(),
|
||||
|
||||
@@ -19,6 +19,96 @@ use tracing_subscriber::layer::SubscriberExt;
|
||||
use super::*;
|
||||
use crate::OutboundProxyPolicy;
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_failures_classify_real_untrusted_certificate_handshakes() {
|
||||
codex_utils_rustls_provider::ensure_rustls_crypto_provider();
|
||||
let certificate = rcgen::generate_simple_self_signed(vec!["localhost".to_string()])
|
||||
.expect("self-signed certificate should generate");
|
||||
let private_key = rustls::pki_types::PrivateKeyDer::Pkcs8(
|
||||
rustls::pki_types::PrivatePkcs8KeyDer::from(certificate.signing_key.serialize_der()),
|
||||
);
|
||||
let configuration = Arc::new(
|
||||
rustls::ServerConfig::builder()
|
||||
.with_no_client_auth()
|
||||
.with_single_cert(vec![certificate.cert.der().clone()], private_key)
|
||||
.expect("TLS server should be configured"),
|
||||
);
|
||||
let listener = TcpListener::bind("127.0.0.1:0").expect("TLS server should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("TLS server should have an address");
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("TLS server should accept");
|
||||
stream
|
||||
.set_read_timeout(Some(Duration::from_secs(3)))
|
||||
.expect("TLS handshake timeout");
|
||||
let mut connection = rustls::ServerConnection::new(configuration)
|
||||
.expect("TLS server connection should be created");
|
||||
let _ = connection.complete_io(&mut stream);
|
||||
});
|
||||
let pool = RouteAwareClientPool::new_without_request_logging(
|
||||
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||
ClientRouteClass::Api,
|
||||
);
|
||||
|
||||
let request = pool
|
||||
.get(format!("https://localhost:{}/", address.port()))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.request
|
||||
.expect("TLS request should build");
|
||||
let error = pool
|
||||
.send_with_resolver(request, |_| async { Ok(OutboundProxyRoute::Direct) })
|
||||
.await
|
||||
.expect_err("self-signed server certificate must not be trusted");
|
||||
drop(std::net::TcpStream::connect(address));
|
||||
server.join().expect("TLS server should finish");
|
||||
|
||||
assert_eq!(
|
||||
error.failure_class(),
|
||||
Some(RouteFailureClass::TlsError),
|
||||
"unexpected certificate error: {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_failures_classify_https_proxy_authentication_challenges() {
|
||||
let (address, proxy) = spawn_response_server(vec![
|
||||
"HTTP/1.1 407 Proxy Authentication Required\r\n\
|
||||
Proxy-Authenticate: Basic realm=\"codex\"\r\n\
|
||||
Content-Length: 0\r\n\
|
||||
Connection: close\r\n\r\n"
|
||||
.to_string(),
|
||||
]);
|
||||
let pool = RouteAwareClientPool::new_without_request_logging(
|
||||
HttpClientFactory::new(OutboundProxyPolicy::RespectSystemProxy),
|
||||
ClientRouteClass::Api,
|
||||
);
|
||||
let mut request = reqwest::Request::new(
|
||||
Method::GET,
|
||||
reqwest::Url::parse("https://example.com/").expect("request URL should parse"),
|
||||
);
|
||||
*request.timeout_mut() = Some(Duration::from_secs(3));
|
||||
|
||||
let error = pool
|
||||
.send_with_resolver(request, move |_| async move {
|
||||
Ok(OutboundProxyRoute::Proxy {
|
||||
url: format!("http://{address}"),
|
||||
no_proxy: None,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.expect_err("HTTPS proxy challenge should reject the CONNECT request");
|
||||
let requests = proxy.join().expect("proxy fixture should finish");
|
||||
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert!(requests[0].starts_with("CONNECT example.com:443 HTTP/1.1\r\n"));
|
||||
assert_eq!(
|
||||
error.failure_class(),
|
||||
Some(RouteFailureClass::ProxyAuthenticationRequired),
|
||||
"unexpected HTTPS proxy error: {error:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_builder_debug_redacts_url_secrets() {
|
||||
let pool = RouteAwareClientPool::new(
|
||||
|
||||
Reference in New Issue
Block a user