Add codex doctor --network-only for focused network diagnostics (#57606)

## What changed

Limit `--network-only` reports to effective configuration, network environment, provider HTTP reachability, and Responses WebSocket checks, skipping unrelated diagnostics such as database and MCP scans. Preserve configuration warnings and report configuration load timing.

If configuration loading fails, report the redacted configuration error and stop without probing guessed network targets.

## Testing

Add integration tests for check selection, recoverable configuration warnings, and configuration failures that send no probe requests or expose configuration values. Add snapshots for human-readable success and failure reports.

GitOrigin-RevId: 997616b6802813df751725f9538541c65bad7d71
This commit is contained in:
dob-oai
2026-10-28 00:37:15 +00:00
committed by copyberry
parent e8e6a922c0
commit 939f5decb2
5 changed files with 381 additions and 38 deletions
+53 -38
View File
@@ -154,8 +154,9 @@ const NARROW_TERMINAL_ROWS: u16 = 24;
/// Options for building a local Codex diagnostic report.
///
/// The command always runs the full diagnostic set. Human output includes
/// detailed diagnostics by default; --summary keeps the terminal output compact.
/// The command runs the full diagnostic set unless `--network-only` is selected.
/// Human output includes detailed diagnostics by default; --summary keeps the terminal output
/// compact.
#[derive(Debug, Parser)]
pub struct DoctorCommand {
/// Internal isolated filesystem probe; exits before loading configuration.
@@ -166,6 +167,10 @@ pub struct DoctorCommand {
#[arg(long, default_value_t = false)]
json: bool,
/// Run config, network environment, provider HTTP, and Responses WebSocket checks.
#[arg(long, default_value_t = false)]
network_only: bool,
/// Limit database integrity scans when collecting a feedback attachment.
#[arg(long, hide = true, default_value_t = false)]
feedback: bool,
@@ -358,6 +363,12 @@ async fn build_report(
arg0_paths: &Arg0DispatchPaths,
) -> DoctorReport {
let progress = doctor_progress(command.json);
if command.network_only {
let checks =
network::checks(root_config_overrides, interactive, arg0_paths, &progress).await;
progress.settle();
return report_from_checks(checks);
}
let mut checks = Vec::new();
checks.push(run_sync_check("system", progress.clone(), system_check));
checks.push(run_async_check("endpoint protection", progress.clone(), security::check()).await);
@@ -525,42 +536,7 @@ async fn build_report(
state_check,
reachability_check,
) = tokio::join!(
async {
run_sync_check("config", progress.clone(), || {
let check = DoctorCheck::new(
"config.load",
"config",
CheckStatus::Fail,
"config could not be loaded",
)
.remediation("Fix the reported config error, then rerun codex doctor.");
// Error messages can echo config values. Report only typed metadata,
// including errors wrapped by io::Error, whose source skips the wrapper.
let config_error = err.chain().find_map(|cause| {
cause.downcast_ref::<ConfigLoadError>().or_else(|| {
cause
.downcast_ref::<std::io::Error>()?
.get_ref()?
.downcast_ref::<ConfigLoadError>()
})
});
if let Some(error) = config_error {
let error = error.config_error();
return check
.detail("error: invalid configuration")
.detail(format!("file: {}", error.path.display()))
.detail(format!("line: {}", error.range.start.line))
.detail(format!("column: {}", error.range.start.column));
}
let io_error = err
.chain()
.find_map(|cause| cause.downcast_ref::<std::io::Error>());
match io_error {
Some(error) => check.detail(format!("error: {}", error.kind())),
None => check.detail("error: configuration load failed"),
}
})
},
async { run_sync_check("config", progress.clone(), || config_load_failure(err)) },
async {
run_sync_check("network", progress.clone(), || {
network::check(/*config*/ None)
@@ -603,6 +579,10 @@ async fn build_report(
progress.settle();
report_from_checks(checks)
}
fn report_from_checks(checks: Vec<DoctorCheck>) -> DoctorReport {
let overall_status = overall_status(&checks);
DoctorReport {
schema_version: 1,
@@ -613,6 +593,41 @@ async fn build_report(
}
}
fn config_load_failure(err: &anyhow::Error) -> DoctorCheck {
let check = DoctorCheck::new(
"config.load",
"config",
CheckStatus::Fail,
"config could not be loaded",
)
.remediation("Fix the reported config error, then rerun codex doctor.");
// Error messages can echo config values. Report only typed metadata,
// including errors wrapped by io::Error, whose source skips the wrapper.
let config_error = err.chain().find_map(|cause| {
cause.downcast_ref::<ConfigLoadError>().or_else(|| {
cause
.downcast_ref::<std::io::Error>()?
.get_ref()?
.downcast_ref::<ConfigLoadError>()
})
});
if let Some(error) = config_error {
let error = error.config_error();
return check
.detail("error: invalid configuration")
.detail(format!("file: {}", error.path.display()))
.detail(format!("line: {}", error.range.start.line))
.detail(format!("column: {}", error.range.start.column));
}
let io_error = err
.chain()
.find_map(|cause| cause.downcast_ref::<std::io::Error>());
match io_error {
Some(error) => check.detail(format!("error: {}", error.kind())),
None => check.detail("error: configuration load failed"),
}
}
async fn load_config(
mut root_config_overrides: CliConfigOverrides,
interactive: &TuiCli,
+56
View File
@@ -1,7 +1,10 @@
use std::env;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use codex_arg0::Arg0DispatchPaths;
use codex_core::config::Config;
#[cfg(target_os = "macos")]
use codex_http_client::MacosSystemProxyConfiguration;
@@ -11,14 +14,67 @@ 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 codex_tui::Cli as TuiCli;
use codex_utils_cli::CliConfigOverrides;
use http::HeaderMap;
use http::Method;
use super::CheckStatus;
use super::DoctorCheck;
use super::DoctorProgress;
use super::push_proxy_env_details;
use super::read_probe_file;
pub(super) async fn checks(
overrides: CliConfigOverrides,
interactive: &TuiCli,
arg0_paths: &Arg0DispatchPaths,
progress: &Arc<dyn DoctorProgress>,
) -> Vec<DoctorCheck> {
progress.begin("config");
let config_started = Instant::now();
let config = match super::load_config(overrides, interactive, arg0_paths).await {
Ok(config) => config,
Err(error) => {
let check = super::config_load_failure(&error)
.detail("network diagnostics: effective network configuration is unavailable");
progress.finish("config", check.status);
return vec![check];
}
};
let config_duration = config_started.elapsed();
let mut config_check = super::config_check(&config).detail(format!(
"configuration load ms: {}",
config_duration.as_millis()
));
config_check.duration_ms = config_duration.as_millis().try_into().unwrap_or(u64::MAX);
progress.finish("config", config_check.status);
let auth_manager = codex_login::AuthManager::shared_from_config(
&config, /*enable_codex_api_key_env*/ true,
)
.await
.ok();
let plan = super::provider_reachability_plan(&config);
#[cfg(target_os = "macos")]
let url = plan.endpoints.first().map(|endpoint| endpoint.url.clone());
let (network, websocket, reachability) = tokio::join!(
async { super::run_sync_check("network", progress.clone(), || check(Some(&config))) },
super::run_async_check(
"websocket",
progress.clone(),
super::websocket_reachability_check(&config, auth_manager)
),
super::run_async_check(
"provider reachability",
progress.clone(),
super::provider_reachability_check(plan)
),
);
#[cfg(target_os = "macos")]
let reachability = with_system_proxy_remediation(reachability, &config, url.as_deref());
vec![config_check, network, websocket, reachability]
}
pub(super) fn check(config: Option<&Config>) -> DoctorCheck {
let mut details = Vec::new();
push_proxy_env_details(&mut details);
@@ -8,7 +8,10 @@ use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
use codex_config::types::AuthCredentialsStoreMode;
use codex_state::SqliteConfig;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
use tempfile::TempDir;
@@ -18,6 +21,232 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
async fn local_provider() -> Result<(MockServer, TempDir)> {
let server = MockServer::start().await;
for (request_method, request_path, status) in
[("HEAD", "/v1/responses", 200), ("GET", "/v1/models", 401)]
{
Mock::given(method(request_method))
.and(path(request_path))
.respond_with(ResponseTemplate::new(status).insert_header("x-request-id", "fixture-id"))
.mount(&server)
.await;
}
let home = TempDir::new()?;
std::fs::write(
home.path().join("config.toml"),
format!(
"cli_auth_credentials_store = \"ephemeral\"\ncheck_for_update_on_startup = false\nmodel_provider = \"local\"\n[model_providers.local]\nname = \"local\"\nbase_url = \"{}/v1\"\nwire_api = \"responses\"\n",
server.uri()
),
)?;
Ok((server, home))
}
fn local_command(home: &TempDir) -> Result<Command> {
let mut command = Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
command
.current_dir(home.path())
.env("CODEX_HOME", home.path())
.env("NO_PROXY", "127.0.0.1,localhost")
.env("no_proxy", "127.0.0.1,localhost")
.stdin(Stdio::null());
for name in [
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"CODEX_ACCESS_TOKEN",
"CODEX_API_KEY",
"OPENAI_API_KEY",
"CODEX_CA_CERTIFICATE",
"SSL_CERT_FILE",
"OPENAI_WORKLOAD_IDENTITY_CONTEXT",
"OPENAI_IDENTITY_TOKEN_FILE",
"OPENAI_FEDERATION_RULE_ID",
] {
command.env_remove(name);
}
Ok(command)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_only_selects_checks_without_database_or_mcp_scans() -> Result<()> {
let (server, home) = local_provider().await?;
Mock::given(path("/unrelated-mcp"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&server)
.await;
let sqlite =
SqliteConfig::new_for_testing(AbsolutePathBuf::try_from(home.path().to_path_buf())?);
let database = sqlite.logs_db_path();
std::fs::write(&database, "not a SQLite database")?;
let mcp_url = format!("{}/unrelated-mcp", server.uri());
let output = local_command(&home)?
.args(["doctor", "--network-only", "--json", "--all", "--feedback"])
.arg("-c")
.arg(format!("mcp_servers.unrelated.url={mcp_url:?}"))
.output()?;
let report: Value = serde_json::from_slice(&output.stdout)?;
assert!(output.status.success(), "{report:#}");
let checks = report["checks"].as_object().unwrap();
assert_eq!(
checks.keys().map(String::as_str).collect::<Vec<_>>(),
vec![
"config.load",
"network.env",
"network.provider_reachability",
"network.websocket_reachability",
]
);
assert_eq!(std::fs::read_to_string(database)?, "not a SQLite database");
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_only_reports_recoverable_config_warnings_and_effective_config() -> Result<()> {
let (_server, home) = local_provider().await?;
let baseline_output = local_command(&home)?
.args(["doctor", "--network-only", "--json"])
.output()?;
let baseline_report: Value = serde_json::from_slice(&baseline_output.stdout)?;
assert!(baseline_output.status.success(), "{baseline_report:#}");
let baseline_warning_count =
baseline_report["checks"]["config.load"]["details"]["startup warnings"]
.as_str()
.unwrap_or("0")
.parse::<usize>()?;
let config_path = home.path().join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
std::fs::write(
config_path,
format!("{config}\n[otel.span_attributes]\n\"\" = \"missing-key\"\n"),
)?;
let output = local_command(&home)?
.args(["doctor", "--network-only", "--json"])
.output()?;
let report: Value = serde_json::from_slice(&output.stdout)?;
assert!(output.status.success(), "{report:#}");
let config_check = &report["checks"]["config.load"];
let warning_count = config_check["details"]["startup warnings"]
.as_str()
.context("missing startup-warning count")?
.parse::<usize>()?;
let warnings = &config_check["details"]["startup warning"];
let startup_warning = std::iter::once(warnings)
.chain(warnings.as_array().into_iter().flatten())
.filter_map(Value::as_str)
.find(|warning| warning.starts_with("Ignoring invalid `otel.span_attributes` config"))
.context("missing recoverable OTEL config warning")?;
let check_statuses = report["checks"]
.as_object()
.unwrap()
.iter()
.map(|(id, check)| (id.clone(), check["status"].clone()))
.collect::<serde_json::Map<_, _>>();
assert_eq!(
json!({
"overallStatus": report["overallStatus"],
"checkStatuses": check_statuses,
"config": {
"summary": config_check["summary"],
"scope": config_check["details"]["configuration scope"],
"provider": config_check["details"]["model provider"],
"startupWarnings": warning_count,
"startupWarning": startup_warning,
},
}),
json!({
"overallStatus": "warning",
"checkStatuses": {
"config.load": "warning",
"network.env": "ok",
"network.provider_reachability": "ok",
"network.websocket_reachability": "ok",
},
"config": {
"summary": "config loaded",
"scope": "invocation config, including cloud-managed policy",
"provider": "local",
"startupWarnings": baseline_warning_count + 1,
"startupWarning": "Ignoring invalid `otel.span_attributes` config: configured span attribute key must not be empty",
},
})
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_only_config_failure_does_not_guess_probe_targets() -> Result<()> {
let proxy = MockServer::start().await;
let home = TempDir::new()?;
std::fs::write(
home.path().join("config.toml"),
"model_provider = sk-config-credential-fixture\n",
)?;
let output = local_command(&home)?
.args(["doctor", "--network-only", "--json"])
.env("HTTP_PROXY", proxy.uri())
.env("HTTPS_PROXY", proxy.uri())
.output()?;
assert_eq!(output.status.code(), Some(1));
assert!(!String::from_utf8_lossy(&output.stdout).contains("sk-config-credential-fixture"));
let report: Value = serde_json::from_slice(&output.stdout)?;
assert_eq!(report["checks"].as_object().unwrap().len(), 1);
assert_eq!(report["checks"]["config.load"]["status"], "fail");
assert_eq!(
report["checks"]["config.load"]["details"]["network diagnostics"],
"effective network configuration is unavailable"
);
assert!(proxy.received_requests().await.unwrap().is_empty());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_only_human_output() -> Result<()> {
let (_server, home) = local_provider().await?;
let output = local_command(&home)?
.args([
"doctor",
"--network-only",
"--summary",
"--ascii",
"--no-color",
])
.output()?;
let stdout = String::from_utf8(output.stdout)?;
assert!(output.status.success(), "{stdout}");
insta::assert_snapshot!("network_only_human_output", stdout);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_only_config_failure_human_output() -> Result<()> {
let home = TempDir::new()?;
std::fs::write(
home.path().join("config.toml"),
"model_provider = sk-config-credential-fixture\n",
)?;
let output = local_command(&home)?
.args(["doctor", "--network-only", "--all", "--ascii", "--no-color"])
.output()?;
let stdout = String::from_utf8(output.stdout)?;
assert_eq!(output.status.code(), Some(1));
assert!(!stdout.contains("sk-config-credential-fixture"));
// Config loading may canonicalize the directory, rendering may shorten it,
// and Windows uses backslashes.
// Normalize the rendered config-file row while retaining its label and filename.
let file_row = Regex::new(r"(?m)^( file +).+[\\/]config\.toml$")?;
assert!(file_row.is_match(&stdout), "{stdout}");
let stdout = file_row.replace_all(&stdout, "${1}<CODEX_HOME>/config.toml");
insta::assert_snapshot!("network_only_config_failure_human_output", stdout);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn doctor_reports_cloud_filesystem_policy_and_rejects_invalid_requirements() -> Result<()> {
for valid_requirements in [true, false] {
@@ -0,0 +1,24 @@
---
source: cli/tests/doctor_enterprise_network.rs
expression: stdout
---
Codex Doctor v0.0.0
Notes
[XX] config config could not be loaded - Fix the reported config error, then rerun codex doctor.
-------------------------------------------------------------
Configuration
[XX] config config could not be loaded - Fix the reported config error, then rerun codex doctor.
feature flags 0 enabled · 0 overridden
error invalid configuration
file <CODEX_HOME>/config.toml
line 1
column 18
network diagnostics effective network configuration is unavailable
-------------------------------------------------------------
0 ok | 1 notes | 0 warn | 1 fail failed
--summary compact output --all expand truncated lists
--json redacted report
@@ -0,0 +1,19 @@
---
source: cli/tests/doctor_enterprise_network.rs
expression: stdout
---
Codex Doctor v0.0.0
Configuration
[ok] config loaded
Connectivity
[ok] network network-related environment looks readable
[ok] websocket Responses WebSocket is not enabled for the active provider
[ok] reachability active provider endpoints are reachable over HTTP
-------------------------------------------------------------
4 ok | 0 warn | 0 fail ok
Run codex doctor without --summary for detailed diagnostics.
--all expand truncated lists --json redacted report