mirror of
https://github.com/openai/codex.git
synced 2026-11-12 22:42:36 +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:
1 parent
772e88c8ae
commit
c8ddb210d2
12 files changed
+677
-93
No files matched your search
Generated
+1
@@ -3379,6 +3379,7 @@ dependencies = [
|
|||||||
"codex-utils-rustls-provider",
|
"codex-utils-rustls-provider",
|
||||||
"futures",
|
"futures",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
|
"native-tls",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
"pretty_assertions",
|
"pretty_assertions",
|
||||||
|
|||||||
+152
-93
@@ -39,6 +39,10 @@ use codex_core::config::ConfigBuilder;
|
|||||||
use codex_core::config::ConfigOverrides;
|
use codex_core::config::ConfigOverrides;
|
||||||
use codex_core::config::find_codex_home;
|
use codex_core::config::find_codex_home;
|
||||||
use codex_features::FEATURES;
|
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::CodexPackageLayout;
|
||||||
use codex_install_context::InstallContext;
|
use codex_install_context::InstallContext;
|
||||||
use codex_install_context::InstallMethod;
|
use codex_install_context::InstallMethod;
|
||||||
@@ -63,12 +67,14 @@ use codex_tui::Cli as TuiCli;
|
|||||||
use codex_utils_cli::CliConfigOverrides;
|
use codex_utils_cli::CliConfigOverrides;
|
||||||
use http::HeaderMap;
|
use http::HeaderMap;
|
||||||
use http::HeaderValue;
|
use http::HeaderValue;
|
||||||
|
use http::Method;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use supports_color::Stream;
|
use supports_color::Stream;
|
||||||
|
|
||||||
mod background;
|
mod background;
|
||||||
mod disk;
|
mod disk;
|
||||||
mod git;
|
mod git;
|
||||||
|
mod network;
|
||||||
mod output;
|
mod output;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
@@ -376,6 +382,11 @@ async fn build_report(
|
|||||||
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
|
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
|
||||||
let auth_manager = auth_manager_result.as_ref().ok().cloned();
|
let auth_manager = auth_manager_result.as_ref().ok().cloned();
|
||||||
let reachability_plan = provider_reachability_plan(config);
|
let reachability_plan = provider_reachability_plan(config);
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let reachability_url = reachability_plan
|
||||||
|
.endpoints
|
||||||
|
.first()
|
||||||
|
.map(|endpoint| endpoint.url.clone());
|
||||||
let (
|
let (
|
||||||
config_check,
|
config_check,
|
||||||
auth_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("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(
|
run_async_check(
|
||||||
"websocket",
|
"websocket",
|
||||||
progress.clone(),
|
progress.clone(),
|
||||||
@@ -449,6 +462,12 @@ async fn build_report(
|
|||||||
provider_reachability_check(reachability_plan),
|
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([
|
checks.extend([
|
||||||
config_check,
|
config_check,
|
||||||
auth_check,
|
auth_check,
|
||||||
@@ -488,7 +507,11 @@ async fn build_report(
|
|||||||
.remediation("Fix the reported config error, then rerun codex doctor.")
|
.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 {
|
async {
|
||||||
run_sync_check("terminal", progress.clone(), || {
|
run_sync_check("terminal", progress.clone(), || {
|
||||||
terminal_check(command.no_color)
|
terminal_check(command.no_color)
|
||||||
@@ -1472,42 +1495,6 @@ fn stored_auth_issues(
|
|||||||
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>) {
|
fn push_proxy_env_details(details: &mut Vec<String>) {
|
||||||
let present_proxy_vars = PROXY_ENV_VARS
|
let present_proxy_vars = PROXY_ENV_VARS
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2579,6 +2566,7 @@ fn fallback_state_check() -> DoctorCheck {
|
|||||||
struct ReachabilityPlan {
|
struct ReachabilityPlan {
|
||||||
description: String,
|
description: String,
|
||||||
endpoints: Vec<ReachabilityEndpoint>,
|
endpoints: Vec<ReachabilityEndpoint>,
|
||||||
|
http_client_factory: HttpClientFactory,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
@@ -2616,10 +2604,12 @@ fn provider_reachability_plan(config: &Config) -> ReachabilityPlan {
|
|||||||
.flatten();
|
.flatten();
|
||||||
let mode = provider_auth_reachability_mode_from_auth(
|
let mode = provider_auth_reachability_mode_from_auth(
|
||||||
config.model_provider.requires_openai_auth,
|
config.model_provider.requires_openai_auth,
|
||||||
|
config.model_provider.env_key.as_deref(),
|
||||||
|
config.model_provider.base_url.as_deref(),
|
||||||
env_var_present,
|
env_var_present,
|
||||||
stored_auth.as_ref(),
|
stored_auth.as_ref(),
|
||||||
);
|
);
|
||||||
provider_reachability_plan_from_parts(
|
let mut plan = provider_reachability_plan_from_parts(
|
||||||
mode,
|
mode,
|
||||||
&config.model_provider_id,
|
&config.model_provider_id,
|
||||||
&config.model_provider.name,
|
&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.query_params.as_ref(),
|
||||||
config.model_provider.is_amazon_bedrock(),
|
config.model_provider.is_amazon_bedrock(),
|
||||||
&config.chatgpt_base_url,
|
&config.chatgpt_base_url,
|
||||||
)
|
);
|
||||||
|
plan.http_client_factory = config.http_client_factory();
|
||||||
|
plan
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_reachability_plan() -> ReachabilityPlan {
|
fn default_reachability_plan() -> ReachabilityPlan {
|
||||||
@@ -2644,13 +2636,19 @@ fn default_reachability_plan() -> ReachabilityPlan {
|
|||||||
|
|
||||||
fn provider_auth_reachability_mode_from_auth(
|
fn provider_auth_reachability_mode_from_auth(
|
||||||
requires_openai_auth: bool,
|
requires_openai_auth: bool,
|
||||||
|
provider_env_key: Option<&str>,
|
||||||
|
provider_base_url: Option<&str>,
|
||||||
env_var_present: impl Fn(&str) -> bool,
|
env_var_present: impl Fn(&str) -> bool,
|
||||||
stored_auth: Option<&AuthDotJson>,
|
stored_auth: Option<&AuthDotJson>,
|
||||||
) -> ProviderAuthReachabilityMode {
|
) -> ProviderAuthReachabilityMode {
|
||||||
if !requires_openai_auth {
|
if !requires_openai_auth {
|
||||||
return ProviderAuthReachabilityMode::NotRequired;
|
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;
|
return ProviderAuthReachabilityMode::ApiKey;
|
||||||
}
|
}
|
||||||
if env_var_present(CODEX_ACCESS_TOKEN_ENV_VAR) {
|
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)
|
should_probe_models_route(provider_name, url, is_amazon_bedrock)
|
||||||
.then(|| provider_url_for_path(url, "models", provider_query_params))
|
.then(|| provider_url_for_path(url, "models", provider_query_params))
|
||||||
});
|
});
|
||||||
let endpoints = match mode {
|
let endpoints = match (mode, provider_base_url) {
|
||||||
ProviderAuthReachabilityMode::ApiKey => vec![ReachabilityEndpoint {
|
(ProviderAuthReachabilityMode::ApiKey, _) | (_, Some(_)) => vec![ReachabilityEndpoint {
|
||||||
label: format!("{provider_id} API"),
|
label: format!("{provider_id} API"),
|
||||||
url: provider_base_url
|
url: provider_url_for_path(
|
||||||
.unwrap_or("https://api.openai.com/v1")
|
provider_base_url.unwrap_or("https://api.openai.com/v1"),
|
||||||
.to_string(),
|
"responses",
|
||||||
|
provider_query_params,
|
||||||
|
),
|
||||||
required: true,
|
required: true,
|
||||||
route_probe_url: provider_route_probe_url,
|
route_probe_url: provider_route_probe_url,
|
||||||
}],
|
}],
|
||||||
ProviderAuthReachabilityMode::Chatgpt => vec![ReachabilityEndpoint {
|
(ProviderAuthReachabilityMode::Chatgpt, None) => vec![ReachabilityEndpoint {
|
||||||
label: "ChatGPT".to_string(),
|
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,
|
required: true,
|
||||||
route_probe_url: None,
|
route_probe_url: None,
|
||||||
}],
|
}],
|
||||||
ProviderAuthReachabilityMode::NotRequired => provider_base_url
|
(ProviderAuthReachabilityMode::NotRequired, None) => Vec::new(),
|
||||||
.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(),
|
|
||||||
};
|
};
|
||||||
ReachabilityPlan {
|
ReachabilityPlan {
|
||||||
description: mode.description().to_string(),
|
description: mode.description().to_string(),
|
||||||
endpoints,
|
endpoints,
|
||||||
|
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2765,15 +2757,20 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
|
|||||||
.details(details);
|
.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 failures = Vec::new();
|
||||||
let mut optional_failures = Vec::new();
|
let mut optional_failures = Vec::new();
|
||||||
let mut route_failures = Vec::new();
|
let mut route_failures = Vec::new();
|
||||||
let mut route_warnings = Vec::new();
|
let mut route_warnings = Vec::new();
|
||||||
let mut issues = Vec::new();
|
let mut issues = Vec::new();
|
||||||
for endpoint in plan.endpoints {
|
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!(
|
Ok(status) => details.push(format!(
|
||||||
"{} base URL: {} reachable ({status})",
|
"{} inference URL: {} reachable (HTTP {status})",
|
||||||
endpoint.label, endpoint.url
|
endpoint.label, endpoint.url
|
||||||
)),
|
)),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
@@ -2783,7 +2780,7 @@ async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
|
|||||||
"optional"
|
"optional"
|
||||||
};
|
};
|
||||||
details.push(format!(
|
details.push(format!(
|
||||||
"{} base URL: {} {err} ({requirement})",
|
"{} inference URL: {} {err} ({requirement})",
|
||||||
endpoint.label, endpoint.url
|
endpoint.label, endpoint.url
|
||||||
));
|
));
|
||||||
if endpoint.required {
|
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 {
|
let Some(route_probe_url) = endpoint.route_probe_url.as_deref() else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
match provider_route_probe_url(route_probe_url).await {
|
match provider_route_probe_url(&client, route_probe_url).await {
|
||||||
RouteProbeOutcome::Ok(status) => {
|
RouteProbeOutcome::Ok(status) => {
|
||||||
details.push(format!(
|
details.push(format!(
|
||||||
"{} route probe: {route_probe_url} route exists ({status})",
|
"{} route probe: {route_probe_url} route exists ({status})",
|
||||||
@@ -2876,8 +2873,8 @@ enum RouteProbeOutcome {
|
|||||||
TransportError(String),
|
TransportError(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn provider_route_probe_url(url: &str) -> RouteProbeOutcome {
|
async fn provider_route_probe_url(client: &RouteAwareClientPool, url: &str) -> RouteProbeOutcome {
|
||||||
match http_get_probe_status_with_timeout(url, Duration::from_secs(3)).await {
|
match network::probe_status(client, url, Method::GET, default_headers()).await {
|
||||||
Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => {
|
Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => {
|
||||||
RouteProbeOutcome::Ok(format!("HTTP {status}"))
|
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> {
|
async fn mcp_http_probe_url(url: &str) -> Result<String, String> {
|
||||||
mcp_http_probe_url_with_timeout(url, Duration::from_secs(3)).await
|
mcp_http_probe_url_with_timeout(url, Duration::from_secs(3)).await
|
||||||
}
|
}
|
||||||
@@ -3652,6 +3645,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
provider_auth_reachability_mode_from_auth(
|
provider_auth_reachability_mode_from_auth(
|
||||||
/*requires_openai_auth*/ true,
|
/*requires_openai_auth*/ true,
|
||||||
|
/*provider_env_key*/ None,
|
||||||
|
/*provider_base_url*/ None,
|
||||||
|_| false,
|
|_| false,
|
||||||
Some(&api_key_auth),
|
Some(&api_key_auth),
|
||||||
),
|
),
|
||||||
@@ -3660,11 +3655,49 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
provider_auth_reachability_mode_from_auth(
|
provider_auth_reachability_mode_from_auth(
|
||||||
/*requires_openai_auth*/ true,
|
/*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,
|
/*stored_auth*/ None,
|
||||||
),
|
),
|
||||||
ProviderAuthReachabilityMode::ApiKey
|
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]
|
#[test]
|
||||||
@@ -3683,10 +3716,11 @@ mod tests {
|
|||||||
description: "provider auth".to_string(),
|
description: "provider auth".to_string(),
|
||||||
endpoints: vec![ReachabilityEndpoint {
|
endpoints: vec![ReachabilityEndpoint {
|
||||||
label: "azure API".to_string(),
|
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,
|
required: true,
|
||||||
route_probe_url: None,
|
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() {
|
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())]);
|
let query_params = HashMap::from([("api-version".to_string(), "2026-01-01".to_string())]);
|
||||||
|
|
||||||
assert_eq!(
|
for (mode, description) in [
|
||||||
provider_reachability_plan_from_parts(
|
(ProviderAuthReachabilityMode::NotRequired, "provider auth"),
|
||||||
ProviderAuthReachabilityMode::NotRequired,
|
(ProviderAuthReachabilityMode::Chatgpt, "ChatGPT auth"),
|
||||||
"custom",
|
] {
|
||||||
"Custom",
|
assert_eq!(
|
||||||
Some("https://example.com/openai/v1/"),
|
provider_reachability_plan_from_parts(
|
||||||
Some(&query_params),
|
mode,
|
||||||
/*is_amazon_bedrock*/ false,
|
"custom",
|
||||||
"https://chatgpt.com/backend-api/",
|
"Custom",
|
||||||
),
|
Some("https://example.com/openai/v1/"),
|
||||||
ReachabilityPlan {
|
Some(&query_params),
|
||||||
description: "provider auth".to_string(),
|
/*is_amazon_bedrock*/ false,
|
||||||
endpoints: vec![ReachabilityEndpoint {
|
"https://chatgpt.com/backend-api/",
|
||||||
label: "custom API".to_string(),
|
),
|
||||||
url: "https://example.com/openai/v1/".to_string(),
|
ReachabilityPlan {
|
||||||
required: true,
|
description: description.to_string(),
|
||||||
route_probe_url: Some(
|
endpoints: vec![ReachabilityEndpoint {
|
||||||
"https://example.com/openai/v1/models?api-version=2026-01-01".to_string()
|
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]
|
#[test]
|
||||||
@@ -3750,13 +3794,26 @@ mod tests {
|
|||||||
plan.endpoints,
|
plan.endpoints,
|
||||||
vec![ReachabilityEndpoint {
|
vec![ReachabilityEndpoint {
|
||||||
label: "openai API".to_string(),
|
label: "openai API".to_string(),
|
||||||
url: "https://api.openai.com/v1".to_string(),
|
url: "https://api.openai.com/v1/responses".to_string(),
|
||||||
required: true,
|
required: true,
|
||||||
route_probe_url: Some("https://api.openai.com/v1/models".to_string()),
|
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]
|
#[test]
|
||||||
fn provider_reachability_outcome_reports_required_failures() {
|
fn provider_reachability_outcome_reports_required_failures() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -3892,7 +3949,9 @@ mod tests {
|
|||||||
.expect("write response");
|
.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");
|
server.join().expect("probe server thread should finish");
|
||||||
|
|
||||||
assert_eq!(status, Ok("HTTP 405".to_string()));
|
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 }
|
codex-utils-rustls-provider = { workspace = true }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
|
native-tls = "0.2"
|
||||||
opentelemetry = { workspace = true }
|
opentelemetry = { workspace = true }
|
||||||
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
|
reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots", "stream"] }
|
||||||
rustls = { workspace = true }
|
rustls = { workspace = true }
|
||||||
|
|||||||
@@ -34,11 +34,15 @@ pub use crate::error::TransportError;
|
|||||||
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
|
pub use crate::outbound_proxy::BuildRouteAwareHttpClientError;
|
||||||
pub use crate::outbound_proxy::ClientRouteClass;
|
pub use crate::outbound_proxy::ClientRouteClass;
|
||||||
pub use crate::outbound_proxy::HttpClientFactory;
|
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::OutboundProxyPolicy;
|
||||||
pub use crate::outbound_proxy::OutboundProxyRoute;
|
pub use crate::outbound_proxy::OutboundProxyRoute;
|
||||||
pub use crate::outbound_proxy::RouteFailureClass;
|
pub use crate::outbound_proxy::RouteFailureClass;
|
||||||
#[doc(hidden)]
|
#[doc(hidden)]
|
||||||
pub use crate::outbound_proxy::cache_system_proxy_route_for_test;
|
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::EncodedJsonBody;
|
||||||
pub use crate::request::PreparedRequestBody;
|
pub use crate::request::PreparedRequestBody;
|
||||||
pub use crate::request::Request;
|
pub use crate::request::Request;
|
||||||
|
|||||||
@@ -103,6 +103,26 @@ pub enum OutboundProxyPolicy {
|
|||||||
RespectSystemProxy,
|
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.
|
/// Resolved proxy route for a concrete outbound destination.
|
||||||
///
|
///
|
||||||
/// `TransportDefault` preserves the underlying transport behavior only when system-proxy support
|
/// `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::Duration;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use super::MacosSystemProxyConfiguration;
|
||||||
use super::RequestOrigin;
|
use super::RequestOrigin;
|
||||||
use super::RouteFailureClass;
|
use super::RouteFailureClass;
|
||||||
use super::SystemProxyDecision;
|
use super::SystemProxyDecision;
|
||||||
@@ -102,6 +103,39 @@ pub(super) fn resolve(request_url: &str, origin: &RequestOrigin) -> SystemProxyD
|
|||||||
proxy_array_decision(&proxies, &target_url, origin)
|
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>> {
|
fn system_proxy_settings() -> Option<CFDictionary<CFString, CFType>> {
|
||||||
let store = SCDynamicStoreBuilder::new("Codex").build()?;
|
let store = SCDynamicStoreBuilder::new("Codex").build()?;
|
||||||
store.get_proxies()
|
store.get_proxies()
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ fn spawn_http_listener(
|
|||||||
Err(error) => panic!("HTTP listener should accept: {error}"),
|
Err(error) => panic!("HTTP listener should accept: {error}"),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
stream
|
||||||
|
.set_nonblocking(false)
|
||||||
|
.expect("HTTP stream should become blocking");
|
||||||
stream
|
stream
|
||||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||||
.expect("HTTP stream should get a read timeout");
|
.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);
|
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 {
|
impl EnvSource for MapEnv {
|
||||||
fn var(&self, key: &str) -> Option<String> {
|
fn var(&self, key: &str) -> Option<String> {
|
||||||
self.values.get(key).cloned()
|
self.values.get(key).cloned()
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use crate::HttpClientBuilder;
|
|||||||
use crate::HttpClientFactory;
|
use crate::HttpClientFactory;
|
||||||
use crate::OutboundProxyPolicy;
|
use crate::OutboundProxyPolicy;
|
||||||
use crate::OutboundProxyRoute;
|
use crate::OutboundProxyRoute;
|
||||||
|
use crate::RouteFailureClass;
|
||||||
use crate::route_aware_redirect::MAX_REDIRECTS;
|
use crate::route_aware_redirect::MAX_REDIRECTS;
|
||||||
use crate::route_aware_redirect::insert_referer;
|
use crate::route_aware_redirect::insert_referer;
|
||||||
use crate::route_aware_redirect::is_redirect;
|
use crate::route_aware_redirect::is_redirect;
|
||||||
@@ -101,6 +102,52 @@ pub enum RouteAwareRequestError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub fn status(&self) -> Option<StatusCode> {
|
||||||
match self {
|
match self {
|
||||||
Self::Request(error) => error.status(),
|
Self::Request(error) => error.status(),
|
||||||
|
|||||||
@@ -19,6 +19,96 @@ use tracing_subscriber::layer::SubscriberExt;
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::OutboundProxyPolicy;
|
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]
|
#[test]
|
||||||
fn request_builder_debug_redacts_url_secrets() {
|
fn request_builder_debug_redacts_url_secrets() {
|
||||||
let pool = RouteAwareClientPool::new(
|
let pool = RouteAwareClientPool::new(
|
||||||
|
|||||||
Reference in new issue
Block a user