mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
de9c5c0226
## Why On Windows npm-managed installs expose the working shim as `npm.cmd`. `codex doctor` probed bare `npm`, which could incorrectly report that npm global-root inspection was unavailable even when the install was healthy. Fixes #22964. ## What changed - Use `npm.cmd` for the doctor npm-root probe on Windows. - Keep the existing `npm` probe on non-Windows platforms.
3849 lines
130 KiB
Rust
3849 lines
130 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::InstallContext;
|
|
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::Other
|
|
} 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 {
|
|
InstallContext::Standalone {
|
|
release_dir,
|
|
resources_dir,
|
|
platform,
|
|
} => {
|
|
let platform = match platform {
|
|
StandalonePlatform::Unix => "unix",
|
|
StandalonePlatform::Windows => "windows",
|
|
};
|
|
let resources = resources_dir
|
|
.as_ref()
|
|
.map(|path| path.display().to_string())
|
|
.unwrap_or_else(|| "none".to_string());
|
|
format!(
|
|
"standalone ({platform}, release {}, resources {resources})",
|
|
release_dir.display()
|
|
)
|
|
}
|
|
InstallContext::Npm => "npm".to_string(),
|
|
InstallContext::Bun => "bun".to_string(),
|
|
InstallContext::Brew => "brew".to_string(),
|
|
InstallContext::Other => "other".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 state_db = codex_state::state_db_path(&config.sqlite_home);
|
|
let log_db = codex_state::logs_db_path(&config.sqlite_home);
|
|
path_readiness(&mut details, "state DB", &state_db);
|
|
path_readiness(&mut details, "log DB", &log_db);
|
|
let mut integrity_failures = Vec::new();
|
|
sqlite_integrity_detail(&mut details, &mut integrity_failures, "state DB", &state_db).await;
|
|
sqlite_integrity_detail(&mut details, &mut integrity_failures, "log DB", &log_db).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(
|
|
¶ms
|
|
.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 InstallContext::Standalone { release_dir, .. } = InstallContext::current() 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)"
|
|
);
|
|
}
|
|
}
|