Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions codex-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

245 changes: 152 additions & 93 deletions codex-rs/cli/src/doctor.rs

Large diffs are not rendered by default.

167 changes: 167 additions & 0 deletions codex-rs/cli/src/doctor/network.rs
Original file line number Diff line number Diff line change
@@ -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;
37 changes: 37 additions & 0 deletions codex-rs/cli/src/doctor/network_tests.rs
Original file line number Diff line number Diff line change
@@ -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)")
);
}
112 changes: 112 additions & 0 deletions codex-rs/cli/tests/doctor_enterprise_network.rs
Original file line number Diff line number Diff line change
@@ -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")
}
1 change: 1 addition & 0 deletions codex-rs/http-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
4 changes: 4 additions & 0 deletions codex-rs/http-client/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 20 additions & 0 deletions codex-rs/http-client/src/outbound_proxy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading