Files
codex/codex-rs/cli/src/doctor.rs
Michael Bolin cfa16fcc2e runtime: detect Codex package layout (#23596)
## Why

The package-builder stack now creates a canonical Codex package
directory where the entrypoint lives under `bin/`, bundled helper
resources live under `codex-resources/`, and bundled PATH-style tools
live under `codex-path/`. That layout is not specific to the standalone
installer: npm, brew, install scripts, and manually unpacked artifacts
should all be able to use the same package shape.

The Rust runtime still only knew about the legacy standalone release
layout, where resources sit next to the executable. A packaged binary
therefore would not identify its package root or prefer the bundled `rg`
from `codex-path/`.

## What changed

- Adds `CodexPackageLayout` to `codex-install-context` and detects it
from an executable path shaped like `<package>/bin/<entrypoint>` when
`<package>/codex-package.json` is present.
- Splits `InstallContext` into an install `method` plus an optional
package layout so the layout is shared across npm, bun, brew,
standalone, and other launch contexts.
- Stores package-layout paths as `AbsolutePathBuf` values.
- Keeps `codex-resources/` and `codex-path/` optional so Codex can still
run with degraded behavior if sidecar directories are missing.
- Updates `InstallContext::rg_command()` to prefer bundled
`codex-path/rg` or `rg.exe`, then fall back to the legacy standalone
resources location, then system `rg`.
- Updates `codex doctor` reporting so package installs show package,
bin, resources, and path directories, and so bundled search detection
recognizes `codex-path/` for any install method.

## Test plan

- `cargo test -p codex-install-context`
- `cargo test -p codex-cli`
- `cargo test -p codex-tui
update_action::tests::maps_install_context_to_update_action`
- `just bazel-lock-check`
2026-05-19 23:13:49 -07:00

3894 lines
132 KiB
Rust

//! Implements the `codex doctor` diagnostic report.
//!
//! Doctor is intentionally read-mostly: checks inspect the current installation,
//! configuration, authentication, terminal, state paths, and bounded reachability
//! probes without attempting repair or starting long-lived services. Each check
//! returns a redacted, serializable row so the same data can back the human
//! summary and `--json` support report.
//!
//! A failing check should describe the problem and remediation, but it should not
//! mutate user state. That keeps the command safe to run before filing a support
//! issue or while diagnosing a broken local installation.
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashMap;
use std::env;
use std::ffi::OsStr;
use std::future::Future;
use std::io::IsTerminal;
use std::io::Read;
use std::net::IpAddr;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use anyhow::Context;
use clap::Parser;
use codex_api::ApiError;
use codex_api::ResponsesWebsocketClient;
use codex_api::is_azure_responses_provider;
use codex_arg0::Arg0DispatchPaths;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_core::config::find_codex_home;
use codex_features::FEATURES;
use codex_install_context::CodexPackageLayout;
use codex_install_context::InstallContext;
use codex_install_context::InstallMethod;
use codex_install_context::StandalonePlatform;
use codex_login::AuthDotJson;
use codex_login::AuthManager;
use codex_login::CODEX_ACCESS_TOKEN_ENV_VAR;
use codex_login::CODEX_API_KEY_ENV_VAR;
use codex_login::CodexAuth;
use codex_login::OPENAI_API_KEY_ENV_VAR;
use codex_login::default_client::build_reqwest_client;
use codex_login::default_client::default_headers;
use codex_login::load_auth_dot_json;
use codex_model_provider::create_model_provider;
use codex_protocol::protocol::AskForApproval;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
use codex_terminal_detection::terminal_info;
use codex_tui::Cli as TuiCli;
use codex_utils_cli::CliConfigOverrides;
use http::HeaderMap;
use http::HeaderValue;
use serde::Serialize;
use supports_color::Stream;
mod background;
mod output;
mod progress;
mod runtime;
mod updates;
use background::background_server_check;
use output::HumanOutputOptions;
use output::redact_detail;
use output::render_human_report;
use progress::DoctorProgress;
use progress::doctor_progress;
use runtime::runtime_check;
use runtime::search_check;
use updates::updates_check;
const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
const RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06";
const WEBSOCKET_IMMEDIATE_CLOSE_GRACE: Duration = Duration::from_millis(250);
const SLOW_CHECK_PROGRESS_THRESHOLD: Duration = Duration::from_secs(2);
const SLOW_CHECK_PROGRESS_INTERVAL: Duration = Duration::from_secs(1);
const PROXY_ENV_VARS: &[&str] = &[
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
];
const COLOR_ENV_VARS: &[&str] = &[
"COLORTERM",
"NO_COLOR",
"CLICOLOR",
"CLICOLOR_FORCE",
"FORCE_COLOR",
"COLORFGBG",
];
const TERMINAL_DIMENSION_ENV_VARS: &[&str] = &["COLUMNS", "LINES"];
const TERMINFO_ENV_VARS: &[&str] = &["TERMINFO", "TERMINFO_DIRS"];
const LOCALE_ENV_VARS: &[&str] = &["LC_ALL", "LC_CTYPE", "LANG"];
#[cfg(windows)]
const NPM_COMMAND: &str = "npm.cmd";
#[cfg(not(windows))]
const NPM_COMMAND: &str = "npm";
const REMOTE_TERMINAL_ENV_VARS: &[&str] = &[
"SSH_TTY",
"SSH_CONNECTION",
"SSH_CLIENT",
"MOSH_IP",
"WSL_DISTRO_NAME",
"WSL_INTEROP",
"VSCODE_INJECTION",
"VSCODE_IPC_HOOK_CLI",
"WAYLAND_DISPLAY",
"DISPLAY",
"WT_SESSION",
];
const TMUX_OPTION_NAMES: &[&str] = &[
"extended-keys",
"xterm-keys",
"allow-passthrough",
"set-clipboard",
"focus-events",
];
const NARROW_TERMINAL_COLUMNS: u16 = 80;
const NARROW_TERMINAL_ROWS: u16 = 24;
/// Options for building a local Codex diagnostic report.
///
/// The command always runs the full bounded diagnostic set. Human output includes
/// detailed diagnostics by default; --summary keeps the terminal output compact.
#[derive(Debug, Parser)]
pub struct DoctorCommand {
/// Emit a redacted machine-readable report.
#[arg(long, default_value_t = false)]
json: bool,
/// Only show grouped check rows and the final count summary.
#[arg(long, default_value_t = false)]
summary: bool,
/// Expand long lists in detailed human output.
#[arg(long, default_value_t = false)]
all: bool,
/// Disable ANSI color in human output.
#[arg(long, default_value_t = false)]
no_color: bool,
/// Use ASCII status labels and separators in human output.
#[arg(long, default_value_t = false)]
ascii: bool,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Serialize)]
#[serde(rename_all = "snake_case")]
enum CheckStatus {
Ok,
Warning,
Fail,
}
/// Machine-readable doctor output shared by human and JSON renderers.
///
/// The schema is intentionally flat: each check carries its own category,
/// status, details, remediation, and duration so support tooling can filter or
/// redact individual rows without understanding the renderer's section layout.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DoctorReport {
schema_version: u32,
generated_at: String,
overall_status: CheckStatus,
codex_version: String,
checks: Vec<DoctorCheck>,
}
/// One diagnostic result in the doctor report.
///
/// Summaries are safe for compact human output. Details may include local paths
/// or command output and are redacted before rendering or JSON serialization.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DoctorCheck {
id: String,
category: String,
status: CheckStatus,
summary: String,
details: Vec<String>,
issues: Vec<DoctorIssue>,
remediation: Option<String>,
duration_ms: u64,
}
/// Structured cause/remedy metadata for a non-ok doctor check.
///
/// Human output uses issues to make warnings and failures self-explanatory:
/// the row headline says what is wrong, matching detail rows show measured vs.
/// expected values, and remedies are printed as explicit next actions.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct DoctorIssue {
severity: CheckStatus,
cause: String,
measured: Option<String>,
expected: Option<String>,
remedy: Option<String>,
fields: Vec<String>,
}
impl DoctorIssue {
fn new(severity: CheckStatus, cause: impl Into<String>) -> Self {
Self {
severity,
cause: cause.into(),
measured: None,
expected: None,
remedy: None,
fields: Vec::new(),
}
}
fn measured(mut self, measured: impl Into<String>) -> Self {
self.measured = Some(measured.into());
self
}
fn expected(mut self, expected: impl Into<String>) -> Self {
self.expected = Some(expected.into());
self
}
fn remedy(mut self, remedy: impl Into<String>) -> Self {
self.remedy = Some(remedy.into());
self
}
fn field(mut self, field: impl Into<String>) -> Self {
self.fields.push(field.into());
self
}
}
impl DoctorCheck {
fn new(
id: impl Into<String>,
category: impl Into<String>,
status: CheckStatus,
summary: impl Into<String>,
) -> Self {
Self {
id: id.into(),
category: category.into(),
status,
summary: summary.into(),
details: Vec::new(),
issues: Vec::new(),
remediation: None,
duration_ms: 0,
}
}
fn detail(mut self, detail: impl Into<String>) -> Self {
self.details.push(detail.into());
self
}
fn details(mut self, details: Vec<String>) -> Self {
self.details.extend(details);
self
}
fn remediation(mut self, remediation: impl Into<String>) -> Self {
self.remediation = Some(remediation.into());
self
}
fn issue(mut self, issue: DoctorIssue) -> Self {
self.issues.push(issue);
self
}
}
/// Builds, renders, and exits according to the current doctor report.
///
/// This is the CLI entry point for codex doctor. It does not repair issues;
/// failures are represented in the report and cause a non-zero process exit so
/// scripts can distinguish a clean environment from one that needs attention.
pub async fn run_doctor(
command: DoctorCommand,
root_config_overrides: CliConfigOverrides,
interactive: &TuiCli,
arg0_paths: &Arg0DispatchPaths,
) -> anyhow::Result<()> {
let report = build_report(&command, root_config_overrides, interactive, arg0_paths).await;
if command.json {
println!(
"{}",
serde_json::to_string_pretty(&redacted_json_report(&report))?
);
} else {
print!(
"{}",
render_human_report(&report, human_output_options(&command))
);
}
if report.overall_status == CheckStatus::Fail {
std::process::exit(1);
}
Ok(())
}
async fn build_report(
command: &DoctorCommand,
root_config_overrides: CliConfigOverrides,
interactive: &TuiCli,
arg0_paths: &Arg0DispatchPaths,
) -> DoctorReport {
let progress = doctor_progress(command.json);
let mut checks = Vec::new();
checks.push(run_sync_check("installation", progress.clone(), || {
installation_check(!command.summary)
}));
checks.push(run_sync_check("runtime", progress.clone(), runtime_check));
checks.push(run_sync_check("search", progress.clone(), search_check));
progress.begin("config");
let config_result = load_config(root_config_overrides, interactive, arg0_paths).await;
match &config_result {
Ok(config) => {
let auth_manager =
AuthManager::shared_from_config(config, /*enable_codex_api_key_env*/ true).await;
let reachability_plan = provider_reachability_plan(config);
let (
config_check,
auth_check,
updates_check,
network_check,
websocket_check,
mcp_check,
sandbox_check,
terminal_check,
state_check,
background_server_check,
reachability_check,
) = tokio::join!(
async { run_sync_check("config", progress.clone(), || config_check(config)) },
async { run_sync_check("auth", progress.clone(), || auth_check(config)) },
async { run_sync_check("updates", progress.clone(), || updates_check(config)) },
async { run_sync_check("network", progress.clone(), network_check) },
run_async_check(
"websocket",
progress.clone(),
websocket_reachability_check(config, Some(auth_manager)),
),
run_async_check("MCP", progress.clone(), mcp_check(config)),
async {
run_sync_check("sandbox", progress.clone(), || {
sandbox_check(config, arg0_paths)
})
},
async {
run_sync_check("terminal", progress.clone(), || {
terminal_check(command.no_color)
})
},
run_async_check("state", progress.clone(), state_check(config)),
async {
run_sync_check("app-server", progress.clone(), || {
background_server_check(config)
})
},
run_async_check(
"provider reachability",
progress.clone(),
provider_reachability_check(reachability_plan),
),
);
checks.extend([
config_check,
auth_check,
updates_check,
network_check,
websocket_check,
mcp_check,
sandbox_check,
terminal_check,
state_check,
background_server_check,
reachability_check,
]);
}
Err(err) => {
let reachability_plan = default_reachability_plan();
let (config_check, network_check, terminal_check, state_check, reachability_check) = tokio::join!(
async {
run_sync_check("config", progress.clone(), || {
DoctorCheck::new(
"config.load",
"config",
CheckStatus::Fail,
"config could not be loaded",
)
.detail(err.to_string())
.remediation("Fix the reported config error, then rerun codex doctor.")
})
},
async { run_sync_check("network", progress.clone(), network_check) },
async {
run_sync_check("terminal", progress.clone(), || {
terminal_check(command.no_color)
})
},
async { run_sync_check("state", progress.clone(), fallback_state_check) },
run_async_check(
"provider reachability",
progress.clone(),
provider_reachability_check(reachability_plan),
),
);
checks.extend([
config_check,
network_check,
terminal_check,
state_check,
reachability_check,
]);
}
}
progress.settle();
let overall_status = overall_status(&checks);
DoctorReport {
schema_version: 1,
generated_at: generated_at(),
overall_status,
codex_version: env!("CARGO_PKG_VERSION").to_string(),
checks,
}
}
async fn load_config(
root_config_overrides: CliConfigOverrides,
interactive: &TuiCli,
arg0_paths: &Arg0DispatchPaths,
) -> anyhow::Result<Config> {
let mut cli_kv_overrides = root_config_overrides
.parse_overrides()
.map_err(anyhow::Error::msg)?;
if interactive.web_search {
cli_kv_overrides.push((
"web_search".to_string(),
toml::Value::String("live".to_string()),
));
}
let overrides = ConfigOverrides {
ephemeral: Some(true),
..config_overrides_from_interactive(interactive, arg0_paths)
};
ConfigBuilder::default()
.cli_overrides(cli_kv_overrides)
.harness_overrides(overrides)
.build()
.await
.context("failed to load Codex config")
}
fn config_overrides_from_interactive(
interactive: &TuiCli,
arg0_paths: &Arg0DispatchPaths,
) -> ConfigOverrides {
let approval_policy = if interactive.dangerously_bypass_approvals_and_sandbox {
Some(AskForApproval::Never)
} else {
interactive.approval_policy.map(Into::into)
};
let sandbox_mode = if interactive.dangerously_bypass_approvals_and_sandbox {
Some(codex_protocol::config_types::SandboxMode::DangerFullAccess)
} else {
interactive.sandbox_mode.map(Into::into)
};
ConfigOverrides {
model: interactive.model.clone(),
config_profile: interactive.config_profile.clone(),
approval_policy,
sandbox_mode,
cwd: interactive.cwd.clone(),
model_provider: interactive
.oss
.then(|| interactive.oss_provider.clone())
.flatten(),
codex_self_exe: arg0_paths.codex_self_exe.clone(),
codex_linux_sandbox_exe: arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: arg0_paths.main_execve_wrapper_exe.clone(),
show_raw_agent_reasoning: interactive.oss.then_some(true),
additional_writable_roots: interactive.add_dir.clone(),
..Default::default()
}
}
/// JSON support report emitted by `codex doctor --json`.
///
/// The report is keyed by check id so support tooling can fetch paths like
/// `checks["terminal.metadata"]` without scanning arrays. Human rendering can
/// reorder or group rows independently, but this JSON shape should stay stable
/// across cosmetic output changes.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonDoctorReport {
schema_version: u32,
generated_at: String,
overall_status: CheckStatus,
codex_version: String,
checks: BTreeMap<String, JsonDoctorCheck>,
}
/// One redacted check in the JSON support report.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonDoctorCheck {
id: String,
category: String,
status: CheckStatus,
summary: String,
details: BTreeMap<String, JsonDetailValue>,
#[serde(skip_serializing_if = "Vec::is_empty")]
issues: Vec<JsonDoctorIssue>,
#[serde(skip_serializing_if = "Vec::is_empty")]
notes: Vec<String>,
remediation: Option<String>,
duration_ms: u64,
}
/// One redacted issue in the JSON support report.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct JsonDoctorIssue {
severity: CheckStatus,
cause: String,
measured: Option<String>,
expected: Option<String>,
remedy: Option<String>,
fields: Vec<String>,
}
/// JSON detail value that preserves repeated detail keys without inventing names.
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
enum JsonDetailValue {
One(String),
Many(Vec<String>),
}
impl JsonDetailValue {
fn push(&mut self, value: String) {
match self {
JsonDetailValue::One(previous) => {
*self = JsonDetailValue::Many(vec![std::mem::take(previous), value]);
}
JsonDetailValue::Many(values) => values.push(value),
}
}
}
fn redacted_json_report(report: &DoctorReport) -> JsonDoctorReport {
let checks = report
.checks
.iter()
.map(|check| {
let json_check = redacted_json_check(check);
(check.id.clone(), json_check)
})
.collect();
JsonDoctorReport {
schema_version: report.schema_version,
generated_at: report.generated_at.clone(),
overall_status: report.overall_status,
codex_version: report.codex_version.clone(),
checks,
}
}
fn redacted_json_check(check: &DoctorCheck) -> JsonDoctorCheck {
let (details, notes) = structured_json_details(&check.details);
JsonDoctorCheck {
id: check.id.clone(),
category: check.category.clone(),
status: check.status,
summary: check.summary.clone(),
details,
issues: check.issues.iter().map(redacted_json_issue).collect(),
notes,
remediation: check.remediation.as_deref().map(redact_detail),
duration_ms: check.duration_ms,
}
}
fn redacted_json_issue(issue: &DoctorIssue) -> JsonDoctorIssue {
JsonDoctorIssue {
severity: issue.severity,
cause: redact_detail(&issue.cause),
measured: issue.measured.as_deref().map(redact_detail),
expected: issue.expected.as_deref().map(redact_detail),
remedy: issue.remedy.as_deref().map(redact_detail),
fields: issue
.fields
.iter()
.map(|field| redact_detail(field))
.collect(),
}
}
/// Converts redacted `label: value` detail strings into JSON key/value fields.
///
/// Detail strings that do not follow the doctor detail convention are preserved
/// as notes instead of being dropped. Repeated labels become arrays so callers
/// can still retrieve the common scalar case directly while keeping all values.
fn structured_json_details(details: &[String]) -> (BTreeMap<String, JsonDetailValue>, Vec<String>) {
let mut structured: BTreeMap<String, JsonDetailValue> = BTreeMap::new();
let mut notes = Vec::new();
for detail in details {
let redacted = redact_detail(detail);
let Some((key, value)) = redacted.split_once(": ") else {
notes.push(redacted);
continue;
};
let key = key.trim();
if key.is_empty() {
notes.push(redacted);
continue;
}
let value = value.to_string();
match structured.get_mut(key) {
Some(existing) => existing.push(value),
None => {
structured.insert(key.to_string(), JsonDetailValue::One(value));
}
}
}
(structured, notes)
}
fn run_sync_check(
label: &'static str,
progress: Arc<dyn DoctorProgress>,
f: impl FnOnce() -> DoctorCheck,
) -> DoctorCheck {
progress.begin(label);
let start = Instant::now();
let mut check = f();
check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
progress.finish(label, check.status);
check
}
async fn run_async_check<Fut>(
label: &'static str,
progress: Arc<dyn DoctorProgress>,
future: Fut,
) -> DoctorCheck
where
Fut: Future<Output = DoctorCheck>,
{
progress.begin(label);
let start = Instant::now();
tokio::pin!(future);
let mut progress_interval = tokio::time::interval(SLOW_CHECK_PROGRESS_INTERVAL);
loop {
tokio::select! {
mut check = &mut future => {
check.duration_ms = start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
progress.finish(label, check.status);
return check;
}
_ = progress_interval.tick() => {
let elapsed = start.elapsed();
if elapsed >= SLOW_CHECK_PROGRESS_THRESHOLD {
progress.heartbeat(label, elapsed);
}
}
}
}
}
fn overall_status(checks: &[DoctorCheck]) -> CheckStatus {
if checks.iter().any(|check| check.status == CheckStatus::Fail) {
CheckStatus::Fail
} else if checks
.iter()
.any(|check| check.status == CheckStatus::Warning)
{
CheckStatus::Warning
} else {
CheckStatus::Ok
}
}
fn generated_at() -> String {
match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
Ok(duration) => {
let seconds = duration.as_secs();
format!("{seconds}s since unix epoch")
}
Err(_) => "unknown".to_string(),
}
}
fn installation_check(show_details: bool) -> DoctorCheck {
let mut details = Vec::new();
let current_exe = env::current_exe().ok();
push_path_detail(&mut details, "current executable", current_exe.as_deref());
let inherited_managed_env = inherited_managed_env_for_cargo_binary(current_exe.as_deref());
let install_context = doctor_install_context(current_exe.as_deref());
details.push(format!(
"install context: {}",
describe_install_context(&install_context)
));
if inherited_managed_env {
details.push(
"ignored inherited package-manager launch env for cargo-built binary".to_string(),
);
}
details.push(format!(
"managed by npm: {}",
doctor_managed_by_npm(current_exe.as_deref())
));
details.push(format!(
"managed by bun: {}",
env::var_os("CODEX_MANAGED_BY_BUN").is_some()
));
push_env_path_detail(
&mut details,
"managed package root",
"CODEX_MANAGED_PACKAGE_ROOT",
);
let path_entries = codex_path_entries();
let mut status = CheckStatus::Ok;
let mut summary = "installation looks consistent".to_string();
let mut remediation = None;
if path_entries.len() > 1 {
details.push(format!("PATH codex entries: {}", path_entries.len()));
}
if show_details || path_entries.len() > 1 {
details.extend(
path_entries
.iter()
.enumerate()
.map(|(index, path)| format!("PATH codex #{}: {path}", index + 1)),
);
}
if doctor_managed_by_npm(current_exe.as_deref()) {
match npm_global_root_check() {
NpmRootCheck::Match { package_root } => {
details.push(format!("npm update target: {}", package_root.display()));
}
NpmRootCheck::Mismatch {
running_package_root,
npm_package_root,
} => {
status = CheckStatus::Fail;
summary =
"npm install -g @openai/codex would update a different install".to_string();
remediation = Some(format!(
"Fix PATH or npm prefix so the running package root ({}) matches the npm global package root ({}).",
running_package_root.display(),
npm_package_root.display()
));
details.push(format!(
"running package root: {}",
running_package_root.display()
));
details.push(format!("npm package root: {}", npm_package_root.display()));
}
NpmRootCheck::MissingPackageRoot => {
status = status.max(CheckStatus::Warning);
summary = "npm-managed launch is missing package-root provenance".to_string();
remediation = Some(
"Reinstall or update Codex so the JS shim provides CODEX_MANAGED_PACKAGE_ROOT."
.to_string(),
);
}
NpmRootCheck::NpmUnavailable(error) => {
status = status.max(CheckStatus::Warning);
summary = "npm-managed launch could not inspect npm global root".to_string();
details.push(format!("npm root -g failed: {error}"));
}
}
}
let mut check = DoctorCheck::new("installation", "install", status, summary).details(details);
if let Some(remediation) = remediation {
check = check.remediation(remediation);
}
check
}
fn doctor_install_context(current_exe: Option<&Path>) -> InstallContext {
if inherited_managed_env_for_cargo_binary(current_exe) {
InstallContext {
method: InstallMethod::Other,
package_layout: None,
}
} else {
InstallContext::current().clone()
}
}
fn doctor_managed_by_npm(current_exe: Option<&Path>) -> bool {
env::var_os("CODEX_MANAGED_BY_NPM").is_some()
&& !inherited_managed_env_for_cargo_binary(current_exe)
}
fn inherited_managed_env_for_cargo_binary(current_exe: Option<&Path>) -> bool {
if env::var_os("CODEX_MANAGED_BY_NPM").is_none()
&& env::var_os("CODEX_MANAGED_BY_BUN").is_none()
{
return false;
}
let Some(current_exe) = current_exe else {
return false;
};
let components = current_exe
.components()
.map(|component| component.as_os_str().to_string_lossy())
.collect::<Vec<_>>();
components
.windows(2)
.any(|window| window[0] == "target" && matches!(window[1].as_ref(), "debug" | "release"))
}
fn describe_install_context(context: &InstallContext) -> String {
match &context.method {
InstallMethod::Standalone {
release_dir,
resources_dir,
platform,
} => {
let platform = match platform {
StandalonePlatform::Unix => "unix",
StandalonePlatform::Windows => "windows",
};
match &context.package_layout {
Some(package_layout) => {
let resources = display_optional_path(package_layout.resources_dir.as_deref());
let path = display_optional_path(package_layout.path_dir.as_deref());
format!(
"standalone ({platform}, package {}, bin {}, resources {resources}, path {path})",
package_layout.package_dir.display(),
package_layout.bin_dir.display()
)
}
None => {
let resources = display_optional_path(resources_dir.as_deref());
format!(
"standalone ({platform}, release {}, resources {resources})",
release_dir.display()
)
}
}
}
InstallMethod::Npm => {
describe_method_with_package_layout("npm", context.package_layout.as_ref())
}
InstallMethod::Bun => {
describe_method_with_package_layout("bun", context.package_layout.as_ref())
}
InstallMethod::Brew => {
describe_method_with_package_layout("brew", context.package_layout.as_ref())
}
InstallMethod::Other => {
describe_method_with_package_layout("other", context.package_layout.as_ref())
}
}
}
fn describe_method_with_package_layout(
method: &str,
package_layout: Option<&CodexPackageLayout>,
) -> String {
match package_layout {
Some(package_layout) => {
let resources = display_optional_path(package_layout.resources_dir.as_deref());
let path = display_optional_path(package_layout.path_dir.as_deref());
format!(
"{method} (package {}, bin {}, resources {resources}, path {path})",
package_layout.package_dir.display(),
package_layout.bin_dir.display()
)
}
None => method.to_string(),
}
}
fn display_optional_path(path: Option<&Path>) -> String {
path.map(|path| path.display().to_string())
.unwrap_or_else(|| "none".to_string())
}
#[derive(Debug, PartialEq, Eq)]
enum NpmRootCheck {
Match {
package_root: PathBuf,
},
Mismatch {
running_package_root: PathBuf,
npm_package_root: PathBuf,
},
MissingPackageRoot,
NpmUnavailable(String),
}
fn npm_global_root_check() -> NpmRootCheck {
let Some(running_package_root) = env::var_os("CODEX_MANAGED_PACKAGE_ROOT").map(PathBuf::from)
else {
return NpmRootCheck::MissingPackageRoot;
};
let output = match run_command(NPM_COMMAND, ["root", "-g"]) {
Ok(output) => output,
Err(err) => return NpmRootCheck::NpmUnavailable(err),
};
let Some(npm_root) = output.lines().map(str::trim).find(|line| !line.is_empty()) else {
return NpmRootCheck::NpmUnavailable("empty output from npm root -g".to_string());
};
compare_npm_package_roots(&running_package_root, &PathBuf::from(npm_root))
}
fn compare_npm_package_roots(running_package_root: &Path, npm_root: &Path) -> NpmRootCheck {
let npm_package_root = npm_root.join("@openai").join("codex");
let running = normalize_path_for_compare(running_package_root);
let target = normalize_path_for_compare(&npm_package_root);
if running == target {
NpmRootCheck::Match {
package_root: npm_package_root,
}
} else {
NpmRootCheck::Mismatch {
running_package_root: running_package_root.to_path_buf(),
npm_package_root,
}
}
}
fn normalize_path_for_compare(path: &Path) -> String {
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
let raw = canonical.to_string_lossy().replace('\\', "/");
if cfg!(windows) {
raw.to_ascii_lowercase()
} else {
raw
}
}
fn display_list<T: AsRef<str>>(items: &[T]) -> String {
if items.is_empty() {
"none".to_string()
} else {
items
.iter()
.map(AsRef::as_ref)
.collect::<Vec<_>>()
.join(", ")
}
}
fn codex_path_entries() -> Vec<String> {
#[cfg(windows)]
let result = run_command("where", ["codex"]);
#[cfg(not(windows))]
let result = run_command("which", ["-a", "codex"]);
result
.unwrap_or_default()
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect()
}
fn run_command<I, S>(program: &str, args: I) -> Result<String, String>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new(program)
.args(args)
.output()
.map_err(|err| err.to_string())?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err(format!("exited with status {}", output.status));
}
return Err(stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn config_check(config: &Config) -> DoctorCheck {
let mut details = Vec::new();
details.push(format!("CODEX_HOME: {}", config.codex_home.display()));
details.push(format!("cwd: {}", config.cwd.display()));
details.push(format!(
"model: {}",
config.model.as_deref().unwrap_or("<default>")
));
details.push(format!("model provider: {}", config.model_provider_id));
details.push(format!("log dir: {}", config.log_dir.display()));
details.push(format!("sqlite home: {}", config.sqlite_home.display()));
details.push(format!("mcp servers: {}", config.mcp_servers.get().len()));
feature_flag_details(config, &mut details);
config_toml_details(config, &mut details);
let status = if config.startup_warnings.is_empty() {
CheckStatus::Ok
} else {
details.extend(
config
.startup_warnings
.iter()
.map(|warning| format!("startup warning: {warning}")),
);
CheckStatus::Warning
};
DoctorCheck::new("config.load", "config", status, "config loaded").details(details)
}
fn feature_flag_details(config: &Config, details: &mut Vec<String>) {
let features = config.features.get();
let enabled_features = FEATURES
.iter()
.filter(|spec| features.enabled(spec.id))
.map(|spec| spec.key)
.collect::<Vec<_>>();
let overrides = FEATURES
.iter()
.filter(|spec| features.enabled(spec.id) != spec.default_enabled)
.map(|spec| format!("{}={}", spec.key, features.enabled(spec.id)))
.collect::<Vec<_>>();
details.push(format!("feature flags enabled: {}", enabled_features.len()));
details.push(format!(
"enabled feature flags: {}",
display_list(&enabled_features)
));
details.push(format!(
"feature flag overrides: {}",
display_list(&overrides)
));
for usage in features.legacy_feature_usages() {
details.push(format!(
"legacy feature flag: {} -> {}",
usage.alias,
usage.feature.key()
));
}
}
fn config_toml_details(config: &Config, details: &mut Vec<String>) {
let config_path = config.codex_home.join(codex_config::CONFIG_TOML_FILE);
details.push(format!("config.toml: {}", config_path.display()));
match std::fs::read_to_string(&config_path) {
Ok(contents) => match toml::from_str::<toml::Value>(&contents) {
Ok(_) => details.push("config.toml parse: ok".to_string()),
Err(err) => details.push(format!("config.toml parse: {err}")),
},
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
details.push("config.toml: missing".to_string());
}
Err(err) => details.push(format!("config.toml read: {err}")),
}
}
fn auth_check(config: &Config) -> DoctorCheck {
let mut details = Vec::new();
let auth_path = config.codex_home.join("auth.json");
details.push(format!(
"auth storage mode: {:?}",
config.cli_auth_credentials_store_mode
));
details.push(format!("auth file: {}", auth_path.display()));
let env_auth_vars = [
OPENAI_API_KEY_ENV_VAR,
CODEX_API_KEY_ENV_VAR,
CODEX_ACCESS_TOKEN_ENV_VAR,
]
.into_iter()
.filter(|name| env_var_present(name))
.collect::<Vec<_>>();
if !env_auth_vars.is_empty() {
details.push(format!(
"auth env vars present: {}",
env_auth_vars.join(", ")
));
}
if let Some(check) = provider_specific_auth_check(
config.model_provider.requires_openai_auth,
config.model_provider.env_key.as_deref(),
config.model_provider.env_key_instructions.as_deref(),
details.clone(),
env_var_present,
) {
return check;
}
match load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => {
details.push(format!("stored auth mode: {}", stored_auth_mode(&auth)));
details.push(format!("stored API key: {}", auth.openai_api_key.is_some()));
details.push(format!("stored ChatGPT tokens: {}", auth.tokens.is_some()));
details.push(format!(
"stored agent identity: {}",
auth.agent_identity.is_some()
));
let auth_issues = stored_auth_issues(&auth, env_var_present);
details.extend(
auth_issues
.iter()
.map(|issue| format!("stored auth issue: {issue}")),
);
let status = if !auth_issues.is_empty() && env_auth_vars.is_empty() {
CheckStatus::Fail
} else if !auth_issues.is_empty() || env_auth_vars.len() > 1 {
CheckStatus::Warning
} else {
CheckStatus::Ok
};
let summary = match status {
CheckStatus::Ok => "auth is configured",
CheckStatus::Warning if !auth_issues.is_empty() => {
"auth is provided by environment, but stored credentials are incomplete"
}
CheckStatus::Warning => {
"auth is configured, but multiple auth env vars are present"
}
CheckStatus::Fail => "stored credentials are incomplete",
};
let mut check =
DoctorCheck::new("auth.credentials", "auth", status, summary).details(details);
if status == CheckStatus::Fail {
check =
check.remediation("Run codex login again or provide a supported auth env var.");
}
check
}
Ok(None) if !env_auth_vars.is_empty() => DoctorCheck::new(
"auth.credentials",
"auth",
CheckStatus::Ok,
"auth is provided by environment",
)
.details(details),
Ok(None) => DoctorCheck::new(
"auth.credentials",
"auth",
CheckStatus::Fail,
"no Codex credentials were found",
)
.details(details)
.remediation("Run codex login or provide an API key through a supported auth env var."),
Err(err) => DoctorCheck::new(
"auth.credentials",
"auth",
CheckStatus::Fail,
"stored credentials could not be read",
)
.detail(err.to_string())
.remediation("Fix auth storage access or run codex login again."),
}
}
fn provider_specific_auth_check(
requires_openai_auth: bool,
provider_env_key: Option<&str>,
provider_env_key_instructions: Option<&str>,
mut details: Vec<String>,
env_var_present: impl Fn(&str) -> bool,
) -> Option<DoctorCheck> {
details.push(format!(
"model provider requires OpenAI auth: {requires_openai_auth}"
));
if requires_openai_auth {
return None;
}
match provider_env_key {
Some(env_key) if env_var_present(env_key) => {
details.push(format!("provider auth env var: {env_key} (present)"));
Some(
DoctorCheck::new(
"auth.credentials",
"auth",
CheckStatus::Ok,
"auth is provided by the active model provider",
)
.details(details),
)
}
Some(env_key) => {
details.push(format!("provider auth env var: {env_key} (missing)"));
let remediation = provider_env_key_instructions
.map(str::to_string)
.unwrap_or_else(|| format!("Set {env_key} for the active model provider."));
Some(
DoctorCheck::new(
"auth.credentials",
"auth",
CheckStatus::Fail,
"active model provider auth env var is missing",
)
.details(details)
.remediation(remediation),
)
}
None => Some(
DoctorCheck::new(
"auth.credentials",
"auth",
CheckStatus::Ok,
"OpenAI auth is not required for the active model provider",
)
.details(details),
),
}
}
fn stored_auth_mode(auth: &codex_login::AuthDotJson) -> &'static str {
match stored_auth_mode_value(auth) {
codex_app_server_protocol::AuthMode::ApiKey => "api_key",
codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt",
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
}
}
fn stored_auth_mode_value(auth: &AuthDotJson) -> codex_app_server_protocol::AuthMode {
if let Some(mode) = auth.auth_mode {
return mode;
}
if auth.openai_api_key.is_some() {
codex_app_server_protocol::AuthMode::ApiKey
} else {
codex_app_server_protocol::AuthMode::Chatgpt
}
}
fn stored_auth_issues(
auth: &AuthDotJson,
env_var_present: impl Fn(&str) -> bool,
) -> Vec<&'static str> {
let mut issues = Vec::new();
match stored_auth_mode_value(auth) {
codex_app_server_protocol::AuthMode::ApiKey => {
let stored_key_present = auth
.openai_api_key
.as_deref()
.is_some_and(|key| !key.trim().is_empty());
let env_key_present =
env_var_present(OPENAI_API_KEY_ENV_VAR) || env_var_present(CODEX_API_KEY_ENV_VAR);
if !stored_key_present && !env_key_present {
issues.push("API key auth is missing an API key");
}
}
codex_app_server_protocol::AuthMode::Chatgpt => {
match auth.tokens.as_ref() {
Some(tokens) => {
if tokens.access_token.trim().is_empty() {
issues.push("ChatGPT auth is missing an access token");
}
if tokens.refresh_token.trim().is_empty() {
issues.push("ChatGPT auth is missing a refresh token");
}
}
None => issues.push("ChatGPT auth is missing token data"),
}
if auth.last_refresh.is_none() {
issues.push("ChatGPT auth is missing refresh metadata");
}
}
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => {
match auth.tokens.as_ref() {
Some(tokens) => {
if tokens.access_token.trim().is_empty() {
issues.push("external ChatGPT auth is missing an access token");
}
if tokens.account_id.is_none() && tokens.id_token.chatgpt_account_id.is_none() {
issues.push("external ChatGPT auth is missing a ChatGPT account id");
}
}
None => issues.push("external ChatGPT auth is missing token data"),
}
if auth.last_refresh.is_none() {
issues.push("external ChatGPT auth is missing refresh metadata");
}
}
codex_app_server_protocol::AuthMode::AgentIdentity => {
if auth
.agent_identity
.as_deref()
.is_none_or(|token| token.trim().is_empty())
{
issues.push("agent identity auth is missing an agent identity token");
}
}
}
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()
.copied()
.filter(|name| env_var_present(name))
.collect::<Vec<_>>();
if present_proxy_vars.is_empty() {
details.push("proxy env vars: none".to_string());
} else {
details.push(format!(
"proxy env vars present: {}",
present_proxy_vars.join(", ")
));
}
}
fn read_probe_file(path: &Path) -> std::io::Result<()> {
let mut file = std::fs::File::open(path)?;
let mut buffer = [0_u8; 1];
let _ = file.read(&mut buffer)?;
Ok(())
}
async fn mcp_check(config: &Config) -> DoctorCheck {
mcp_check_from_servers(config.mcp_servers.get()).await
}
async fn mcp_check_from_servers(servers: &HashMap<String, McpServerConfig>) -> DoctorCheck {
if servers.is_empty() {
return DoctorCheck::new(
"mcp.config",
"mcp",
CheckStatus::Ok,
"no MCP servers configured",
);
}
let mut details = Vec::new();
let mut transport_counts: BTreeMap<&'static str, usize> = BTreeMap::new();
let mut disabled = 0usize;
let mut missing_env = Vec::new();
let mut unreachable_required_http = Vec::new();
let mut unreachable_optional_http = Vec::new();
for (name, server) in servers {
let disabled_server = !server.enabled || server.disabled_reason.is_some();
if disabled_server {
disabled += 1;
}
match &server.transport {
McpServerTransportConfig::Stdio {
command,
env,
env_vars,
cwd,
..
} => {
*transport_counts.entry("stdio").or_default() += 1;
if disabled_server {
continue;
}
if let Some(cwd) = cwd
&& !cwd.exists()
{
missing_env.push(format!("{name}: cwd does not exist ({})", cwd.display()));
}
if command.trim().is_empty() {
missing_env.push(format!("{name}: stdio command is empty"));
} else if let Err(err) =
stdio_command_resolves(command, cwd.as_deref(), env.as_ref())
{
missing_env.push(format!(
"{name}: stdio command {command:?} is not resolvable ({err})"
));
}
if let Some(env) = env {
for key in env.keys().filter(|key| key.trim().is_empty()) {
missing_env.push(format!("{name}: empty env key {key}"));
}
}
for env_var in env_vars {
if env_var.is_remote_source() {
missing_env.push(format!(
"{name}: env_vars entry `{}` uses source `remote`, which requires remote MCP stdio",
env_var.name()
));
} else if !env_var_present(env_var.name()) {
missing_env.push(format!("{name}: env var {} is not set", env_var.name()));
}
}
}
McpServerTransportConfig::StreamableHttp {
url,
bearer_token_env_var,
env_http_headers,
..
} => {
*transport_counts.entry("streamable_http").or_default() += 1;
if disabled_server {
continue;
}
if let Some(env_var) = bearer_token_env_var
&& !env_var_present(env_var)
{
missing_env.push(format!("{name}: bearer token env var {env_var} is not set"));
}
if let Some(headers) = env_http_headers {
for env_var in headers.values() {
if !env_var_present(env_var) {
missing_env
.push(format!("{name}: header env var {env_var} is not set"));
}
}
}
if let Err(err) = mcp_http_probe_url(url).await {
let detail = format!("{name}: {url} ({err})");
if server.required {
unreachable_required_http.push(detail);
} else {
unreachable_optional_http.push(detail);
}
}
}
}
}
details.push(format!("configured servers: {}", servers.len()));
details.push(format!("disabled servers: {disabled}"));
for (transport, count) in transport_counts {
details.push(format!("{transport} servers: {count}"));
}
details.extend(missing_env.iter().cloned());
details.extend(
unreachable_required_http
.iter()
.map(|detail| format!("required reachability failed: {detail}")),
);
details.extend(
unreachable_optional_http
.iter()
.map(|detail| format!("optional reachability failed: {detail}")),
);
let required_missing = servers.iter().any(|(name, server)| {
server.required
&& missing_env
.iter()
.any(|missing| missing.starts_with(&format!("{name}:")))
});
let status = if required_missing || !unreachable_required_http.is_empty() {
CheckStatus::Fail
} else if !missing_env.is_empty() || !unreachable_optional_http.is_empty() {
CheckStatus::Warning
} else {
CheckStatus::Ok
};
let summary = match status {
CheckStatus::Ok => "MCP configuration is locally consistent",
CheckStatus::Warning => "MCP configuration has optional issues",
CheckStatus::Fail => "MCP configuration has failing required inputs or reachability",
};
let mut check = DoctorCheck::new("mcp.config", "mcp", status, summary).details(details);
if status != CheckStatus::Ok {
check = check.remediation("Set the missing MCP env vars or disable the affected server.");
}
check
}
fn sandbox_check(config: &Config, arg0_paths: &Arg0DispatchPaths) -> DoctorCheck {
let mut details = Vec::new();
details.push(format!(
"approval policy: {:?}",
config.permissions.approval_policy.value()
));
let file_system_sandbox = config.permissions.file_system_sandbox_policy();
details.push(format!("filesystem sandbox: {}", file_system_sandbox.kind));
details.push(format!(
"network sandbox: {}",
config.permissions.network_sandbox_policy()
));
push_path_detail(
&mut details,
"codex-linux-sandbox helper",
arg0_paths.codex_linux_sandbox_exe.as_deref(),
);
push_path_detail(
&mut details,
"execve wrapper helper",
arg0_paths.main_execve_wrapper_exe.as_deref(),
);
let mut status = CheckStatus::Ok;
let mut summary = "sandbox configuration is readable".to_string();
if let Some(helper) = arg0_paths.codex_linux_sandbox_exe.as_deref()
&& !helper.exists()
{
status = CheckStatus::Warning;
summary = "Linux sandbox helper path does not exist".to_string();
}
DoctorCheck::new("sandbox.helpers", "sandbox", status, summary).details(details)
}
#[derive(Clone, Debug)]
struct TerminalCheckInputs {
info: TerminalInfo,
env: BTreeMap<String, String>,
present_env: BTreeSet<String>,
no_color_flag: bool,
stdin_is_terminal: bool,
stdout_is_terminal: bool,
stderr_is_terminal: bool,
stream_supports_color: bool,
terminal_size: Result<(u16, u16), String>,
tmux_details: Vec<String>,
}
impl TerminalCheckInputs {
fn detect(no_color_flag: bool) -> Self {
let names = terminal_env_names();
let (env, present_env) = collect_env_snapshot(&names);
let terminal_size = crossterm::terminal::size().map_err(|err| err.to_string());
let info = terminal_info();
let tmux_details = if matches!(info.multiplexer, Some(Multiplexer::Tmux { .. })) {
tmux_diagnostic_details()
} else {
Vec::new()
};
Self {
info,
env,
present_env,
no_color_flag,
stdin_is_terminal: std::io::stdin().is_terminal(),
stdout_is_terminal: std::io::stdout().is_terminal(),
stderr_is_terminal: std::io::stderr().is_terminal(),
stream_supports_color: supports_color::on(Stream::Stdout).is_some(),
terminal_size,
tmux_details,
}
}
fn env_value(&self, name: &str) -> Option<&str> {
self.env.get(name).map(String::as_str)
}
fn env_present(&self, name: &str) -> bool {
self.present_env.contains(name)
}
}
fn terminal_check(no_color_flag: bool) -> DoctorCheck {
terminal_check_from_inputs(TerminalCheckInputs::detect(no_color_flag))
}
fn terminal_check_from_inputs(inputs: TerminalCheckInputs) -> DoctorCheck {
let info = &inputs.info;
let name = info.name;
let mut details = vec![format!("terminal: {}", terminal_name(info))];
if let Some(term_program) = info.term_program.as_deref() {
details.push(format!("TERM_PROGRAM: {term_program}"));
}
if let Some(version) = info.version.as_deref() {
details.push(format!("terminal version: {version}"));
}
if let Some(term) = info.term.as_deref() {
details.push(format!("TERM: {term}"));
}
if let Some(multiplexer) = info.multiplexer.as_ref() {
details.push(format!("multiplexer: {}", multiplexer_name(multiplexer)));
}
details.push(format!("stdin is terminal: {}", inputs.stdin_is_terminal));
details.push(format!("stdout is terminal: {}", inputs.stdout_is_terminal));
details.push(format!("stderr is terminal: {}", inputs.stderr_is_terminal));
match &inputs.terminal_size {
Ok((columns, rows)) => details.push(format!("terminal size: {columns}x{rows}")),
Err(err) => details.push(format!("terminal size: unavailable ({err})")),
}
push_terminal_env_values(&mut details, &inputs, TERMINAL_DIMENSION_ENV_VARS);
details.push(format!("color output: {}", color_output_summary(&inputs)));
push_terminal_env_values(&mut details, &inputs, COLOR_ENV_VARS);
let terminfo_warning = push_terminfo_details(&mut details, &inputs);
let locale = effective_locale(&inputs);
if let Some(locale) = locale.as_ref() {
details.push(format!("effective locale: {locale}"));
}
push_presence_env_values(&mut details, &inputs, REMOTE_TERMINAL_ENV_VARS);
details.extend(inputs.tmux_details.iter().cloned());
let locale_warning = locale.as_deref().is_some_and(is_non_utf8_locale);
let mut issues = Vec::new();
if matches!(name, TerminalName::Dumb) {
issues.push(
DoctorIssue::new(
CheckStatus::Fail,
"TERM=dumb - colors and cursor control are disabled",
)
.measured("TERM=dumb")
.expected("TERM=xterm-256color or another real terminal type")
.remedy("set TERM to a real value, for example xterm-256color")
.field("TERM"),
);
}
if locale_warning {
let measured = locale.unwrap_or_else(|| "unknown".to_string());
issues.push(
DoctorIssue::new(
CheckStatus::Warning,
"locale is not UTF-8 - unicode glyphs may render incorrectly",
)
.measured(measured)
.expected("UTF-8 locale, for example en_US.UTF-8")
.remedy("export LANG=en_US.UTF-8 or another UTF-8 locale")
.field("effective locale"),
);
}
if terminfo_warning {
issues.push(
DoctorIssue::new(
CheckStatus::Fail,
"TERMINFO unreadable - terminal capabilities are unknown",
)
.expected("readable terminfo file or directory")
.remedy("check that $TERMINFO points to a readable directory")
.field("TERMINFO")
.field("TERMINFO_DIRS entry"),
);
}
issues.extend(terminal_size_issues(&inputs));
let status = issues
.iter()
.map(|issue| issue.severity)
.max()
.unwrap_or(CheckStatus::Ok);
let summary = issues
.first()
.map(|issue| issue.cause.as_str())
.unwrap_or("terminal metadata was detected");
let mut check = DoctorCheck::new("terminal.env", "terminal", status, summary).details(details);
for issue in issues {
check = check.issue(issue);
}
check
}
fn terminal_name(info: &TerminalInfo) -> &'static str {
match info.name {
TerminalName::AppleTerminal => "Apple Terminal",
TerminalName::Ghostty => "Ghostty",
TerminalName::Iterm2 => "iTerm2",
TerminalName::WarpTerminal => "Warp",
TerminalName::VsCode => "VS Code",
TerminalName::WezTerm => "WezTerm",
TerminalName::Kitty => "kitty",
TerminalName::Alacritty => "Alacritty",
TerminalName::Konsole => "Konsole",
TerminalName::GnomeTerminal => "GNOME Terminal",
TerminalName::Vte => "VTE",
TerminalName::WindowsTerminal => "Windows Terminal",
TerminalName::Dumb => "dumb",
TerminalName::Unknown => "unknown",
}
}
fn multiplexer_name(multiplexer: &Multiplexer) -> String {
match multiplexer {
Multiplexer::Tmux { version } => match version {
Some(version) => format!("tmux {version}"),
None => "tmux".to_string(),
},
Multiplexer::Zellij { version } => match version {
Some(version) => format!("zellij {version}"),
None => "zellij".to_string(),
},
}
}
fn terminal_env_names() -> BTreeSet<&'static str> {
let mut names = BTreeSet::from(["TERM", "TERM_PROGRAM", "TERM_PROGRAM_VERSION"]);
names.extend(COLOR_ENV_VARS.iter().copied());
names.extend(TERMINAL_DIMENSION_ENV_VARS.iter().copied());
names.extend(TERMINFO_ENV_VARS.iter().copied());
names.extend(LOCALE_ENV_VARS.iter().copied());
names.extend(REMOTE_TERMINAL_ENV_VARS.iter().copied());
names
}
fn collect_env_snapshot(
names: &BTreeSet<&'static str>,
) -> (BTreeMap<String, String>, BTreeSet<String>) {
let mut values = BTreeMap::new();
let mut present = BTreeSet::new();
for name in names {
if let Some(raw) = env::var_os(name) {
present.insert((*name).to_string());
let value = raw.to_string_lossy().trim().to_string();
if !value.is_empty() {
values.insert((*name).to_string(), value);
}
}
}
(values, present)
}
fn push_terminal_env_values(
details: &mut Vec<String>,
inputs: &TerminalCheckInputs,
names: &[&str],
) {
for name in names {
if let Some(value) = inputs.env_value(name) {
details.push(format!("{name}: {value}"));
} else if inputs.env_present(name) {
details.push(format!("{name}: present"));
}
}
}
fn push_presence_env_values(
details: &mut Vec<String>,
inputs: &TerminalCheckInputs,
names: &[&str],
) {
for name in names {
if inputs.env_present(name) {
details.push(format!("{name}: present"));
}
}
}
fn color_output_summary(inputs: &TerminalCheckInputs) -> String {
if should_enable_color(
inputs.no_color_flag,
inputs.env_present("NO_COLOR"),
inputs.env_value("TERM"),
inputs.stdout_is_terminal,
inputs.stream_supports_color,
) {
return "enabled".to_string();
}
let reason = if inputs.no_color_flag {
"--no-color"
} else if inputs.env_present("NO_COLOR") {
"NO_COLOR"
} else if inputs.env_value("TERM") == Some("dumb") {
"TERM=dumb"
} else if !inputs.stdout_is_terminal {
"stdout is not a terminal"
} else if !inputs.stream_supports_color {
"terminal color support not detected"
} else {
"disabled"
};
format!("disabled ({reason})")
}
fn push_terminfo_details(details: &mut Vec<String>, inputs: &TerminalCheckInputs) -> bool {
let mut has_warning = false;
if let Some(raw) = inputs.env_value("TERMINFO") {
let path = PathBuf::from(raw);
let (status, warning) = terminal_path_readiness(&path);
details.push(format!("TERMINFO: {} ({status})", path.display()));
has_warning |= warning;
}
if let Some(raw) = inputs.env_value("TERMINFO_DIRS") {
for path in env::split_paths(raw).filter(|path| !path.as_os_str().is_empty()) {
let (status, warning) = terminal_path_readiness(&path);
details.push(format!(
"TERMINFO_DIRS entry: {} ({status})",
path.display()
));
has_warning |= warning;
}
} else if inputs.env_present("TERMINFO_DIRS") {
details.push("TERMINFO_DIRS: present".to_string());
}
has_warning
}
fn terminal_path_readiness(path: &Path) -> (String, bool) {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_dir() => match std::fs::read_dir(path) {
Ok(_) => ("dir".to_string(), false),
Err(err) => (format!("dir unreadable: {err}"), true),
},
Ok(metadata) if metadata.is_file() => match read_probe_file(path) {
Ok(_) => ("file".to_string(), false),
Err(err) => (format!("file unreadable: {err}"), true),
},
Ok(_) => ("not a file or directory".to_string(), true),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => ("missing".to_string(), true),
Err(err) => (err.to_string(), true),
}
}
fn effective_locale(inputs: &TerminalCheckInputs) -> Option<String> {
LOCALE_ENV_VARS
.iter()
.find_map(|name| inputs.env_value(name).map(ToString::to_string))
}
fn is_non_utf8_locale(locale: &str) -> bool {
let locale = locale.to_ascii_lowercase();
!(locale.contains("utf-8") || locale.contains("utf8"))
}
fn terminal_size_issues(inputs: &TerminalCheckInputs) -> Vec<DoctorIssue> {
let mut issues = Vec::new();
if let Ok((columns, rows)) = inputs.terminal_size {
if columns > 0 && columns < NARROW_TERMINAL_COLUMNS {
issues.push(
DoctorIssue::new(
CheckStatus::Warning,
format!("width {columns} cols - output may wrap (recommended >=80)"),
)
.measured(format!("{columns} x {rows}"))
.expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns"))
.remedy("resize the window to at least 80 columns")
.field("terminal size"),
);
}
if rows > 0 && rows < NARROW_TERMINAL_ROWS {
issues.push(
DoctorIssue::new(
CheckStatus::Warning,
format!("height {rows} rows - content may scroll off (recommended >=24)"),
)
.measured(format!("{columns} x {rows}"))
.expected(format!(">= {NARROW_TERMINAL_ROWS} rows"))
.remedy("resize the window to at least 24 rows")
.field("terminal size"),
);
}
}
if let Some(columns) = inputs
.env_value("COLUMNS")
.and_then(|columns| columns.parse::<u16>().ok())
&& columns > 0
&& columns < NARROW_TERMINAL_COLUMNS
{
issues.push(
DoctorIssue::new(
CheckStatus::Warning,
format!("COLUMNS={columns} - output may wrap (recommended >=80)"),
)
.measured(format!("{columns} columns"))
.expected(format!(">= {NARROW_TERMINAL_COLUMNS} columns"))
.remedy("resize the window to at least 80 columns")
.field("COLUMNS"),
);
}
if let Some(rows) = inputs
.env_value("LINES")
.and_then(|rows| rows.parse::<u16>().ok())
&& rows > 0
&& rows < NARROW_TERMINAL_ROWS
{
issues.push(
DoctorIssue::new(
CheckStatus::Warning,
format!("LINES={rows} - content may scroll off (recommended >=24)"),
)
.measured(format!("{rows} rows"))
.expected(format!(">= {NARROW_TERMINAL_ROWS} rows"))
.remedy("resize the window to at least 24 rows")
.field("LINES"),
);
}
issues
}
fn tmux_diagnostic_details() -> Vec<String> {
let mut details = Vec::new();
push_tmux_display_detail(&mut details, "tmux client termtype", "#{client_termtype}");
push_tmux_display_detail(&mut details, "tmux client termname", "#{client_termname}");
for option in TMUX_OPTION_NAMES {
let value = tmux_option_value(option).unwrap_or_else(|| "unavailable".to_string());
details.push(format!("tmux {option}: {value}"));
}
details
}
fn push_tmux_display_detail(details: &mut Vec<String>, label: &str, format: &str) {
if let Some(value) = tmux_display_message(format) {
details.push(format!("{label}: {value}"));
}
}
fn tmux_option_value(option: &str) -> Option<String> {
let output = Command::new("tmux")
.args(["show-options", "-gqv", option])
.output()
.ok()?;
if !output.status.success() {
return None;
}
non_empty_trimmed(String::from_utf8(output.stdout).ok()?)
}
fn tmux_display_message(format: &str) -> Option<String> {
let output = Command::new("tmux")
.args(["display-message", "-p", format])
.output()
.ok()?;
if !output.status.success() {
return None;
}
non_empty_trimmed(String::from_utf8(output.stdout).ok()?)
}
fn non_empty_trimmed(value: String) -> Option<String> {
let value = value.trim().to_string();
if value.is_empty() { None } else { Some(value) }
}
async fn state_check(config: &Config) -> DoctorCheck {
let mut details = Vec::new();
path_readiness(&mut details, "CODEX_HOME", &config.codex_home);
path_readiness(&mut details, "log dir", &config.log_dir);
path_readiness(&mut details, "sqlite home", &config.sqlite_home);
let mut integrity_failures = Vec::new();
for db in codex_state::runtime_db_paths(&config.sqlite_home) {
path_readiness(&mut details, db.label, &db.path);
sqlite_integrity_detail(&mut details, &mut integrity_failures, db.label, &db.path).await;
}
rollout_stats_details(&mut details, &config.codex_home);
standalone_release_cache_details(&mut details);
let status = if integrity_failures.is_empty() {
CheckStatus::Ok
} else {
CheckStatus::Fail
};
let summary = if status == CheckStatus::Ok {
"state paths and databases are inspectable"
} else {
"state database integrity check failed"
};
let mut check = DoctorCheck::new("state.paths", "state", status, summary).details(details);
if status == CheckStatus::Fail {
check = check
.remediation("Back up CODEX_HOME, then remove or repair the affected SQLite database.");
}
check
}
async fn sqlite_integrity_detail(
details: &mut Vec<String>,
integrity_failures: &mut Vec<String>,
label: &str,
path: &Path,
) {
if !path.is_file() {
details.push(format!("{label} integrity: skipped (missing)"));
return;
}
match codex_state::sqlite_integrity_check(path).await {
Ok(rows) if rows.iter().all(|row| row == "ok") => {
details.push(format!("{label} integrity: ok"));
}
Ok(rows) => {
let message = format!("{label} integrity: {}", rows.join("; "));
integrity_failures.push(message.clone());
details.push(message);
}
Err(err) => {
let message = format!("{label} integrity: {err}");
integrity_failures.push(message.clone());
details.push(message);
}
}
}
fn rollout_stats_details(details: &mut Vec<String>, codex_home: &Path) {
let active = collect_rollout_stats(&codex_home.join("sessions"));
let archived = collect_rollout_stats(&codex_home.join("archived_sessions"));
push_rollout_stats_detail(details, "active rollout files", active);
push_rollout_stats_detail(details, "archived rollout files", archived);
}
fn push_rollout_stats_detail(details: &mut Vec<String>, label: &str, stats: RolloutStats) {
match stats.error {
Some(error) => details.push(format!("{label}: scan failed ({error})")),
None => details.push(format!(
"{label}: {} files, {} total bytes, {} average bytes",
stats.files,
stats.total_bytes,
stats.average_bytes()
)),
}
}
#[derive(Default)]
struct RolloutStats {
files: u64,
total_bytes: u64,
error: Option<String>,
}
impl RolloutStats {
fn average_bytes(&self) -> u64 {
if self.files == 0 {
0
} else {
self.total_bytes / self.files
}
}
}
fn collect_rollout_stats(root: &Path) -> RolloutStats {
let mut stats = RolloutStats::default();
collect_rollout_stats_inner(root, &mut stats);
stats
}
fn collect_rollout_stats_inner(path: &Path, stats: &mut RolloutStats) {
if stats.error.is_some() {
return;
}
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return,
Err(err) => {
stats.error = Some(err.to_string());
return;
}
};
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(err) => {
stats.error = Some(err.to_string());
return;
}
};
let path = entry.path();
let metadata = match entry.metadata() {
Ok(metadata) => metadata,
Err(err) => {
stats.error = Some(err.to_string());
return;
}
};
if metadata.is_dir() {
collect_rollout_stats_inner(&path, stats);
} else if metadata.is_file() && is_rollout_file(&path) {
stats.files += 1;
stats.total_bytes = stats.total_bytes.saturating_add(metadata.len());
}
}
}
fn is_rollout_file(path: &Path) -> bool {
path.extension() == Some(OsStr::new("jsonl"))
&& path
.file_name()
.and_then(OsStr::to_str)
.is_some_and(|name| name.starts_with("rollout-"))
}
async fn websocket_reachability_check(
config: &Config,
auth_manager: Option<Arc<AuthManager>>,
) -> DoctorCheck {
let provider = &config.model_provider;
let mut details = vec![
format!("model provider: {}", config.model_provider_id),
format!("provider name: {}", provider.name),
format!("wire API: {}", provider.wire_api),
format!("supports websockets: {}", provider.supports_websockets),
];
push_proxy_env_details(&mut details);
if !provider.supports_websockets {
return DoctorCheck::new(
"network.websocket_reachability",
"websocket",
CheckStatus::Ok,
"Responses WebSocket is not enabled for the active provider",
)
.details(details);
}
details.push(format!(
"connect timeout: {} ms",
provider.websocket_connect_timeout().as_millis()
));
let runtime_provider = create_model_provider(provider.clone(), auth_manager);
let auth = runtime_provider.auth().await;
details.push(format!(
"auth mode: {}",
auth.as_ref().map(auth_mode_name).unwrap_or("none")
));
let api_provider = match runtime_provider.api_provider().await {
Ok(api_provider) => api_provider,
Err(err) => {
return websocket_probe_warning(
"Responses WebSocket provider setup failed",
details,
format!("provider setup failed: {err}"),
);
}
};
match api_provider.websocket_url_for_path("responses") {
Ok(url) => {
details.push(format!("endpoint: {url}"));
if let Some(host) = url.host_str()
&& let Some(port) = url.port_or_known_default()
{
details.extend(dns_address_family_details(host, port).await);
}
}
Err(err) => {
return websocket_probe_warning(
"Responses WebSocket endpoint could not be built",
details,
format!("endpoint build failed: {err}"),
);
}
}
let api_auth = match runtime_provider.api_auth().await {
Ok(api_auth) => api_auth,
Err(err) => {
return websocket_probe_warning(
"Responses WebSocket auth could not be resolved",
details,
format!("auth resolution failed: {err}"),
);
}
};
let mut extra_headers = HeaderMap::new();
extra_headers.insert(
OPENAI_BETA_HEADER,
HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE),
);
let client = ResponsesWebsocketClient::new(api_provider, api_auth);
match tokio::time::timeout(
provider.websocket_connect_timeout(),
client.probe_handshake(
extra_headers,
default_headers(),
WEBSOCKET_IMMEDIATE_CLOSE_GRACE,
),
)
.await
{
Ok(Ok(probe)) => {
details.push(format!("handshake result: HTTP {}", probe.status));
details.push(format!("reasoning header: {}", probe.reasoning_included));
details.push(format!(
"models etag present: {}",
probe.models_etag_present
));
details.push(format!(
"server model present: {}",
probe.server_model_present
));
if let Some(close) = probe.immediate_close {
details.push(format!("immediate close code: {}", close.code));
details.push(format!("immediate close reason: {}", close.reason));
return DoctorCheck::new(
"network.websocket_reachability",
"websocket",
CheckStatus::Warning,
"Responses WebSocket closed immediately after handshake",
)
.details(details)
.remediation(
"Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.",
);
}
DoctorCheck::new(
"network.websocket_reachability",
"websocket",
CheckStatus::Ok,
"Responses WebSocket handshake succeeded",
)
.details(details)
}
Ok(Err(err)) => websocket_probe_warning(
"Responses WebSocket failed; HTTPS fallback may still work",
details,
websocket_error_detail(&err),
),
Err(_) => websocket_probe_warning(
"Responses WebSocket timed out; HTTPS fallback may still work",
details,
"handshake timed out".to_string(),
),
}
}
fn websocket_probe_warning(
summary: &'static str,
mut details: Vec<String>,
error_detail: String,
) -> DoctorCheck {
details.push(error_detail);
DoctorCheck::new(
"network.websocket_reachability",
"websocket",
CheckStatus::Warning,
summary,
)
.details(details)
.remediation("Check proxy, VPN, firewall, DNS, custom CA, and WebSocket policy support.")
}
fn websocket_error_detail(err: &ApiError) -> String {
match err {
ApiError::Transport(transport) => format!("handshake transport error: {transport}"),
ApiError::Api { status, message } => {
format!("handshake API error: {status} {message}")
}
ApiError::Stream(message) => format!("handshake stream error: {message}"),
ApiError::ContextWindowExceeded
| ApiError::QuotaExceeded
| ApiError::UsageNotIncluded
| ApiError::Retryable { .. }
| ApiError::RateLimit(_)
| ApiError::InvalidRequest { .. }
| ApiError::CyberPolicy { .. }
| ApiError::ServerOverloaded => format!("handshake error: {err}"),
}
}
fn auth_mode_name(auth: &CodexAuth) -> &'static str {
match auth.auth_mode() {
codex_app_server_protocol::AuthMode::ApiKey => "api_key",
codex_app_server_protocol::AuthMode::Chatgpt => "chatgpt",
codex_app_server_protocol::AuthMode::ChatgptAuthTokens => "chatgpt_auth_tokens",
codex_app_server_protocol::AuthMode::AgentIdentity => "agent_identity",
}
}
async fn dns_address_family_details(host: &str, port: u16) -> Vec<String> {
match tokio::net::lookup_host((host, port)).await {
Ok(addresses) => {
let addresses = addresses.collect::<Vec<_>>();
let ipv4_count = addresses
.iter()
.filter(|address| matches!(address.ip(), IpAddr::V4(_)))
.count();
let ipv6_count = addresses
.iter()
.filter(|address| matches!(address.ip(), IpAddr::V6(_)))
.count();
let first_family = addresses
.first()
.map(|address| match address.ip() {
IpAddr::V4(_) => "IPv4",
IpAddr::V6(_) => "IPv6",
})
.unwrap_or("none");
vec![format!(
"DNS: {ipv4_count} IPv4, {ipv6_count} IPv6, first {first_family}"
)]
}
Err(err) => vec![format!("DNS: lookup failed ({err})")],
}
}
fn fallback_state_check() -> DoctorCheck {
let codex_home = find_codex_home();
match codex_home {
Ok(path) => DoctorCheck::new(
"state.paths",
"state",
CheckStatus::Ok,
"CODEX_HOME was resolved without config",
)
.detail(format!("CODEX_HOME: {}", path.display())),
Err(err) => DoctorCheck::new(
"state.paths",
"state",
CheckStatus::Warning,
"CODEX_HOME could not be resolved",
)
.detail(err.to_string()),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ReachabilityPlan {
description: String,
endpoints: Vec<ReachabilityEndpoint>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
struct ReachabilityEndpoint {
label: String,
url: String,
required: bool,
route_probe_url: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ProviderAuthReachabilityMode {
NotRequired,
ApiKey,
Chatgpt,
}
impl ProviderAuthReachabilityMode {
fn description(self) -> &'static str {
match self {
Self::NotRequired => "provider auth",
Self::ApiKey => "API key auth",
Self::Chatgpt => "ChatGPT auth",
}
}
}
fn provider_reachability_plan(config: &Config) -> ReachabilityPlan {
let stored_auth =
load_auth_dot_json(&config.codex_home, config.cli_auth_credentials_store_mode)
.ok()
.flatten();
let mode = provider_auth_reachability_mode_from_auth(
config.model_provider.requires_openai_auth,
env_var_present,
stored_auth.as_ref(),
);
provider_reachability_plan_from_parts(
mode,
&config.model_provider_id,
&config.model_provider.name,
config.model_provider.base_url.as_deref(),
config.model_provider.query_params.as_ref(),
config.model_provider.is_amazon_bedrock(),
&config.chatgpt_base_url,
)
}
fn default_reachability_plan() -> ReachabilityPlan {
provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::Chatgpt,
"openai",
"OpenAI",
/*provider_base_url*/ None,
/*provider_query_params*/ None,
/*is_amazon_bedrock*/ false,
"https://chatgpt.com/backend-api/",
)
}
fn provider_auth_reachability_mode_from_auth(
requires_openai_auth: bool,
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) {
return ProviderAuthReachabilityMode::ApiKey;
}
if env_var_present(CODEX_ACCESS_TOKEN_ENV_VAR) {
return ProviderAuthReachabilityMode::Chatgpt;
}
match stored_auth.map(stored_auth_mode_value) {
Some(codex_app_server_protocol::AuthMode::ApiKey) => ProviderAuthReachabilityMode::ApiKey,
Some(
codex_app_server_protocol::AuthMode::Chatgpt
| codex_app_server_protocol::AuthMode::ChatgptAuthTokens
| codex_app_server_protocol::AuthMode::AgentIdentity,
)
| None => ProviderAuthReachabilityMode::Chatgpt,
}
}
fn provider_reachability_plan_from_parts(
mode: ProviderAuthReachabilityMode,
provider_id: &str,
provider_name: &str,
provider_base_url: Option<&str>,
provider_query_params: Option<&HashMap<String, String>>,
is_amazon_bedrock: bool,
chatgpt_base_url: &str,
) -> ReachabilityPlan {
let provider_route_probe_url = provider_base_url
.or_else(|| {
(mode == ProviderAuthReachabilityMode::ApiKey).then_some("https://api.openai.com/v1")
})
.and_then(|url| {
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 {
label: format!("{provider_id} API"),
url: provider_base_url
.unwrap_or("https://api.openai.com/v1")
.to_string(),
required: true,
route_probe_url: provider_route_probe_url,
}],
ProviderAuthReachabilityMode::Chatgpt => vec![ReachabilityEndpoint {
label: "ChatGPT".to_string(),
url: chatgpt_base_url.to_string(),
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(),
};
ReachabilityPlan {
description: mode.description().to_string(),
endpoints,
}
}
fn should_probe_models_route(provider_name: &str, base_url: &str, is_amazon_bedrock: bool) -> bool {
!is_amazon_bedrock && !is_azure_responses_provider(provider_name, Some(base_url))
}
fn provider_url_for_path(
base_url: &str,
path: &str,
query_params: Option<&HashMap<String, String>>,
) -> String {
let base = base_url.trim_end_matches('/');
let path = path.trim_start_matches('/');
let mut url = if path.is_empty() {
base.to_string()
} else {
format!("{base}/{path}")
};
if let Some(params) = query_params
&& !params.is_empty()
{
let separator = if url.contains('?') { '&' } else { '?' };
url.push(separator);
url.push_str(
&params
.iter()
.map(|(key, value)| format!("{key}={value}"))
.collect::<Vec<_>>()
.join("&"),
);
}
url
}
async fn provider_reachability_check(plan: ReachabilityPlan) -> DoctorCheck {
let mut details = vec![format!("reachability mode: {}", plan.description)];
if plan.endpoints.is_empty() {
details.push("active provider endpoint: none configured".to_string());
return DoctorCheck::new(
"network.provider_reachability",
"reachability",
CheckStatus::Ok,
"active provider has no HTTP endpoint to probe",
)
.details(details);
}
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 {
Ok(status) => details.push(format!(
"{} base URL: {} reachable ({status})",
endpoint.label, endpoint.url
)),
Err(err) => {
let requirement = if endpoint.required {
"required"
} else {
"optional"
};
details.push(format!(
"{} base URL: {} {err} ({requirement})",
endpoint.label, endpoint.url
));
if endpoint.required {
failures.push(endpoint.url);
} else {
optional_failures.push(endpoint.url);
}
continue;
}
}
let Some(route_probe_url) = endpoint.route_probe_url.as_deref() else {
continue;
};
match provider_route_probe_url(route_probe_url).await {
RouteProbeOutcome::Ok(status) => {
details.push(format!(
"{} route probe: {route_probe_url} route exists ({status})",
endpoint.label,
));
}
RouteProbeOutcome::Warning(status) => {
details.push(format!(
"{} route probe: {route_probe_url} returned {status} (warning)",
endpoint.label,
));
route_warnings.push(route_probe_url.to_string());
}
RouteProbeOutcome::Fail(status) => {
details.push(format!(
"{} route probe: {route_probe_url} returned {status} (required)",
endpoint.label,
));
route_failures.push(route_probe_url.to_string());
issues.push(
DoctorIssue::new(
CheckStatus::Fail,
"provider base URL route returned 404 - verify the configured API prefix",
)
.measured(format!("{route_probe_url} returned {status}"))
.expected("GET /models returns 2xx, 401, or 403")
.remedy("Set base_url to the provider API root, for example https://api.openai.com/v1")
.field("route probe"),
);
}
RouteProbeOutcome::TransportError(err) => {
details.push(format!(
"{} route probe: {route_probe_url} {err} (required)",
endpoint.label,
));
route_failures.push(route_probe_url.to_string());
issues.push(
DoctorIssue::new(
CheckStatus::Fail,
"provider route probe could not connect - verify network access to the provider API",
)
.measured(format!("{route_probe_url} {err}"))
.expected("GET /models completes")
.remedy("Check proxy, VPN, firewall, DNS, and custom CA configuration.")
.field("route probe"),
);
}
}
}
let (status, summary) = provider_reachability_outcome(
failures.len() + route_failures.len(),
optional_failures.len() + route_warnings.len(),
);
let mut check = DoctorCheck::new(
"network.provider_reachability",
"reachability",
status,
summary,
)
.details(details);
for issue in issues {
check = check.issue(issue);
}
if status != CheckStatus::Ok {
check = check.remediation("Check proxy, VPN, firewall, DNS, and custom CA configuration.");
}
check
}
enum RouteProbeOutcome {
Ok(String),
Warning(String),
Fail(String),
TransportError(String),
}
async fn provider_route_probe_url(url: &str) -> RouteProbeOutcome {
match http_get_probe_status_with_timeout(url, Duration::from_secs(3)).await {
Ok(status) if (200..300).contains(&status) || matches!(status, 401 | 403) => {
RouteProbeOutcome::Ok(format!("HTTP {status}"))
}
Ok(404) => RouteProbeOutcome::Fail("HTTP 404".to_string()),
Ok(status) => RouteProbeOutcome::Warning(format!("HTTP {status}")),
Err(err) => RouteProbeOutcome::TransportError(err),
}
}
fn provider_reachability_outcome(
required_failures: usize,
warnings: usize,
) -> (CheckStatus, &'static str) {
match (required_failures, warnings) {
(0, 0) => (
CheckStatus::Ok,
"active provider endpoints are reachable over HTTP",
),
(0, _) => (
CheckStatus::Warning,
"provider endpoint checks returned warnings",
),
(_, _) => (
CheckStatus::Fail,
"one or more required provider endpoints are unreachable over HTTP",
),
}
}
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
}
async fn mcp_http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
match http_probe_url_with_timeout(url, timeout).await {
Ok(status) => Ok(status),
Err(head_err) => match http_get_probe_url_with_timeout(url, timeout).await {
Ok(status) => Ok(status),
Err(get_err) => Err(format!("HEAD {head_err}; GET {get_err}")),
},
}
}
async fn http_probe_url_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
let response = build_reqwest_client()
.head(url)
.timeout(timeout)
.send()
.await
.map_err(|err| {
if err.is_timeout() {
"request timed out".to_string()
} else if err.is_connect() {
"connect failed".to_string()
} else if err.is_builder() {
"request could not be built".to_string()
} else {
err.to_string()
}
})?;
Ok(format!("HTTP {}", response.status().as_u16()))
}
async fn http_get_probe_url_with_timeout(url: &str, timeout: Duration) -> Result<String, String> {
http_get_probe_status_with_timeout(url, timeout)
.await
.map(|status| format!("HTTP {status}"))
}
async fn http_get_probe_status_with_timeout(url: &str, timeout: Duration) -> Result<u16, String> {
let response = build_reqwest_client()
.get(url)
.timeout(timeout)
.send()
.await
.map_err(|err| {
if err.is_timeout() {
"request timed out".to_string()
} else if err.is_connect() {
"connect failed".to_string()
} else if err.is_builder() {
"request could not be built".to_string()
} else {
err.to_string()
}
})?;
Ok(response.status().as_u16())
}
fn stdio_command_resolves(
command: &str,
cwd: Option<&Path>,
server_env: Option<&HashMap<String, String>>,
) -> Result<(), String> {
let command_path = Path::new(command);
if command_path.is_absolute() {
return executable_path_exists(command_path);
}
if command_path.components().count() > 1 {
let base = cwd
.map(Path::to_path_buf)
.or_else(|| env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."));
return executable_path_exists(&base.join(command_path));
}
let Some(path_env) = server_env
.and_then(|env| env.get("PATH").map(String::as_str))
.map(std::ffi::OsString::from)
.or_else(|| env::var_os("PATH"))
else {
return Err("PATH is not set".to_string());
};
for dir in env::split_paths(&path_env) {
let candidate = dir.join(command);
if executable_path_exists(&candidate).is_ok() {
return Ok(());
}
#[cfg(windows)]
{
let pathext = env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".to_string());
for extension in pathext.split(';').filter(|extension| !extension.is_empty()) {
let candidate = dir.join(format!("{command}{extension}"));
if executable_path_exists(&candidate).is_ok() {
return Ok(());
}
}
}
}
Err("not found on PATH".to_string())
}
fn executable_path_exists(path: &Path) -> Result<(), String> {
match std::fs::metadata(path) {
Ok(metadata) if metadata.is_file() => executable_file_permission(path, &metadata),
Ok(_) => Err("path is not a file".to_string()),
Err(err) => Err(err.to_string()),
}
}
#[cfg(unix)]
fn executable_file_permission(path: &Path, metadata: &std::fs::Metadata) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
if metadata.permissions().mode() & 0o111 == 0 {
Err(format!("{} is not executable", path.display()))
} else {
Ok(())
}
}
#[cfg(not(unix))]
fn executable_file_permission(_path: &Path, _metadata: &std::fs::Metadata) -> Result<(), String> {
Ok(())
}
fn path_readiness(details: &mut Vec<String>, label: &str, path: &Path) {
match std::fs::metadata(path) {
Ok(metadata) => {
let kind = if metadata.is_dir() {
"dir"
} else if metadata.is_file() {
"file"
} else {
"other"
};
details.push(format!("{label}: {} ({kind})", path.display()));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
details.push(format!("{label}: {} (missing)", path.display()));
}
Err(err) => details.push(format!("{label}: {} ({err})", path.display())),
}
}
fn standalone_release_cache_details(details: &mut Vec<String>) {
let context = InstallContext::current();
let InstallMethod::Standalone { release_dir, .. } = &context.method else {
return;
};
let Some(releases_dir) = release_dir.parent() else {
return;
};
let Ok(entries) = std::fs::read_dir(&releases_dir) else {
return;
};
let release_count = entries.filter_map(Result::ok).count();
details.push(format!(
"standalone release cache: {release_count} entries in {}",
releases_dir.display()
));
}
fn push_path_detail(details: &mut Vec<String>, label: &str, path: Option<&Path>) {
match path {
Some(path) => details.push(format!("{label}: {}", path.display())),
None => details.push(format!("{label}: none")),
}
}
fn push_env_path_detail(details: &mut Vec<String>, label: &str, name: &str) {
match env::var_os(name) {
Some(path) => details.push(format!("{label}: {}", PathBuf::from(path).display())),
None => details.push(format!("{label}: not set")),
}
}
fn env_var_present(name: &str) -> bool {
env::var_os(name).is_some_and(|value| !value.is_empty())
}
fn human_output_options(command: &DoctorCommand) -> HumanOutputOptions {
let term = env::var("TERM").ok();
let color_enabled = should_enable_color(
command.no_color,
env::var_os("NO_COLOR").is_some(),
term.as_deref(),
std::io::stdout().is_terminal(),
supports_color::on(Stream::Stdout).is_some(),
);
HumanOutputOptions {
show_details: !command.summary,
show_all: command.all,
ascii: command.ascii,
color_enabled,
}
}
fn should_enable_color(
no_color_flag: bool,
no_color_env: bool,
term: Option<&str>,
stdout_is_tty: bool,
stream_supports_color: bool,
) -> bool {
!no_color_flag
&& !no_color_env
&& term != Some("dumb")
&& stdout_is_tty
&& stream_supports_color
}
#[cfg(test)]
mod tests {
use std::io::Read;
use std::io::Write;
use std::net::TcpListener;
use std::sync::Mutex;
use clap::Parser;
use codex_protocol::config_types::SandboxMode;
use pretty_assertions::assert_eq;
use super::*;
#[derive(Default)]
struct RecordingProgress {
events: Mutex<Vec<String>>,
}
impl RecordingProgress {
fn events(&self) -> Vec<String> {
self.events.lock().expect("events lock").clone()
}
}
impl DoctorProgress for RecordingProgress {
fn begin(&self, label: &'static str) {
self.events
.lock()
.expect("events lock")
.push(format!("begin {label}"));
}
fn heartbeat(&self, label: &'static str, elapsed: Duration) {
self.events
.lock()
.expect("events lock")
.push(format!("heartbeat {label} {}", elapsed.as_secs()));
}
fn finish(&self, label: &'static str, status: CheckStatus) {
self.events
.lock()
.expect("events lock")
.push(format!("finish {label} {status:?}"));
}
fn settle(&self) {
self.events
.lock()
.expect("events lock")
.push("settle".to_string());
}
}
fn respond_once(listener: &TcpListener, response: &[u8]) {
let (mut stream, _) = listener.accept().expect("accept probe request");
let mut request = [0; 1024];
let _ = stream.read(&mut request);
stream.write_all(response).expect("write response");
}
#[test]
fn overall_status_prefers_fail() {
let checks = vec![
DoctorCheck::new("a", "config", CheckStatus::Warning, "warning"),
DoctorCheck::new("b", "auth", CheckStatus::Fail, "fail"),
];
assert_eq!(overall_status(&checks), CheckStatus::Fail);
}
#[test]
fn run_sync_check_notifies_progress() {
let progress_impl = Arc::new(RecordingProgress::default());
let progress: Arc<dyn DoctorProgress> = progress_impl.clone();
let check = run_sync_check("test", progress, || {
DoctorCheck::new("test", "test", CheckStatus::Ok, "ok")
});
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(
progress_impl.events(),
vec!["begin test".to_string(), "finish test Ok".to_string()]
);
}
#[tokio::test]
async fn run_async_check_notifies_progress() {
let progress_impl = Arc::new(RecordingProgress::default());
let progress: Arc<dyn DoctorProgress> = progress_impl.clone();
let check = run_async_check("test", progress, async {
DoctorCheck::new("test", "test", CheckStatus::Warning, "warning")
})
.await;
assert_eq!(check.status, CheckStatus::Warning);
assert_eq!(
progress_impl.events(),
vec!["begin test".to_string(), "finish test Warning".to_string()]
);
}
#[test]
fn compare_npm_package_roots_detects_match() {
let running = PathBuf::from("/prefix/lib/node_modules/@openai/codex");
let npm_root = PathBuf::from("/prefix/lib/node_modules");
assert_eq!(
compare_npm_package_roots(&running, &npm_root),
NpmRootCheck::Match {
package_root: npm_root.join("@openai").join("codex")
}
);
}
#[test]
fn compare_npm_package_roots_detects_mismatch() {
let running = PathBuf::from("/old/lib/node_modules/@openai/codex");
let npm_root = PathBuf::from("/new/lib/node_modules");
assert_eq!(
compare_npm_package_roots(&running, &npm_root),
NpmRootCheck::Mismatch {
running_package_root: running,
npm_package_root: npm_root.join("@openai").join("codex"),
}
);
}
#[test]
fn config_overrides_from_interactive_preserves_global_options() {
let interactive = TuiCli::parse_from([
"codex",
"--oss",
"--local-provider",
"ollama",
"--model",
"llama3.2",
"--cd",
"/tmp",
"--sandbox",
"danger-full-access",
"--ask-for-approval",
"never",
"--add-dir",
"/var/tmp",
]);
let arg0_paths = Arg0DispatchPaths {
codex_self_exe: Some(PathBuf::from("/bin/codex")),
codex_linux_sandbox_exe: Some(PathBuf::from("/bin/codex-linux-sandbox")),
main_execve_wrapper_exe: Some(PathBuf::from("/bin/codex-execve-wrapper")),
};
let overrides = config_overrides_from_interactive(&interactive, &arg0_paths);
assert_eq!(overrides.model.as_deref(), Some("llama3.2"));
assert_eq!(overrides.model_provider.as_deref(), Some("ollama"));
assert_eq!(overrides.cwd.as_deref(), Some(Path::new("/tmp")));
assert_eq!(overrides.approval_policy, Some(AskForApproval::Never));
assert_eq!(overrides.sandbox_mode, Some(SandboxMode::DangerFullAccess));
assert_eq!(overrides.show_raw_agent_reasoning, Some(true));
assert_eq!(
overrides.additional_writable_roots,
vec![PathBuf::from("/var/tmp")]
);
assert_eq!(overrides.codex_self_exe, arg0_paths.codex_self_exe);
assert_eq!(
overrides.codex_linux_sandbox_exe,
arg0_paths.codex_linux_sandbox_exe
);
assert_eq!(
overrides.main_execve_wrapper_exe,
arg0_paths.main_execve_wrapper_exe
);
}
#[test]
fn redacted_json_report_structures_and_sanitizes_details() {
let report = DoctorReport {
schema_version: 1,
generated_at: "0s since unix epoch".to_string(),
overall_status: CheckStatus::Warning,
codex_version: "0.0.0".to_string(),
checks: vec![
DoctorCheck::new(
"mcp.config",
"mcp",
CheckStatus::Warning,
"MCP configuration has optional issues",
)
.detail(
"optional reachability failed: remote: https://user:pass@example.com/mcp?x=abc (connect failed)",
)
.detail("OPENAI_API_KEY: sk-live-secret")
.detail("duplicate: one")
.detail("duplicate: two")
.detail("freeform note")
.issue(
DoctorIssue::new(
CheckStatus::Warning,
"remote https://user:pass@example.com/mcp?x=abc is unreachable",
)
.measured("https://user:pass@example.com/mcp?x=abc")
.expected("reachable MCP endpoint")
.remedy("Check https://user:pass@example.com/help?x=abc.")
.field("optional reachability failed"),
)
.remediation("Open https://user:pass@example.com/help?x=abc."),
],
};
let redacted_report = redacted_json_report(&report);
let redacted = serde_json::to_string(&redacted_report).expect("serialize report");
let json = serde_json::to_value(redacted_report).expect("report should serialize");
assert!(!redacted.contains("user:pass"));
assert!(!redacted.contains("x=abc"));
assert!(!redacted.contains("sk-live-secret"));
assert!(redacted.contains("https://example.com/mcp"));
assert_eq!(json["checks"].is_object(), true);
assert_eq!(json["checks"]["mcp.config"]["id"], "mcp.config");
assert_eq!(
json["checks"]["mcp.config"]["details"]["OPENAI_API_KEY"],
"<redacted>"
);
assert_eq!(
json["checks"]["mcp.config"]["details"]["duplicate"],
serde_json::json!(["one", "two"])
);
assert_eq!(
json["checks"]["mcp.config"]["notes"],
serde_json::json!(["freeform note"])
);
assert_eq!(
json["checks"]["mcp.config"]["issues"][0]["measured"],
"https://example.com/mcp"
);
assert_eq!(
json["checks"]["mcp.config"]["issues"][0]["remedy"],
"Check https://example.com/help."
);
}
#[tokio::test]
async fn mcp_check_ignores_disabled_servers() {
let disabled_server: McpServerConfig = toml::from_str(
r#"
url = "http://127.0.0.1:9/mcp"
enabled = false
required = true
bearer_token_env_var = "CODEX_DOCTOR_DISABLED_MCP_TOKEN"
"#,
)
.expect("should deserialize disabled MCP config");
let servers = HashMap::from([("disabled".to_string(), disabled_server)]);
let check = mcp_check_from_servers(&servers).await;
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(check.summary, "MCP configuration is locally consistent");
assert!(check.details.contains(&"disabled servers: 1".to_string()));
assert!(
check
.details
.iter()
.all(|detail| !detail.contains("CODEX_DOCTOR_DISABLED_MCP_TOKEN"))
);
assert!(
check
.details
.iter()
.all(|detail| !detail.contains("reachability failed"))
);
}
#[tokio::test]
async fn mcp_check_warns_for_optional_http_reachability() {
let optional_server: McpServerConfig = toml::from_str(
r#"
url = "http://127.0.0.1:9/mcp"
"#,
)
.expect("should deserialize optional MCP config");
let servers = HashMap::from([("optional".to_string(), optional_server)]);
let check = mcp_check_from_servers(&servers).await;
assert_eq!(check.status, CheckStatus::Warning);
assert_eq!(check.summary, "MCP configuration has optional issues");
assert!(
check
.details
.iter()
.any(|detail| detail.contains("optional reachability failed: optional:"))
);
}
#[tokio::test]
async fn mcp_check_fails_required_remote_stdio_env_var() {
let command = toml::Value::String(
std::env::current_exe()
.expect("current exe")
.to_string_lossy()
.into_owned(),
);
let required_server: McpServerConfig = toml::from_str(&format!(
r#"
command = {command}
required = true
env_vars = [{{ name = "REMOTE_ONLY_TOKEN", source = "remote" }}]
"#,
))
.expect("should deserialize required MCP config");
let servers = HashMap::from([("required".to_string(), required_server)]);
let check = mcp_check_from_servers(&servers).await;
assert_eq!(check.status, CheckStatus::Fail);
assert!(check.details.iter().any(|detail| {
detail.contains(
"required: env_vars entry `REMOTE_ONLY_TOKEN` uses source `remote`, which requires remote MCP stdio",
)
}));
}
#[test]
fn provider_specific_auth_allows_non_openai_provider_without_env_key() {
let check = provider_specific_auth_check(
/*requires_openai_auth*/ false,
/*provider_env_key*/ None,
/*provider_env_key_instructions*/ None,
Vec::new(),
|_| false,
)
.expect("non-OpenAI provider should produce a provider-specific check");
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(
check.summary,
"OpenAI auth is not required for the active model provider"
);
}
#[test]
fn provider_specific_auth_fails_when_provider_env_key_is_missing() {
let check = provider_specific_auth_check(
/*requires_openai_auth*/ false,
Some("PROVIDER_API_KEY"),
Some("Set PROVIDER_API_KEY before running Codex."),
Vec::new(),
|_| false,
)
.expect("non-OpenAI provider should produce a provider-specific check");
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(
check.summary,
"active model provider auth env var is missing"
);
assert_eq!(
check.remediation,
Some("Set PROVIDER_API_KEY before running Codex.".to_string())
);
}
#[test]
fn stored_auth_validation_rejects_missing_api_key() {
let auth = AuthDotJson {
auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey),
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
};
assert_eq!(
stored_auth_issues(&auth, |_| false),
vec!["API key auth is missing an API key"]
);
assert!(stored_auth_issues(&auth, |name| name == OPENAI_API_KEY_ENV_VAR).is_empty());
}
#[test]
fn stored_auth_validation_rejects_missing_chatgpt_tokens() {
let auth = AuthDotJson {
auth_mode: None,
openai_api_key: None,
tokens: None,
last_refresh: None,
agent_identity: None,
};
assert_eq!(
stored_auth_issues(&auth, |_| false),
vec![
"ChatGPT auth is missing token data",
"ChatGPT auth is missing refresh metadata",
]
);
}
#[test]
fn provider_reachability_mode_uses_api_key_auth() {
let api_key_auth = AuthDotJson {
auth_mode: Some(codex_app_server_protocol::AuthMode::ApiKey),
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
agent_identity: None,
};
assert_eq!(
provider_auth_reachability_mode_from_auth(
/*requires_openai_auth*/ true,
|_| false,
Some(&api_key_auth),
),
ProviderAuthReachabilityMode::ApiKey
);
assert_eq!(
provider_auth_reachability_mode_from_auth(
/*requires_openai_auth*/ true,
|name| name == OPENAI_API_KEY_ENV_VAR,
/*stored_auth*/ None,
),
ProviderAuthReachabilityMode::ApiKey
);
}
#[test]
fn provider_reachability_uses_active_provider_endpoint() {
assert_eq!(
provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::NotRequired,
"azure",
"azure",
Some("https://example.openai.azure.com/openai/v1"),
/*provider_query_params*/ None,
/*is_amazon_bedrock*/ false,
"https://chatgpt.com/backend-api/",
),
ReachabilityPlan {
description: "provider auth".to_string(),
endpoints: vec![ReachabilityEndpoint {
label: "azure API".to_string(),
url: "https://example.openai.azure.com/openai/v1".to_string(),
required: true,
route_probe_url: None,
}],
}
);
}
#[test]
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()
),
}],
}
);
}
#[test]
fn provider_reachability_skips_route_probe_for_bedrock() {
let plan = provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::NotRequired,
"amazon-bedrock",
"Amazon Bedrock",
Some("https://bedrock-runtime.us-east-1.amazonaws.com/openai/v1"),
/*provider_query_params*/ None,
/*is_amazon_bedrock*/ true,
"https://chatgpt.com/backend-api/",
);
assert_eq!(plan.endpoints[0].route_probe_url, None);
}
#[test]
fn provider_reachability_api_key_does_not_require_chatgpt() {
let plan = provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::ApiKey,
"openai",
"OpenAI",
/*provider_base_url*/ None,
/*provider_query_params*/ None,
/*is_amazon_bedrock*/ false,
"https://chatgpt.com/backend-api/",
);
assert_eq!(
plan.endpoints,
vec![ReachabilityEndpoint {
label: "openai API".to_string(),
url: "https://api.openai.com/v1".to_string(),
required: true,
route_probe_url: Some("https://api.openai.com/v1/models".to_string()),
}]
);
}
#[test]
fn provider_reachability_outcome_reports_required_failures() {
assert_eq!(
provider_reachability_outcome(/*required_failures*/ 0, /*warnings*/ 1,),
(
CheckStatus::Warning,
"provider endpoint checks returned warnings",
)
);
assert_eq!(
provider_reachability_outcome(/*required_failures*/ 1, /*warnings*/ 0,),
(
CheckStatus::Fail,
"one or more required provider endpoints are unreachable over HTTP",
)
);
}
#[tokio::test]
async fn provider_reachability_route_404_fails_bad_base_url_path() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr = listener.local_addr().expect("listener address");
let server = std::thread::spawn(move || {
respond_once(
&listener,
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
respond_once(
&listener,
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
});
let plan = provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::ApiKey,
"openai",
"OpenAI",
Some(&format!("http://{addr}/xxxx")),
/*provider_query_params*/ None,
/*is_amazon_bedrock*/ false,
"https://chatgpt.com/backend-api/",
);
let check = provider_reachability_check(plan).await;
server.join().expect("probe server thread should finish");
assert_eq!(check.status, CheckStatus::Fail);
assert!(
check
.details
.iter()
.any(|detail| detail.contains("route probe:") && detail.contains("HTTP 404"))
);
assert_eq!(check.issues.len(), 1);
assert_eq!(
check.issues[0].remedy.as_deref(),
Some("Set base_url to the provider API root, for example https://api.openai.com/v1")
);
}
#[tokio::test]
async fn provider_reachability_route_401_keeps_reachability_ok() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr = listener.local_addr().expect("listener address");
let server = std::thread::spawn(move || {
respond_once(
&listener,
b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
respond_once(
&listener,
b"HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
);
});
let plan = provider_reachability_plan_from_parts(
ProviderAuthReachabilityMode::ApiKey,
"openai",
"OpenAI",
Some(&format!("http://{addr}/v1")),
/*provider_query_params*/ None,
/*is_amazon_bedrock*/ false,
"https://chatgpt.com/backend-api/",
);
let check = provider_reachability_check(plan).await;
server.join().expect("probe server thread should finish");
assert_eq!(check.status, CheckStatus::Ok);
assert!(
check
.details
.iter()
.any(|detail| detail.contains("route exists (HTTP 401)"))
);
}
#[test]
fn collect_rollout_stats_counts_nested_rollout_files() {
let temp = tempfile::tempdir().expect("create temp dir");
let nested = temp
.path()
.join("sessions")
.join("2026")
.join("05")
.join("13");
std::fs::create_dir_all(&nested).expect("create nested rollout dir");
std::fs::write(
nested.join("rollout-2026-05-13T00-00-00-test.jsonl"),
"12345",
)
.expect("write rollout file");
std::fs::write(nested.join("not-a-rollout.jsonl"), "ignored").expect("write ignored jsonl");
let stats = collect_rollout_stats(&temp.path().join("sessions"));
assert_eq!(stats.files, 1);
assert_eq!(stats.total_bytes, 5);
assert_eq!(stats.average_bytes(), 5);
assert_eq!(stats.error, None);
}
#[tokio::test]
async fn http_probe_treats_http_status_as_reachable() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr = listener.local_addr().expect("listener address");
let server = std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept probe request");
let mut request = [0; 1024];
let _ = stream.read(&mut request);
stream
.write_all(
b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write response");
});
let status = http_probe_url(&format!("http://{addr}/mcp")).await;
server.join().expect("probe server thread should finish");
assert_eq!(status, Ok("HTTP 405".to_string()));
}
#[tokio::test]
async fn mcp_http_probe_falls_back_to_get_when_head_times_out() {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test listener");
let addr = listener.local_addr().expect("listener address");
let server = std::thread::spawn(move || {
let (mut head_stream, _) = listener.accept().expect("accept HEAD probe request");
let head = std::thread::spawn(move || {
let mut request = [0; 1024];
let _ = head_stream.read(&mut request);
std::thread::sleep(Duration::from_millis(50));
});
let (mut get_stream, _) = listener.accept().expect("accept GET probe request");
let mut request = [0; 1024];
let _ = get_stream.read(&mut request);
get_stream
.write_all(
b"HTTP/1.1 405 Method Not Allowed\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
)
.expect("write response");
head.join().expect("HEAD holder should finish");
});
let status = mcp_http_probe_url_with_timeout(
&format!("http://{addr}/mcp"),
Duration::from_millis(10),
)
.await;
server.join().expect("probe server thread should finish");
assert_eq!(status, Ok("HTTP 405".to_string()));
}
#[tokio::test]
async fn mcp_check_fails_required_missing_stdio_command() {
let required_server: McpServerConfig = toml::from_str(
r#"
command = "definitely-missing-codex-doctor-mcp"
required = true
"#,
)
.expect("should deserialize required MCP config");
let servers = HashMap::from([("required".to_string(), required_server)]);
let check = mcp_check_from_servers(&servers).await;
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(
check.summary,
"MCP configuration has failing required inputs or reachability"
);
assert!(check.details.iter().any(|detail| {
detail.contains(
"required: stdio command \"definitely-missing-codex-doctor-mcp\" is not resolvable",
)
}));
}
#[cfg(unix)]
#[test]
fn read_probe_file_rejects_unreadable_file() {
use std::os::unix::fs::PermissionsExt;
let file = tempfile::NamedTempFile::new().expect("create temp file");
std::fs::write(file.path(), "cert").expect("write temp file");
let mut permissions = std::fs::metadata(file.path())
.expect("metadata")
.permissions();
permissions.set_mode(0o000);
std::fs::set_permissions(file.path(), permissions).expect("remove read permissions");
let result = read_probe_file(file.path());
let mut permissions = std::fs::metadata(file.path())
.expect("metadata")
.permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(file.path(), permissions).expect("restore read permissions");
assert!(result.is_err());
}
#[cfg(unix)]
#[test]
fn executable_path_exists_rejects_non_executable_file() {
use std::os::unix::fs::PermissionsExt;
let file = tempfile::NamedTempFile::new().expect("create temp file");
std::fs::write(file.path(), "#!/bin/sh\n").expect("write temp file");
let mut permissions = std::fs::metadata(file.path())
.expect("metadata")
.permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(file.path(), permissions).expect("set non-executable mode");
let result = executable_path_exists(file.path());
assert!(result.is_err());
let mut permissions = std::fs::metadata(file.path())
.expect("metadata")
.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(file.path(), permissions).expect("set executable mode");
assert_eq!(executable_path_exists(file.path()), Ok(()));
}
#[test]
fn should_enable_color_respects_terminal_inputs() {
assert!(should_enable_color(
/*no_color_flag*/ false,
/*no_color_env*/ false,
Some("xterm-256color"),
/*stdout_is_tty*/ true,
/*stream_supports_color*/ true,
));
assert!(!should_enable_color(
/*no_color_flag*/ true,
/*no_color_env*/ false,
Some("xterm-256color"),
/*stdout_is_tty*/ true,
/*stream_supports_color*/ true,
));
assert!(!should_enable_color(
/*no_color_flag*/ false,
/*no_color_env*/ true,
Some("xterm-256color"),
/*stdout_is_tty*/ true,
/*stream_supports_color*/ true,
));
assert!(!should_enable_color(
/*no_color_flag*/ false,
/*no_color_env*/ false,
Some("dumb"),
/*stdout_is_tty*/ true,
/*stream_supports_color*/ true,
));
assert!(!should_enable_color(
/*no_color_flag*/ false,
/*no_color_env*/ false,
Some("xterm-256color"),
/*stdout_is_tty*/ false,
/*stream_supports_color*/ true,
));
}
fn terminal_inputs() -> TerminalCheckInputs {
TerminalCheckInputs {
info: TerminalInfo {
name: TerminalName::Unknown,
term_program: None,
version: None,
term: Some("xterm-256color".to_string()),
multiplexer: None,
},
env: BTreeMap::from([("TERM".to_string(), "xterm-256color".to_string())]),
present_env: BTreeSet::from(["TERM".to_string()]),
no_color_flag: false,
stdin_is_terminal: true,
stdout_is_terminal: true,
stderr_is_terminal: true,
stream_supports_color: true,
terminal_size: Ok((120, 40)),
tmux_details: Vec::new(),
}
}
fn set_terminal_env(inputs: &mut TerminalCheckInputs, name: &str, value: &str) {
inputs.present_env.insert(name.to_string());
if value.is_empty() {
inputs.env.remove(name);
} else {
inputs.env.insert(name.to_string(), value.to_string());
}
}
#[test]
fn terminal_check_warns_for_dumb_terminal() {
let mut inputs = terminal_inputs();
inputs.info.name = TerminalName::Dumb;
inputs.info.term = Some("dumb".to_string());
set_terminal_env(&mut inputs, "TERM", "dumb");
let check = terminal_check_from_inputs(inputs);
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(
check.summary,
"TERM=dumb - colors and cursor control are disabled"
);
assert_eq!(check.issues.len(), 1);
assert_eq!(
check.issues[0].remedy.as_deref(),
Some("set TERM to a real value, for example xterm-256color")
);
}
#[test]
fn terminal_check_warns_for_narrow_terminal() {
let mut inputs = terminal_inputs();
inputs.terminal_size = Ok((79, 24));
let check = terminal_check_from_inputs(inputs);
assert_eq!(check.status, CheckStatus::Warning);
assert_eq!(
check.summary,
"width 79 cols - output may wrap (recommended >=80)"
);
assert_eq!(check.issues[0].expected.as_deref(), Some(">= 80 columns"));
assert_eq!(
check.issues[0].remedy.as_deref(),
Some("resize the window to at least 80 columns")
);
}
#[test]
fn terminal_check_warns_for_declared_narrow_terminal() {
let mut inputs = terminal_inputs();
set_terminal_env(&mut inputs, "COLUMNS", "60");
let check = terminal_check_from_inputs(inputs);
assert_eq!(check.status, CheckStatus::Warning);
assert_eq!(
check.summary,
"COLUMNS=60 - output may wrap (recommended >=80)"
);
assert!(check.details.contains(&"COLUMNS: 60".to_string()));
assert_eq!(check.issues[0].fields, vec!["COLUMNS".to_string()]);
}
#[test]
fn terminal_check_warns_for_non_utf8_locale() {
let mut inputs = terminal_inputs();
set_terminal_env(&mut inputs, "LANG", "C");
let check = terminal_check_from_inputs(inputs);
assert_eq!(check.status, CheckStatus::Warning);
assert_eq!(
check.summary,
"locale is not UTF-8 - unicode glyphs may render incorrectly"
);
assert!(check.details.contains(&"effective locale: C".to_string()));
assert_eq!(
check.issues[0].remedy.as_deref(),
Some("export LANG=en_US.UTF-8 or another UTF-8 locale")
);
}
#[test]
fn terminal_check_warns_for_unreadable_terminfo_path() {
let tempdir = tempfile::tempdir().expect("create tempdir");
let missing = tempdir.path().join("missing-terminfo");
let mut inputs = terminal_inputs();
set_terminal_env(&mut inputs, "TERMINFO", &missing.to_string_lossy());
let check = terminal_check_from_inputs(inputs);
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(
check.summary,
"TERMINFO unreadable - terminal capabilities are unknown"
);
assert!(
check
.details
.iter()
.any(|detail| detail.starts_with("TERMINFO: ") && detail.ends_with(" (missing)"))
);
assert_eq!(
check.issues[0].remedy.as_deref(),
Some("check that $TERMINFO points to a readable directory")
);
}
#[test]
fn terminal_check_reports_remote_indicators_as_present_only() {
let mut inputs = terminal_inputs();
set_terminal_env(&mut inputs, "SSH_CONNECTION", "10.0.0.1 1 10.0.0.2 22");
let check = terminal_check_from_inputs(inputs);
assert!(
check
.details
.contains(&"SSH_CONNECTION: present".to_string())
);
assert!(
!check
.details
.iter()
.any(|detail| detail.contains("10.0.0.1"))
);
}
#[test]
fn terminal_check_keeps_tmux_probe_failures_non_fatal() {
let mut inputs = terminal_inputs();
inputs.info.multiplexer = Some(Multiplexer::Tmux { version: None });
let check = terminal_check_from_inputs(inputs);
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(check.summary, "terminal metadata was detected");
}
#[test]
fn color_output_summary_reports_disabled_reasons() {
let mut inputs = terminal_inputs();
inputs.no_color_flag = true;
assert_eq!(color_output_summary(&inputs), "disabled (--no-color)");
inputs = terminal_inputs();
set_terminal_env(&mut inputs, "NO_COLOR", "");
assert_eq!(color_output_summary(&inputs), "disabled (NO_COLOR)");
inputs = terminal_inputs();
inputs.info.term = Some("dumb".to_string());
set_terminal_env(&mut inputs, "TERM", "dumb");
assert_eq!(color_output_summary(&inputs), "disabled (TERM=dumb)");
inputs = terminal_inputs();
inputs.stdout_is_terminal = false;
assert_eq!(
color_output_summary(&inputs),
"disabled (stdout is not a terminal)"
);
}
}