diff --git a/.github/ISSUE_TEMPLATE/3-cli.yml b/.github/ISSUE_TEMPLATE/3-cli.yml index 37229c7f2b..cfd368c0ba 100644 --- a/.github/ISSUE_TEMPLATE/3-cli.yml +++ b/.github/ISSUE_TEMPLATE/3-cli.yml @@ -11,6 +11,8 @@ body: Make sure you are running the [latest](https://npmjs.com/package/@openai/codex) version of Codex CLI. The bug you are experiencing may already have been fixed. + If your version supports it, please run `codex doctor --json` and paste the output in the "Codex doctor report" field below. This helps us diagnose install, config, auth, terminal, MCP, network, and local state issues. + - type: input id: version attributes: @@ -43,6 +45,16 @@ body: description: | Also note any multiplexer in use (screen / tmux / zellij). E.g., VS Code, Terminal.app, iTerm2, Ghostty, Windows Terminal (WSL / PowerShell) + - type: textarea + id: doctor + attributes: + label: Codex doctor report + description: | + If available, run `codex doctor --json` and paste the full output here. + + The report is designed to redact secrets, but please review it before submitting. + If your Codex version does not support `doctor`, write `not available`. + render: json - type: textarea id: actual attributes: diff --git a/codex-cli/bin/codex.js b/codex-cli/bin/codex.js index 67ab3e2d95..475239549a 100755 --- a/codex-cli/bin/codex.js +++ b/codex-cli/bin/codex.js @@ -2,7 +2,7 @@ // Unified entry point for the Codex CLI. import { spawn } from "node:child_process"; -import { existsSync } from "fs"; +import { existsSync, realpathSync } from "fs"; import { createRequire } from "node:module"; import path from "path"; import { fileURLToPath } from "url"; @@ -171,6 +171,7 @@ const packageManagerEnvVar = ? "CODEX_MANAGED_BY_BUN" : "CODEX_MANAGED_BY_NPM"; env[packageManagerEnvVar] = "1"; +env.CODEX_MANAGED_PACKAGE_ROOT = realpathSync(path.join(__dirname, "..")); const child = spawn(binaryPath, process.argv.slice(2), { stdio: "inherit", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 564c1a9c78..ec43bd6f38 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2220,6 +2220,7 @@ dependencies = [ "assert_matches", "clap", "clap_complete", + "codex-api", "codex-app-server", "codex-app-server-daemon", "codex-app-server-protocol", @@ -2234,10 +2235,12 @@ dependencies = [ "codex-exec-server", "codex-execpolicy", "codex-features", + "codex-install-context", "codex-login", "codex-mcp", "codex-mcp-server", "codex-memories-write", + "codex-model-provider", "codex-models-manager", "codex-protocol", "codex-responses-api-proxy", @@ -2253,11 +2256,14 @@ dependencies = [ "codex-utils-cli", "codex-utils-path", "codex-windows-sandbox", + "crossterm", + "http 1.4.0", "libc", "owo-colors", "predicates", "pretty_assertions", "regex-lite", + "serde", "serde_json", "sqlx", "supports-color 3.0.2", diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cf68f638ba..d2b681d063 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -439,6 +439,7 @@ mod command_exec_processor; mod config_processor; mod environment_processor; mod external_agent_config_processor; +mod feedback_doctor_report; mod feedback_processor; mod fs_processor; mod git_processor; diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs new file mode 100644 index 0000000000..3bd9c9fd9b --- /dev/null +++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs @@ -0,0 +1,212 @@ +//! Builds a redacted doctor report attachment for feedback uploads. +//! +//! Feedback upload should never depend on doctor succeeding. This module runs +//! the configured Codex executable as a subprocess, accepts only valid JSON from +//! `codex doctor --json`, derives a small set of Sentry tags, and otherwise +//! skips the attachment with a warning. Keeping the report generation out of the +//! app-server process avoids sharing doctor internals across crates while still +//! attaching exactly the same JSON a user could copy from the CLI. + +use std::collections::BTreeMap; +use std::time::Duration; + +use codex_core::config::Config; +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; +use codex_feedback::FeedbackAttachment; +use serde_json::Value; +use tokio::process::Command; +use tokio::time::timeout; +use tracing::warn; + +const DOCTOR_FEEDBACK_REPORT_TIMEOUT: Duration = Duration::from_secs(25); +const MAX_DOCTOR_TAG_VALUE_LEN: usize = 256; + +/// Redacted doctor report data that can be merged into a feedback upload. +pub(crate) struct DoctorFeedbackReport { + /// JSON support report to upload as `codex-doctor-report.json`. + pub(crate) attachment: FeedbackAttachment, + /// Low-cardinality Sentry tags derived from the report status and check ids. + pub(crate) tags: BTreeMap, +} + +/// Runs `codex doctor --json` and returns a best-effort feedback attachment. +/// +/// Failure to spawn Codex, finish before the timeout, or parse JSON means the +/// feedback upload proceeds without the doctor report. Callers should merge the +/// returned tags without overriding explicit client-provided tags. +pub(crate) async fn doctor_feedback_report(config: &Config) -> Option { + let executable = config + .codex_self_exe + .clone() + .or_else(|| std::env::current_exe().ok())?; + + let mut command = Command::new(&executable); + command.arg("doctor").arg("--json"); + command.kill_on_drop(/*kill_on_drop*/ true); + let output = match timeout(DOCTOR_FEEDBACK_REPORT_TIMEOUT, command.output()).await { + Ok(Ok(output)) => output, + Ok(Err(err)) => { + warn!( + executable = %executable.display(), + error = %err, + "failed to run doctor report for feedback; skipping attachment" + ); + return None; + } + Err(_) => { + warn!( + executable = %executable.display(), + "timed out running doctor report for feedback; skipping attachment" + ); + return None; + } + }; + + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(json_start) = stdout.find('{') else { + warn!( + executable = %executable.display(), + status = %output.status, + stderr = %String::from_utf8_lossy(&output.stderr), + "doctor report for feedback did not produce JSON; skipping attachment" + ); + return None; + }; + let json = stdout[json_start..].trim(); + let report: Value = match serde_json::from_str(json) { + Ok(report) => report, + Err(err) => { + warn!( + executable = %executable.display(), + status = %output.status, + error = %err, + "doctor report for feedback was not valid JSON; skipping attachment" + ); + return None; + } + }; + + let pretty = serde_json::to_vec_pretty(&report).unwrap_or_else(|_| json.as_bytes().to_vec()); + Some(DoctorFeedbackReport { + tags: doctor_report_tags(&report), + attachment: FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: pretty, + }, + }) +} + +fn doctor_report_tags(report: &Value) -> BTreeMap { + let mut tags = BTreeMap::new(); + if let Some(overall_status) = report.get("overallStatus").and_then(Value::as_str) { + tags.insert( + "doctor_overall_status".to_string(), + truncate_tag_value(overall_status), + ); + } + + let mut ok_count = 0usize; + let mut warning_count = 0usize; + let mut fail_count = 0usize; + let mut failed_checks = Vec::new(); + let mut warning_checks = Vec::new(); + if let Some(checks) = report.get("checks") { + for check in check_values(checks) { + let status = check.get("status").and_then(Value::as_str); + let id = check.get("id").and_then(Value::as_str).unwrap_or("unknown"); + match status { + Some("ok") => ok_count += 1, + Some("warning") => { + warning_count += 1; + warning_checks.push(id.to_string()); + } + Some("fail") => { + fail_count += 1; + failed_checks.push(id.to_string()); + } + _ => {} + } + } + } + tags.insert("doctor_ok_count".to_string(), ok_count.to_string()); + tags.insert( + "doctor_warning_count".to_string(), + warning_count.to_string(), + ); + tags.insert("doctor_fail_count".to_string(), fail_count.to_string()); + if !failed_checks.is_empty() { + tags.insert( + "doctor_failed_checks".to_string(), + truncate_tag_value(&failed_checks.join(",")), + ); + } + if !warning_checks.is_empty() { + tags.insert( + "doctor_warning_checks".to_string(), + truncate_tag_value(&warning_checks.join(",")), + ); + } + + tags +} + +/// Iterates checks from both the current keyed JSON shape and older array reports. +fn check_values(checks: &Value) -> Box + '_> { + match checks { + Value::Array(values) => Box::new(values.iter()), + Value::Object(values) => Box::new(values.values()), + _ => Box::new(std::iter::empty()), + } +} + +fn truncate_tag_value(value: &str) -> String { + if value.chars().count() <= MAX_DOCTOR_TAG_VALUE_LEN { + return value.to_string(); + } + let prefix = value + .chars() + .take(MAX_DOCTOR_TAG_VALUE_LEN.saturating_sub(3)) + .collect::(); + format!("{prefix}...") +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use serde_json::json; + + #[test] + fn doctor_report_tags_summarize_status_counts() { + let report = json!({ + "overallStatus": "fail", + "checks": { + "runtime.provenance": {"id": "runtime.provenance", "status": "ok"}, + "websocket.reachability": { + "id": "websocket.reachability", + "status": "warning" + }, + "auth.credentials": {"id": "auth.credentials", "status": "fail"} + } + }); + + let tags = doctor_report_tags(&report); + + let expected = BTreeMap::from([ + ("doctor_fail_count".to_string(), "1".to_string()), + ( + "doctor_failed_checks".to_string(), + "auth.credentials".to_string(), + ), + ("doctor_ok_count".to_string(), "1".to_string()), + ("doctor_overall_status".to_string(), "fail".to_string()), + ( + "doctor_warning_checks".to_string(), + "websocket.reachability".to_string(), + ), + ("doctor_warning_count".to_string(), "1".to_string()), + ]); + assert_eq!(tags, expected); + } +} diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 5b9039b57d..18f519d3c0 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -56,6 +56,7 @@ impl FeedbackRequestProcessor { extra_log_files, tags, } = params; + let mut upload_tags = tags.unwrap_or_default(); let conversation_id = match thread_id.as_deref() { Some(thread_id) => match ThreadId::from_string(thread_id) { @@ -197,14 +198,27 @@ impl FeedbackRequestProcessor { } } + let mut extra_attachments = Vec::new(); + if include_logs + && let Some(doctor_report) = + super::feedback_doctor_report::doctor_feedback_report(&self.config).await + { + extra_attachments.push(doctor_report.attachment); + for (key, value) in doctor_report.tags { + upload_tags.entry(key).or_insert(value); + } + } + let session_source = self.thread_manager.session_source(); let upload_result = tokio::task::spawn_blocking(move || { + let tags = (!upload_tags.is_empty()).then_some(&upload_tags); snapshot.upload_feedback(FeedbackUploadOptions { classification: &classification, reason: reason.as_deref(), - tags: tags.as_ref(), + tags, include_logs, + extra_attachments: &extra_attachments, extra_attachment_paths: &attachment_paths, session_source: Some(session_source), logs_override: sqlite_feedback_logs, diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 120d2e497a..0831163934 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -26,6 +26,7 @@ codex-app-server-daemon = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } +codex-api = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } @@ -36,10 +37,12 @@ codex-exec = { workspace = true } codex-exec-server = { workspace = true } codex-execpolicy = { workspace = true } codex-features = { workspace = true } +codex-install-context = { workspace = true } codex-login = { workspace = true } codex-memories-write = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } +codex-model-provider = { workspace = true } codex-models-manager = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } @@ -52,18 +55,23 @@ codex-terminal-detection = { workspace = true } codex-tui = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path = { workspace = true } +crossterm = { workspace = true } +http = { workspace = true } libc = { workspace = true } owo-colors = { workspace = true } regex-lite = { workspace = true } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } supports-color = { workspace = true } tempfile = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", + "net", "process", "rt-multi-thread", "signal", + "time", ] } toml = { workspace = true } tracing = { workspace = true } diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs new file mode 100644 index 0000000000..a3742baa72 --- /dev/null +++ b/codex-rs/cli/src/doctor.rs @@ -0,0 +1,3844 @@ +//! 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"]; +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, +} + +/// 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, + issues: Vec, + remediation: Option, + 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, + expected: Option, + remedy: Option, + fields: Vec, +} + +impl DoctorIssue { + fn new(severity: CheckStatus, cause: impl Into) -> Self { + Self { + severity, + cause: cause.into(), + measured: None, + expected: None, + remedy: None, + fields: Vec::new(), + } + } + + fn measured(mut self, measured: impl Into) -> Self { + self.measured = Some(measured.into()); + self + } + + fn expected(mut self, expected: impl Into) -> Self { + self.expected = Some(expected.into()); + self + } + + fn remedy(mut self, remedy: impl Into) -> Self { + self.remedy = Some(remedy.into()); + self + } + + fn field(mut self, field: impl Into) -> Self { + self.fields.push(field.into()); + self + } +} + +impl DoctorCheck { + fn new( + id: impl Into, + category: impl Into, + status: CheckStatus, + summary: impl Into, + ) -> 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) -> Self { + self.details.push(detail.into()); + self + } + + fn details(mut self, details: Vec) -> Self { + self.details.extend(details); + self + } + + fn remediation(mut self, remediation: impl Into) -> 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 { + 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, +} + +/// 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, + #[serde(skip_serializing_if = "Vec::is_empty")] + issues: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + notes: Vec, + remediation: Option, + 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, + expected: Option, + remedy: Option, + fields: Vec, +} + +/// JSON detail value that preserves repeated detail keys without inventing names. +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] +enum JsonDetailValue { + One(String), + Many(Vec), +} + +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, Vec) { + let mut structured: BTreeMap = 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, + 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( + label: &'static str, + progress: Arc, + future: Fut, +) -> DoctorCheck +where + Fut: Future, +{ + 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::>(); + 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", ["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>(items: &[T]) -> String { + if items.is_empty() { + "none".to_string() + } else { + items + .iter() + .map(AsRef::as_ref) + .collect::>() + .join(", ") + } +} + +fn codex_path_entries() -> Vec { + #[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(program: &str, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + 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("") + )); + 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) { + let features = config.features.get(); + let enabled_features = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id)) + .map(|spec| spec.key) + .collect::>(); + let overrides = FEATURES + .iter() + .filter(|spec| features.enabled(spec.id) != spec.default_enabled) + .map(|spec| format!("{}={}", spec.key, features.enabled(spec.id))) + .collect::>(); + 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) { + 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::(&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::>(); + 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, + env_var_present: impl Fn(&str) -> bool, +) -> Option { + 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) { + let present_proxy_vars = PROXY_ENV_VARS + .iter() + .copied() + .filter(|name| env_var_present(name)) + .collect::>(); + 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) -> 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, + present_env: BTreeSet, + 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, +} + +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, BTreeSet) { + 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, + 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, + 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, 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 { + 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 { + 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::().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::().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 { + 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, label: &str, format: &str) { + if let Some(value) = tmux_display_message(format) { + details.push(format!("{label}: {value}")); + } +} + +fn tmux_option_value(option: &str) -> Option { + 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 { + 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 { + 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, + integrity_failures: &mut Vec, + 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, 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, 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, +} + +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>, +) -> 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, + 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 { + match tokio::net::lookup_host((host, port)).await { + Ok(addresses) => { + let addresses = addresses.collect::>(); + 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, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ReachabilityEndpoint { + label: String, + url: String, + required: bool, + route_probe_url: Option, +} + +#[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>, + 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 { + 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::>() + .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 { + http_probe_url_with_timeout(url, Duration::from_secs(3)).await +} + +async fn mcp_http_probe_url(url: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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>, +) -> 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, 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) { + 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, 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, 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>, + } + + impl RecordingProgress { + fn events(&self) -> Vec { + 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 = 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 = 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"], + "" + ); + 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)" + ); + } +} diff --git a/codex-rs/cli/src/doctor/background.rs b/codex-rs/cli/src/doctor/background.rs new file mode 100644 index 0000000000..0ec5132cd3 --- /dev/null +++ b/codex-rs/cli/src/doctor/background.rs @@ -0,0 +1,150 @@ +//! Reports app-server daemon state without starting or stopping the daemon. +//! +//! The background-server check is deliberately passive. It reads the daemon +//! state directory, PID files, settings file, and control socket path, then +//! attempts only a local socket connection when a socket already exists. That +//! keeps doctor safe to run while the user is debugging startup or update-loop +//! issues. + +use std::path::Path; + +use codex_core::config::Config; + +use super::CheckStatus; +use super::DoctorCheck; + +const STATE_DIR_NAME: &str = "app-server-daemon"; +const SETTINGS_FILE_NAME: &str = "settings.json"; +const PID_FILE_NAME: &str = "app-server.pid"; +const UPDATE_PID_FILE_NAME: &str = "app-server-updater.pid"; + +/// Builds the app-server status row from existing daemon state. +/// +/// Missing files are expected for the ephemeral/not-running case and should not +/// be treated as failures. A stale socket is a warning because it can explain +/// client connection problems without proving the daemon itself is broken. +pub(super) fn background_server_check(config: &Config) -> DoctorCheck { + let mut details = Vec::new(); + let state_dir = config.codex_home.join(STATE_DIR_NAME); + details.push(format!("daemon state dir: {}", state_dir.display())); + push_file_detail( + &mut details, + "settings", + &state_dir.join(SETTINGS_FILE_NAME), + ); + push_file_detail(&mut details, "pid file", &state_dir.join(PID_FILE_NAME)); + push_file_detail( + &mut details, + "update-loop pid file", + &state_dir.join(UPDATE_PID_FILE_NAME), + ); + + let socket_path = match codex_app_server::app_server_control_socket_path(&config.codex_home) { + Ok(socket_path) => socket_path, + Err(err) => { + return DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Warning, + "background server socket path could not be resolved", + ) + .details(details) + .detail(err.to_string()); + } + }; + + details.push(format!("control socket: {}", socket_path.display())); + let status = socket_status(socket_path.as_path()); + details.push(format!("status: {}", status.detail_label())); + details.push(format!("mode: {}", server_mode(&state_dir))); + + let mut check = DoctorCheck::new( + "app_server.status", + "app-server", + status.check_status(), + status.summary(), + ) + .details(details); + if status.check_status() == CheckStatus::Warning { + check = check.remediation("Run codex app-server daemon version for more details."); + } + check +} + +fn push_file_detail(details: &mut Vec, label: &str, path: &Path) { + match std::fs::metadata(path) { + Ok(metadata) if metadata.is_file() => { + details.push(format!("{label}: {} (file)", path.display())); + } + Ok(_) => { + details.push(format!("{label}: {} (not a file)", 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 server_mode(state_dir: &Path) -> &'static str { + if state_dir.join(SETTINGS_FILE_NAME).is_file() { + "persistent" + } else { + "ephemeral" + } +} + +#[derive(Clone, Copy)] +enum SocketStatus { + NotRunning, + Running, + #[cfg(unix)] + StaleOrUnreachable, +} + +impl SocketStatus { + fn check_status(self) -> CheckStatus { + match self { + Self::NotRunning | Self::Running => CheckStatus::Ok, + #[cfg(unix)] + Self::StaleOrUnreachable => CheckStatus::Warning, + } + } + + fn summary(self) -> &'static str { + match self { + Self::NotRunning => "background server is not running", + Self::Running => "background server is running", + #[cfg(unix)] + Self::StaleOrUnreachable => "background server socket is stale or unreachable", + } + } + + fn detail_label(self) -> &'static str { + match self { + Self::NotRunning => "not running", + Self::Running => "running", + #[cfg(unix)] + Self::StaleOrUnreachable => "stale or unreachable", + } + } +} + +fn socket_status(socket_path: &Path) -> SocketStatus { + if !socket_path.exists() { + return SocketStatus::NotRunning; + } + + #[cfg(unix)] + { + match std::os::unix::net::UnixStream::connect(socket_path) { + Ok(_) => SocketStatus::Running, + Err(_) => SocketStatus::StaleOrUnreachable, + } + } + + #[cfg(not(unix))] + { + SocketStatus::Running + } +} diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs new file mode 100644 index 0000000000..da5fd20ce8 --- /dev/null +++ b/codex-rs/cli/src/doctor/output.rs @@ -0,0 +1,1555 @@ +//! Renders doctor reports for terminal users. +//! +//! The renderer is intentionally separate from check construction so the JSON +//! report can stay stable while the human view optimizes for scanability. It +//! groups checks by concern, colors only status/actionable tokens, and redacts +//! sensitive detail lines before showing them in detailed output. + +mod detail; + +use std::fmt::Write as _; + +use detail::HumanDetail; +use detail::detail_lines; +use owo_colors::OwoColorize; +use owo_colors::XtermColors; + +use super::CheckStatus; +use super::DoctorCheck; +use super::DoctorReport; + +const NAME_WIDTH: usize = 12; +const DETAIL_LABEL_WIDTH: usize = 24; +const SEPARATOR_WIDTH: usize = 61; + +const GROUPS: &[OutputGroup] = &[ + OutputGroup { + title: "Environment", + keys: &["runtime", "install", "search", "terminal", "state"], + }, + OutputGroup { + title: "Configuration", + keys: &["config", "auth", "mcp", "sandbox"], + }, + OutputGroup { + title: "Updates", + keys: &["updates"], + }, + OutputGroup { + title: "Connectivity", + keys: &["network", "websocket", "reachability"], + }, + OutputGroup { + title: "Background Server", + keys: &["app-server"], + }, +]; + +struct OutputGroup { + title: &'static str, + keys: &'static [&'static str], +} + +/// Rendering controls for human doctor output. +/// +/// These options affect presentation only. They must not change which checks +/// run or which fields are present in the underlying JSON report. +#[derive(Clone, Copy, Debug)] +pub(super) struct HumanOutputOptions { + pub(super) show_details: bool, + pub(super) show_all: bool, + pub(super) ascii: bool, + pub(super) color_enabled: bool, +} + +/// Formats a doctor report into the grouped terminal layout. +/// +/// The renderer expects checks to carry stable categories, but it owns their +/// display order. Adding a new category without adding it to GROUPS keeps JSON +/// output intact but hides that row from the human view. +pub(super) fn render_human_report(report: &DoctorReport, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "{} {}", + bold("Codex Doctor", options), + dim(&header_suffix(report), options) + ); + out.push('\n'); + + let notes = notes_for_report(report); + if !notes.is_empty() { + let _ = writeln!(out, "{}", bold("Notes", options)); + for note in ¬es { + write_note_row(&mut out, note, options); + } + let _ = writeln!(out, "{}", dim(&separator(options), options)); + out.push('\n'); + } + + let mut wrote_group = false; + for group in GROUPS { + let group_checks = checks_for_group(report, group); + if group_checks.is_empty() { + continue; + } + + if wrote_group { + out.push('\n'); + } + wrote_group = true; + + let _ = writeln!(out, "{}", bold(group.title, options)); + for check in group_checks { + write_check_row(&mut out, check, options); + } + } + + out.push('\n'); + let _ = writeln!(out, "{}", dim(&separator(options), options)); + let _ = writeln!(out, "{}", summary_line(report, options)); + out.push('\n'); + write_footer(&mut out, options); + out +} + +fn checks_for_group<'a>(report: &'a DoctorReport, group: &OutputGroup) -> Vec<&'a DoctorCheck> { + group + .keys + .iter() + .flat_map(|key| { + report + .checks + .iter() + .filter(move |check| check.category == *key) + }) + .collect() +} + +fn write_check_row(out: &mut String, check: &DoctorCheck, options: HumanOutputOptions) { + let description = row_description(check, options); + let status = display_status(check); + let _ = writeln!( + out, + " {}{} {}", + status_marker_slot(status, options), + format_args!("{: { + let is_issue = expected.is_some(); + let label = format!("{label: { + let spacer = " ".repeat(DETAIL_LABEL_WIDTH); + let _ = writeln!( + out, + " {} {}", + detail_label(&spacer, options), + detail_value(&value, options) + ); + } + HumanDetail::Bullet(value) => { + let _ = writeln!( + out, + " {} {}", + very_dim(if options.ascii { "-" } else { "·" }, options), + dim(&highlight_actions(&value, options), options) + ); + } + HumanDetail::Remedy(value) => { + let marker = if options.ascii { "->" } else { "→" }; + let _ = writeln!( + out, + " {} {}", + orange(marker, options), + highlight_actions(&value, options) + ); + } + } +} + +fn row_description(check: &DoctorCheck, options: HumanOutputOptions) -> String { + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) && !check.issues.is_empty() + { + return issue_summary(check); + } + if matches!(check.status, CheckStatus::Warning | CheckStatus::Fail) + && let Some(remediation) = &check.remediation + { + let dash = if options.ascii { " - " } else { " — " }; + let summary = &check.summary; + return format!("{summary}{dash}{remediation}"); + } + + display_summary(check, options) +} + +fn issue_summary(check: &DoctorCheck) -> String { + match check.issues.as_slice() { + [] => check.summary.clone(), + [issue] => issue.cause.clone(), + issues => format!( + "{} issues - {}", + issues.len(), + issues + .iter() + .take(2) + .map(|issue| issue.cause.as_str()) + .collect::>() + .join("; ") + ), + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DisplayStatus { + Ok, + Update, + Note, + Warning, + Fail, + Idle, +} + +struct DoctorNote { + status: DisplayStatus, + name: String, + summary: String, +} + +fn display_status(check: &DoctorCheck) -> DisplayStatus { + if check.category == "app-server" + && check.status == CheckStatus::Ok + && check + .details + .iter() + .any(|detail| detail == "status: not running") + { + return DisplayStatus::Idle; + } + + match check.status { + CheckStatus::Ok => DisplayStatus::Ok, + CheckStatus::Warning => DisplayStatus::Warning, + CheckStatus::Fail => DisplayStatus::Fail, + } +} + +fn status_marker(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = if options.ascii { + match status { + DisplayStatus::Ok => "[ok]", + DisplayStatus::Update => "[up]", + DisplayStatus::Note | DisplayStatus::Warning => "[!!]", + DisplayStatus::Fail => "[XX]", + DisplayStatus::Idle => "[--]", + } + } else { + match status { + DisplayStatus::Ok => "✓", + DisplayStatus::Update => "↑", + DisplayStatus::Note | DisplayStatus::Warning => "⚠", + DisplayStatus::Fail => "✗", + DisplayStatus::Idle => "○", + } + }; + + match status { + DisplayStatus::Ok => green(marker, options), + DisplayStatus::Update => amber(marker, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(marker, options), + DisplayStatus::Fail => red(marker, options), + DisplayStatus::Idle => dim(marker, options), + } +} + +fn status_marker_slot(status: DisplayStatus, options: HumanOutputOptions) -> String { + let marker = status_marker(status, options); + format!("{marker} ") +} + +fn style_description( + description: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let highlighted = highlight_actions(description, options); + match status { + DisplayStatus::Ok | DisplayStatus::Idle => dim(&highlighted, options), + DisplayStatus::Update => amber(&highlighted, options), + DisplayStatus::Note | DisplayStatus::Warning | DisplayStatus::Fail => highlighted, + } +} + +fn detail_marker(is_issue: bool, options: HumanOutputOptions) -> String { + if !is_issue { + return " ".to_string(); + } + orange(if options.ascii { ">" } else { "▸" }, options) +} + +fn style_note_summary(note: &DoctorNote, options: HumanOutputOptions) -> String { + if note.status == DisplayStatus::Update { + return style_update_note_summary(¬e.summary, options); + } + style_description(¬e.summary, note.status, options) +} + +fn style_update_note_summary(summary: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return summary.to_string(); + } + + let Some((version, rest)) = summary.split_once(" available") else { + return amber(summary, options); + }; + let Some((action, parenthetical)) = rest.split_once(" (") else { + return format!( + "{}{}", + amber(&format!("{version} available"), options), + amber(rest, options) + ); + }; + format!( + "{}{} {}", + amber(&format!("{version} available"), options), + amber(action, options), + dim(&format!("({parenthetical}"), options) + ) +} + +fn summary_line(report: &DoctorReport, options: HumanOutputOptions) -> String { + let notes = notes_for_report(report); + let counts = StatusCounts::from_report(report, notes.len()); + let separator = dim(if options.ascii { " | " } else { " · " }, options); + let status = overall_status_label(report.overall_status); + let mut parts = vec![count_label(counts.ok, "ok", DisplayStatus::Ok, options)]; + if counts.idle > 0 { + parts.push(count_label( + counts.idle, + "idle", + DisplayStatus::Idle, + options, + )); + } + if counts.notes > 0 { + parts.push(count_label( + counts.notes, + "notes", + DisplayStatus::Note, + options, + )); + } + parts.push(count_label( + counts.warning, + "warn", + DisplayStatus::Warning, + options, + )); + parts.push(count_label( + counts.fail, + "fail", + DisplayStatus::Fail, + options, + )); + format!( + "{} {}", + parts.join(&separator), + styled_overall_status(status, report.overall_status, options) + ) +} + +fn count_label( + count: usize, + label: &str, + status: DisplayStatus, + options: HumanOutputOptions, +) -> String { + let count = dim(&count.to_string(), options); + let label = match status { + DisplayStatus::Ok => green(label, options), + DisplayStatus::Update => amber(label, options), + DisplayStatus::Note | DisplayStatus::Warning => orange(label, options), + DisplayStatus::Fail => red(label, options), + DisplayStatus::Idle => dim(label, options), + }; + format!("{count} {label}") +} + +fn overall_status_label(status: CheckStatus) -> &'static str { + match status { + CheckStatus::Ok => "ok", + CheckStatus::Warning => "degraded", + CheckStatus::Fail => "failed", + } +} + +fn styled_overall_status(label: &str, status: CheckStatus, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return label.to_string(); + } + + match status { + CheckStatus::Ok => label.green().bold().to_string(), + CheckStatus::Warning => label.yellow().bold().to_string(), + CheckStatus::Fail => label.red().bold().to_string(), + } +} + +fn write_footer(out: &mut String, options: HumanOutputOptions) { + if options.show_details { + let _ = writeln!( + out, + "{} {:<24} {} {}", + cyan("--summary", options), + dim("compact output", options), + cyan("--all", options), + dim("expand truncated lists", options) + ); + } else { + let _ = writeln!( + out, + "{}", + dim( + "Run codex doctor without --summary for detailed diagnostics.", + options + ) + ); + let _ = writeln!( + out, + "{} {:<28} {} {}", + cyan("--all", options), + dim("expand truncated lists", options), + cyan("--json", options), + dim("redacted report", options) + ); + return; + } + let _ = writeln!( + out, + "{} {}", + cyan("--json", options), + dim("redacted report", options) + ); +} + +fn header_suffix(report: &DoctorReport) -> String { + let version = format!("v{}", report.codex_version); + report + .checks + .iter() + .find(|check| check.category == "runtime") + .and_then(|check| detail::detail_value(check, "platform")) + .map_or(version.clone(), |platform| { + format!("{version} · {platform}") + }) +} + +fn notes_for_report(report: &DoctorReport) -> Vec { + let mut notes = Vec::new(); + if let Some(check) = find_check(report, "updates") { + update_note(check, report) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "state") { + rollout_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + if let Some(check) = find_check(report, "sandbox") { + sandbox_note(check) + .into_iter() + .for_each(|note| notes.push(note)); + } + non_ok_notes(report) + .into_iter() + .for_each(|note| notes.push(note)); + auth_reachability_note(report) + .into_iter() + .for_each(|note| notes.push(note)); + notes +} + +fn find_check<'a>(report: &'a DoctorReport, category: &str) -> Option<&'a DoctorCheck> { + report + .checks + .iter() + .find(|check| check.category == category) +} + +fn update_note(check: &DoctorCheck, report: &DoctorReport) -> Option { + let status = detail::detail_value(check, "latest version status")?; + if !status.contains("newer version is available") { + return None; + } + let latest = detail::detail_value(check, "latest version") + .or_else(|| detail::detail_value(check, "cached latest version")) + .unwrap_or_else(|| "newer version".to_string()); + let dismissed = detail::detail_value(check, "dismissed version"); + let mut parenthetical = format!("current {}", report.codex_version); + if let Some(dismissed) = dismissed + && !detail::is_falsy(&dismissed) + { + parenthetical.push_str(&format!(", dismissed {dismissed}")); + } + Some(DoctorNote { + status: DisplayStatus::Update, + name: "updates".to_string(), + summary: format!("{latest} available ({parenthetical})"), + }) +} + +fn rollout_note(check: &DoctorCheck) -> Option { + let active = detail::detail_value(check, "active rollout files")?; + let (files, bytes) = detail::rollout_files_and_bytes(&active)?; + if files < 1000 && bytes < 1024 * 1024 * 1024 { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "rollouts".to_string(), + summary: format!( + "{} active files · {} on disk", + detail::format_count(files), + detail::format_bytes(bytes) + ), + }) +} + +fn sandbox_note(check: &DoctorCheck) -> Option { + let filesystem = detail::detail_value(check, "filesystem sandbox")?; + let network = detail::detail_value(check, "network sandbox")?; + if filesystem == "restricted" && network == "restricted" { + return None; + } + Some(DoctorNote { + status: DisplayStatus::Warning, + name: "sandbox".to_string(), + summary: format!("filesystem {filesystem} · network {network}"), + }) +} + +fn non_ok_notes(report: &DoctorReport) -> Vec { + report + .checks + .iter() + .filter(|check| matches!(check.status, CheckStatus::Warning | CheckStatus::Fail)) + .map(|check| DoctorNote { + status: display_status(check), + name: check.category.clone(), + summary: actionable_note_summary(check), + }) + .collect() +} + +fn actionable_note_summary(check: &DoctorCheck) -> String { + if !check.issues.is_empty() { + return issue_summary(check); + } + if let Some(remediation) = &check.remediation { + return format!("{} - {remediation}", check.summary); + } + check.summary.clone() +} + +fn auth_reachability_note(report: &DoctorReport) -> Option { + let websocket = find_check(report, "websocket")?; + let reachability = find_check(report, "reachability")?; + let auth_mode = detail::detail_value(websocket, "auth mode")?; + let reachability_mode = detail::detail_value(reachability, "reachability mode")?; + let auth_mode_lower = auth_mode.to_ascii_lowercase(); + let reachability_mode_lower = reachability_mode.to_ascii_lowercase(); + if auth_mode_lower.contains("chatgpt") && reachability_mode_lower.contains("api key") { + return Some(DoctorNote { + status: DisplayStatus::Warning, + name: "auth".to_string(), + summary: "mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode".to_string(), + }); + } + None +} + +fn display_summary(check: &DoctorCheck, _options: HumanOutputOptions) -> String { + match check.category.as_str() { + "runtime" => runtime_summary(check), + "install" if check.status == CheckStatus::Ok => "consistent".to_string(), + "search" => search_summary(check), + "terminal" => terminal_summary(check), + "state" => state_summary(check), + "config" if check.status == CheckStatus::Ok => "loaded".to_string(), + "mcp" => mcp_summary(check), + "sandbox" => sandbox_summary(check), + "network" => network_summary(check), + "websocket" => websocket_summary(check), + "app-server" => app_server_summary(check), + _ => check.summary.clone(), + } +} + +fn runtime_summary(check: &DoctorCheck) -> String { + if detail::detail_value(check, "current executable") + .is_some_and(|path| path.contains("/target/debug/")) + { + return "local debug build".to_string(); + } + detail::detail_value(check, "install method").unwrap_or_else(|| check.summary.clone()) +} + +fn search_summary(check: &DoctorCheck) -> String { + let provider = detail::detail_value(check, "search provider"); + let command = detail::detail_value(check, "search command"); + let readiness = detail::detail_value(check, "search command readiness"); + match (readiness, provider, command) { + (Some(readiness), Some(provider), Some(command)) if check.status == CheckStatus::Ok => { + format!("{readiness} ({provider}, `{command}`)") + } + _ => check.summary.clone(), + } +} + +fn terminal_summary(check: &DoctorCheck) -> String { + let mut parts = Vec::new(); + if let Some(terminal) = detail::detail_value(check, "terminal") { + let version = detail::detail_value(check, "terminal version"); + parts.push(version.map_or(terminal.clone(), |version| format!("{terminal} {version}"))); + } + if let Some(multiplexer) = detail::detail_value(check, "multiplexer") { + parts.push(multiplexer); + } + if let Some(term) = detail::detail_value(check, "TERM") { + parts.push(format!("TERM={term}")); + } + if parts.is_empty() { + check.summary.clone() + } else { + parts.join(" · ") + } +} + +fn state_summary(check: &DoctorCheck) -> String { + let state_ok = + detail::detail_value(check, "state DB integrity").is_some_and(|value| value == "ok"); + let log_ok = detail::detail_value(check, "log DB integrity").is_some_and(|value| value == "ok"); + if state_ok && log_ok { + "databases healthy".to_string() + } else { + check.summary.clone() + } +} + +fn mcp_summary(check: &DoctorCheck) -> String { + let Some(count) = detail::detail_value(check, "configured servers") else { + return check.summary.clone(); + }; + let disabled = + detail::detail_value(check, "disabled servers").unwrap_or_else(|| "0".to_string()); + let transports = check + .details + .iter() + .filter_map(|detail| detail.split_once(" servers: ")) + .filter(|(transport, _)| *transport != "configured" && *transport != "disabled") + .map(|(transport, count)| format!("{count} {transport}")) + .collect::>(); + if transports.is_empty() { + format!("{count} servers · {disabled} disabled") + } else { + format!( + "{} server ({}) · {} disabled", + count, + transports.join(", "), + disabled + ) + } +} + +fn sandbox_summary(check: &DoctorCheck) -> String { + let approval = detail::detail_value(check, "approval policy"); + let filesystem = detail::detail_value(check, "filesystem sandbox"); + let network = detail::detail_value(check, "network sandbox"); + match (approval, filesystem, network) { + (Some(approval), Some(filesystem), Some(network)) => { + format!("{filesystem} fs + {network} network · approval {approval}") + } + _ => check.summary.clone(), + } +} + +fn network_summary(check: &DoctorCheck) -> String { + detail::detail_value(check, "proxy env vars") + .map(|value| { + if value == "none" { + "no proxy env vars".to_string() + } else { + "proxy env vars present".to_string() + } + }) + .unwrap_or_else(|| check.summary.clone()) +} + +fn websocket_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "handshake result") + .or_else(|| detail::detail_value(check, "handshake status")); + let timeout = detail::detail_value(check, "connect timeout") + .map(|value| value.replace("000 ms", "s").replace(" ms", "ms")); + match (status, timeout) { + (Some(status), Some(timeout)) => format!("connected ({status}) · {timeout} timeout"), + _ => check.summary.clone(), + } +} + +fn app_server_summary(check: &DoctorCheck) -> String { + let status = detail::detail_value(check, "status"); + let mode = detail::detail_value(check, "mode"); + match (status, mode) { + (Some(status), Some(mode)) => format!("{status} ({mode} mode)"), + _ => check.summary.clone(), + } +} + +fn separator(options: HumanOutputOptions) -> String { + if options.ascii { + "-".repeat(SEPARATOR_WIDTH) + } else { + "─".repeat(SEPARATOR_WIDTH) + } +} + +fn highlight_actions(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&highlight_flags(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&highlight_flags(part, options)); + } + in_code = !in_code; + } + out +} + +fn highlight_flags(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + if bare.starts_with("--") { + let highlighted = cyan(bare, options); + format!("{highlighted}{punctuation}{suffix}") + } else { + token.to_string() + } + }) + .collect() +} + +pub(super) fn redact_detail(detail: &str) -> String { + let lower = detail.to_ascii_lowercase(); + let label = lower.split(':').next().unwrap_or_default(); + if label.contains("env var") { + return redact_urls(detail); + } + if detail + .split_once(": ") + .is_some_and(|(_, value)| is_safe_presence_value(value)) + { + return redact_urls(detail); + } + + let secret_keys = [ + "openai_api_key", + "codex_api_key", + "codex_access_token", + "authorization", + "bearer_token", + "token", + "secret", + ]; + if secret_keys.iter().any(|key| lower.contains(key)) { + let name = detail.split(':').next().unwrap_or(detail); + format!("{name}: ") + } else { + redact_urls(detail) + } +} + +fn is_safe_presence_value(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "true" | "false" | "yes" | "no" | "present" | "absent" | "missing" | "not set" + ) +} + +fn redact_urls(detail: &str) -> String { + detail + .split_inclusive(char::is_whitespace) + .map(redact_url_token) + .collect() +} + +fn redact_url_token(token: &str) -> String { + let Some(scheme_end) = token.find("://") else { + return token.to_string(); + }; + let mut suffix_start = token.len(); + while suffix_start > scheme_end + 3 + && matches!( + token.as_bytes()[suffix_start - 1], + b' ' | b'\t' | b'\n' | b'\r' | b'.' | b',' | b';' | b':' | b')' | b']' + ) + { + suffix_start -= 1; + } + + let (body, suffix) = token.split_at(suffix_start); + let scheme_prefix_end = scheme_end + 3; + let rest = &body[scheme_prefix_end..]; + let authority_end = rest + .find(['/', '?', '#']) + .map(|index| scheme_prefix_end + index) + .unwrap_or(body.len()); + let authority = &body[scheme_prefix_end..authority_end]; + let authority = authority + .rsplit_once('@') + .map_or(authority, |(_, host)| host); + let path = &body[authority_end..]; + let path = path + .find(['?', '#']) + .map(|index| &path[..index]) + .unwrap_or(path); + let path = redact_url_path(path); + format!( + "{}{}{}{}", + &body[..scheme_prefix_end], + authority, + path, + suffix + ) +} + +fn redact_url_path(path: &str) -> String { + let mut segments = path.split('/').filter(|segment| !segment.is_empty()); + let Some(first_segment) = segments.next() else { + return path.to_string(); + }; + if segments.next().is_some() { + format!("/{first_segment}/") + } else { + path.to_string() + } +} + +#[derive(Default)] +struct StatusCounts { + ok: usize, + idle: usize, + notes: usize, + warning: usize, + fail: usize, +} + +impl StatusCounts { + fn from_report(report: &DoctorReport, notes: usize) -> Self { + let mut counts = Self { + notes, + ..Self::default() + }; + for check in &report.checks { + match display_status(check) { + DisplayStatus::Ok => counts.ok += 1, + DisplayStatus::Idle => counts.idle += 1, + DisplayStatus::Warning => counts.warning += 1, + DisplayStatus::Fail => counts.fail += 1, + DisplayStatus::Update | DisplayStatus::Note => {} + } + } + counts + } +} + +fn bold(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.bold().to_string() + } else { + text.to_string() + } +} + +fn dim(text: &str, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.dimmed().to_string() + } else { + text.to_string() + } +} + +fn very_dim(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 238, options) +} + +fn detail_label(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 240, options) +} + +fn detail_value(text: &str, options: HumanOutputOptions) -> String { + if !options.color_enabled { + return text.to_string(); + } + style_detail_text(text, options) +} + +fn style_detail_text(text: &str, options: HumanOutputOptions) -> String { + let mut out = String::new(); + let mut parts = text.split('`'); + if let Some(first) = parts.next() { + out.push_str(&style_detail_plain_text(first, options)); + } + let mut in_code = true; + for part in parts { + if in_code { + out.push_str(&cyan(part, options)); + } else { + out.push_str(&style_detail_plain_text(part, options)); + } + in_code = !in_code; + } + out +} + +fn style_detail_plain_text(text: &str, options: HumanOutputOptions) -> String { + text.split_inclusive(char::is_whitespace) + .map(|token| style_detail_token(token, options)) + .collect() +} + +fn style_detail_token(token: &str, options: HumanOutputOptions) -> String { + let trimmed = token.trim_end(); + let suffix = &token[trimmed.len()..]; + let bare = trimmed.trim_end_matches([',', '.', ':', ';', ')']); + let punctuation = &trimmed[bare.len()..]; + let styled = style_detail_bare_token(bare, options); + format!("{styled}{punctuation}{suffix}") +} + +fn style_detail_bare_token(bare: &str, options: HumanOutputOptions) -> String { + if bare.is_empty() { + return String::new(); + } + if bare == "" { + return color256(&bare.italic().to_string(), /*code*/ 244, options); + } + if bare.contains("(missing)") || detail::is_falsy(bare) { + return color256(bare, /*code*/ 240, options); + } + if let Some((label, value)) = bare.split_once(':') + && detail::is_falsy(value) + { + return format!("{label}:{}", color256(value, /*code*/ 240, options)); + } + if bare == "ok" { + return green(bare, options); + } + if bare.starts_with("--") || looks_copyable(bare) { + return cyan(bare, options); + } + if matches!(bare, "B" | "KB" | "MB" | "GB" | "TB" | "files" | "file") { + return dim(bare, options); + } + bare.to_string() +} + +fn green(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 10, options) +} + +fn amber(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 220, options) +} + +fn orange(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 214, options) +} + +fn red(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 196, options) +} + +fn cyan(text: &str, options: HumanOutputOptions) -> String { + color256(text, /*code*/ 117, options) +} + +fn color256(text: &str, code: u8, options: HumanOutputOptions) -> String { + if options.color_enabled { + text.color(XtermColors::from(code)).to_string() + } else { + text.to_string() + } +} + +fn looks_copyable(text: &str) -> bool { + text.starts_with("http://") + || text.starts_with("https://") + || text.starts_with("wss://") + || text.starts_with("~/") + || text.starts_with('/') + || text.starts_with("./") + || text.starts_with("../") +} + +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use super::*; + + fn detailed_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn summary_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: false, + } + } + + fn detailed_all_no_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: true, + ascii: false, + color_enabled: false, + } + } + + fn detailed_color_unicode_options() -> HumanOutputOptions { + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: true, + } + } + + fn sample_report() -> DoctorReport { + let checks = vec![ + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + "running local build on darwin-arm64", + ), + DoctorCheck::new( + "installation", + "install", + CheckStatus::Ok, + "installation looks consistent", + ), + DoctorCheck::new( + "runtime.search", + "search", + CheckStatus::Ok, + "search is OK (bundled)", + ), + DoctorCheck::new( + "terminal.env", + "terminal", + CheckStatus::Warning, + "narrow terminal", + ), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ), + DoctorCheck::new( + "auth.credentials", + "auth", + CheckStatus::Fail, + "token expired", + ) + .detail("OPENAI_API_KEY: present") + .remediation("Run `codex login`."), + DoctorCheck::new( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ), + DoctorCheck::new( + "network.env", + "network", + CheckStatus::Ok, + "network environment readable", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ), + ]; + DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Fail, + codex_version: "0.0.0".to_string(), + checks, + } + } + + #[test] + fn render_human_report_includes_details_by_default_without_color() { + let rendered = render_human_report(&sample_report(), detailed_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + managed by npm: no · bun: no · package root — + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + OPENAI_API_KEY present + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +--summary compact output --all expand truncated lists +--json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_summary_output_without_color() { + let rendered = render_human_report(&sample_report(), summary_no_color_unicode_options()); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + ⚠ terminal narrow terminal + ✗ auth token expired - Run `codex login`. +───────────────────────────────────────────────────────────── + +Environment + ✓ runtime running local build on darwin-arm64 + ✓ install consistent + ✓ search search is OK (bundled) + ⚠ terminal narrow terminal + ✓ state state paths inspectable + +Configuration + ✗ auth token expired — Run `codex login`. + +Updates + ✓ updates update configuration is locally consistent + +Connectivity + ✓ network network environment readable + ✓ websocket Responses WebSocket handshake succeeded + ✓ reachability active provider endpoints are reachable over HTTP + +Background Server + ✓ app-server background server is not running + +{} +9 ok · 2 notes · 1 warn · 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "─".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_supports_ascii_output() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: true, + color_enabled: false, + }, + ); + let expected = format!( + "\ +Codex Doctor v0.0.0 + +Notes + [!!] terminal narrow terminal + [XX] auth token expired - Run `codex login`. +------------------------------------------------------------- + +Environment + [ok] runtime running local build on darwin-arm64 + [ok] install consistent + [ok] search search is OK (bundled) + [!!] terminal narrow terminal + [ok] state state paths inspectable + +Configuration + [XX] auth token expired - Run `codex login`. + +Updates + [ok] updates update configuration is locally consistent + +Connectivity + [ok] network network environment readable + [ok] websocket Responses WebSocket handshake succeeded + [ok] reachability active provider endpoints are reachable over HTTP + +Background Server + [ok] app-server background server is not running + +{} +9 ok | 2 notes | 1 warn | 1 fail failed + +Run codex doctor without --summary for detailed diagnostics. +--all expand truncated lists --json redacted report +", + "-".repeat(SEPARATOR_WIDTH) + ); + assert_eq!(rendered, expected); + } + + #[test] + fn render_human_report_includes_redacted_details() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: true, + show_all: false, + ascii: false, + color_enabled: false, + }, + ); + assert!(rendered.contains(" OPENAI_API_KEY present")); + } + + #[test] + fn render_human_report_explains_terminal_warning_issue() { + 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( + "terminal.env", + "terminal", + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .detail("terminal: Ghostty") + .detail("terminal version: 1.3.1") + .detail("terminal size: 79x26") + .issue( + super::super::DoctorIssue::new( + CheckStatus::Warning, + "width 79 cols - output may wrap (recommended >=80)", + ) + .expected(">= 80 columns") + .remedy("resize the window to at least 80 columns") + .field("terminal size"), + ), + ], + }; + + let rendered = render_human_report(&report, detailed_no_color_unicode_options()); + + assert!( + rendered.contains("⚠ terminal width 79 cols - output may wrap (recommended >=80)") + ); + assert!(rendered.contains("▸ terminal size 79x26 (expected >= 80 columns)")); + assert!(rendered.contains("→ resize the window to at least 80 columns")); + assert!(!rendered.contains("⚠ terminal Ghostty 1.3.1")); + } + + #[test] + fn render_human_report_promotes_notes_without_changing_statuses() { + 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( + "updates.status", + "updates", + CheckStatus::Ok, + "update configuration is locally consistent", + ) + .detail("latest version status: newer version is available") + .detail("latest version: 0.130.0") + .detail("dismissed version: 0.128.0"), + DoctorCheck::new( + "state.paths", + "state", + CheckStatus::Ok, + "state paths inspectable", + ) + .detail("active rollout files: 1515 files, 2702146365 total bytes, 1783594 average bytes"), + DoctorCheck::new( + "sandbox.helpers", + "sandbox", + CheckStatus::Ok, + "sandbox configuration is readable", + ) + .detail("filesystem sandbox: danger-full-access") + .detail("network sandbox: restricted") + .detail("approval policy: Never"), + DoctorCheck::new( + "mcp.config", + "mcp", + CheckStatus::Warning, + "MCP configuration has optional issues", + ), + DoctorCheck::new( + "network.websocket_reachability", + "websocket", + CheckStatus::Ok, + "Responses WebSocket handshake succeeded", + ) + .detail("auth mode: chatgpt"), + DoctorCheck::new( + "network.provider_reachability", + "reachability", + CheckStatus::Ok, + "active provider endpoints are reachable over HTTP", + ) + .detail("reachability mode: API key auth"), + DoctorCheck::new( + "app_server.status", + "app-server", + CheckStatus::Ok, + "background server is not running", + ) + .detail("status: not running") + .detail("mode: ephemeral"), + ], + }; + + let rendered = render_human_report(&report, summary_no_color_unicode_options()); + + assert!(rendered.contains("Notes\n ↑ updates")); + assert!(rendered.contains("0.130.0 available (current 0.0.0, dismissed 0.128.0)")); + assert!(rendered.contains("⚠ rollouts")); + assert!(rendered.contains("⚠ sandbox")); + assert!(rendered.contains("⚠ mcp")); + assert!(rendered.contains( + "⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode" + )); + assert!(rendered.contains("○ app-server not running (ephemeral mode)")); + assert!(rendered.contains("5 ok · 1 idle · 5 notes · 1 warn · 0 fail degraded")); + } + + #[test] + fn render_human_report_expands_feature_flags_with_all() { + let report = DoctorReport { + schema_version: 1, + generated_at: "0s since unix epoch".to_string(), + overall_status: CheckStatus::Ok, + codex_version: "0.0.0".to_string(), + checks: vec![ + DoctorCheck::new("config.load", "config", CheckStatus::Ok, "config loaded") + .detail("model: gpt-5.5") + .detail("model provider: openai") + .detail("feature flags enabled: 3") + .detail("enabled feature flags: shell_tool, memories, goals") + .detail("feature flag overrides: memories=true"), + ], + }; + + let compact = render_human_report(&report, detailed_no_color_unicode_options()); + let expanded = render_human_report(&report, detailed_all_no_color_unicode_options()); + + assert!(!compact.contains("enabled flags")); + assert!( + compact.contains( + "feature flags 3 enabled · 1 overridden (full list with --all)" + ) + ); + assert!(expanded.contains("enabled flags shell_tool, memories, goals")); + } + + #[test] + fn detail_value_colors_inline_statuses_and_low_signal_values() { + let rendered = detail_value( + "npm: no · commit unknown · integrity ok · ~/code/codex/target/debug/codex · ", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("npm: \u{1b}[38;5;240mno")); + assert!(rendered.contains("\u{1b}[38;5;240munknown")); + assert!(rendered.contains("\u{1b}[38;5;10mok")); + assert!(rendered.contains("\u{1b}[38;5;117m~/code/codex/target/debug/codex")); + assert!(rendered.contains("\u{1b}[38;5;244m")); + } + + #[test] + fn update_note_emphasizes_available_version_and_dims_context() { + let rendered = style_update_note_summary( + "0.130.0 available (current 0.0.0, dismissed 0.128.0)", + detailed_color_unicode_options(), + ); + + assert!(rendered.contains("\u{1b}[38;5;220m0.130.0 available")); + assert!(rendered.contains("\u{1b}[2m(current 0.0.0, dismissed 0.128.0)")); + } + + #[test] + fn redact_detail_sanitizes_urls() { + let redacted = redact_detail( + "reachability failed: https://user:pass@example.com/mcp?x=abc#frag (connect failed)", + ); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp (connect failed)" + ); + } + + #[test] + fn redact_detail_sanitizes_secret_url_path_segments() { + let redacted = redact_detail("reachability failed: https://example.com/mcp/abc123xyz"); + + assert_eq!( + redacted, + "reachability failed: https://example.com/mcp/" + ); + } + + #[test] + fn redact_detail_preserves_env_var_names() { + assert_eq!( + redact_detail("auth env vars present: OPENAI_API_KEY, CODEX_API_KEY"), + "auth env vars present: OPENAI_API_KEY, CODEX_API_KEY" + ); + } + + #[test] + fn redact_detail_preserves_secret_presence_booleans() { + assert_eq!( + redact_detail("stored ChatGPT tokens: true"), + "stored ChatGPT tokens: true" + ); + assert_eq!( + redact_detail("stored ChatGPT tokens: false"), + "stored ChatGPT tokens: false" + ); + } + + #[test] + fn render_human_report_can_emit_color() { + let rendered = render_human_report( + &sample_report(), + HumanOutputOptions { + show_details: false, + show_all: false, + ascii: false, + color_enabled: true, + }, + ); + assert!(rendered.contains("\u{1b}[")); + } +} diff --git a/codex-rs/cli/src/doctor/output/detail.rs b/codex-rs/cli/src/doctor/output/detail.rs new file mode 100644 index 0000000000..4b5f49991b --- /dev/null +++ b/codex-rs/cli/src/doctor/output/detail.rs @@ -0,0 +1,648 @@ +//! Converts raw doctor detail strings into human-oriented rows. +//! +//! Checks intentionally store details as simple redacted `label: value` strings +//! so JSON serialization and human rendering share the same source data. This +//! module owns the presentation-only transformations: collapsing noisy booleans, +//! truncating long paths for terminal output, grouping repeated values, and +//! keeping the `--all` expansion behavior out of check construction. + +use std::collections::BTreeSet; +use std::env; + +use super::DoctorCheck; +use super::HumanOutputOptions; +use super::redact_detail; + +const LIST_LIMIT: usize = 7; +const PATH_LIMIT: usize = 48; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) enum HumanDetail { + Row { + label: String, + value: String, + expected: Option, + }, + Continuation(String), + Bullet(String), + Remedy(String), +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct ParsedDetail { + label: String, + value: String, +} + +pub(super) fn detail_lines(check: &DoctorCheck, options: HumanOutputOptions) -> Vec { + let parsed = parsed_details(check); + let details = match check.category.as_str() { + "runtime" => runtime_details(&parsed), + "install" => install_details(&parsed, options), + "config" => config_details(&parsed, options), + "state" => state_details(&parsed), + _ => generic_details(&parsed), + }; + let mut details = details + .into_iter() + .map(|detail| attach_issue_metadata(detail, check)) + .map(|detail| humanize_detail(detail, options)) + .collect::>(); + details.extend(issue_remedies(check)); + details +} + +pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option { + parsed_details(check) + .into_iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value) +} + +pub(super) fn rollout_summary(value: &str) -> Option { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, rest) = rest.split_once(" total bytes, ")?; + let (average_bytes, _) = rest.split_once(" average bytes")?; + let files = files.trim().parse::().ok()?; + let total_bytes = total_bytes.trim().parse::().ok()?; + let average_bytes = average_bytes.trim().parse::().ok()?; + Some(format!( + "{} files · {} (avg {})", + format_count(files), + format_bytes(total_bytes), + format_bytes(average_bytes) + )) +} + +pub(super) fn rollout_files_and_bytes(value: &str) -> Option<(u64, u64)> { + let (files, rest) = value.split_once(" files, ")?; + let (total_bytes, _) = rest.split_once(" total bytes, ")?; + Some(( + files.trim().parse::().ok()?, + total_bytes.trim().parse::().ok()?, + )) +} + +pub(super) fn format_bytes(bytes: u64) -> String { + const KIB: f64 = 1024.0; + const MIB: f64 = KIB * 1024.0; + const GIB: f64 = MIB * 1024.0; + + let bytes = bytes as f64; + if bytes >= GIB { + format!("{:.2} GB", bytes / GIB) + } else if bytes >= MIB { + format!("{:.2} MB", bytes / MIB) + } else if bytes >= KIB { + format!("{:.2} KB", bytes / KIB) + } else { + format!("{} B", bytes as u64) + } +} + +pub(super) fn format_count(count: u64) -> String { + let mut digits = count.to_string(); + let mut out = String::new(); + while digits.len() > 3 { + let tail = digits.split_off(digits.len() - 3); + if out.is_empty() { + out = tail; + } else { + out = format!("{tail},{out}"); + } + } + if out.is_empty() { + digits + } else { + format!("{digits},{out}") + } +} + +fn parsed_details(check: &DoctorCheck) -> Vec { + check + .details + .iter() + .map(|detail| redact_detail(detail)) + .map(|detail| { + detail + .split_once(": ") + .map(|(label, value)| ParsedDetail { + label: label.to_string(), + value: value.to_string(), + }) + .unwrap_or_else(|| ParsedDetail { + label: String::new(), + value: detail, + }) + }) + .collect() +} + +fn runtime_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "version", "version"); + push_row_if_present(&mut out, parsed, "install method", "install method"); + push_row_if_present(&mut out, parsed, "commit", "commit"); + push_row_if_present(&mut out, parsed, "current executable", "executable"); + push_remaining( + &mut out, + parsed, + &[ + "version", + "platform", + "install method", + "commit", + "current executable", + ], + &[], + ); + out +} + +fn install_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "install context", "context"); + if parsed.iter().any(|detail| { + detail.value == "ignored inherited package-manager launch env for cargo-built binary" + }) { + out.push(HumanDetail::Bullet( + "ignored inherited package-manager launch env for cargo-built binary".to_string(), + )); + } + + let managed_by_npm = value(parsed, "managed by npm").unwrap_or("false"); + let managed_by_bun = value(parsed, "managed by bun").unwrap_or("false"); + let package_root = value(parsed, "managed package root").unwrap_or("not set"); + out.push(HumanDetail::Row { + label: "managed by".to_string(), + value: format!( + "npm: {} · bun: {} · package root {}", + yes_no(managed_by_npm), + yes_no(managed_by_bun), + if is_falsy(package_root) { + "—".to_string() + } else { + package_root.to_string() + } + ), + expected: None, + }); + + let path_entries = numbered_values(parsed, "PATH codex #"); + if !path_entries.is_empty() { + let total = path_entries.len(); + let shown = if options.show_all { + total + } else { + total.min(3) + }; + out.push(HumanDetail::Row { + label: format!("PATH entries ({total})"), + value: path_entries[0].clone(), + expected: None, + }); + out.extend( + path_entries + .iter() + .skip(1) + .take(shown.saturating_sub(1)) + .cloned() + .map(HumanDetail::Continuation), + ); + if shown < total { + out.push(HumanDetail::Continuation( + "… (full list with --all)".to_string(), + )); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "current executable", + "install context", + "managed by npm", + "managed by bun", + "managed package root", + "PATH codex entries", + ], + &["PATH codex #"], + ); + out +} + +fn config_details(parsed: &[ParsedDetail], options: HumanOutputOptions) -> Vec { + let mut out = Vec::new(); + if let Some(model) = value(parsed, "model") { + let value = value(parsed, "model provider").map_or_else( + || model.to_string(), + |provider| format!("{model} · {provider}"), + ); + out.push(HumanDetail::Row { + label: "model".to_string(), + value, + expected: None, + }); + } + push_row_if_present(&mut out, parsed, "cwd", "cwd"); + push_row_if_present(&mut out, parsed, "config.toml", "config.toml"); + push_row_if_present(&mut out, parsed, "config.toml parse", "config.toml parse"); + push_row_if_present(&mut out, parsed, "config.toml read", "config.toml read"); + push_row_if_present(&mut out, parsed, "mcp servers", "MCP servers"); + push_feature_flags(&mut out, parsed, options); + + for detail in parsed + .iter() + .filter(|detail| detail.label == "legacy feature flag") + { + out.push(HumanDetail::Row { + label: "legacy alias".to_string(), + value: detail.value.clone(), + expected: None, + }); + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "cwd", + "model", + "model provider", + "log dir", + "sqlite home", + "mcp servers", + "feature flags enabled", + "enabled feature flags", + "feature flag overrides", + "legacy feature flag", + "config.toml", + "config.toml parse", + "config.toml read", + ], + &[], + ); + out +} + +fn state_details(parsed: &[ParsedDetail]) -> Vec { + let mut out = Vec::new(); + push_row_if_present(&mut out, parsed, "CODEX_HOME", "CODEX_HOME"); + push_row_if_present(&mut out, parsed, "log dir", "log dir"); + push_row_if_present(&mut out, parsed, "sqlite home", "sqlite home"); + push_database_row(&mut out, parsed, "state DB"); + push_database_row(&mut out, parsed, "log DB"); + + for (source, label) in [ + ("active rollout files", "active rollouts"), + ("archived rollout files", "archived rollouts"), + ] { + if let Some(value) = value(parsed, source) { + out.push(HumanDetail::Row { + label: label.to_string(), + value: rollout_summary(value).unwrap_or_else(|| value.to_string()), + expected: None, + }); + } + } + + push_remaining( + &mut out, + parsed, + &[ + "CODEX_HOME", + "log dir", + "sqlite home", + "state DB", + "log DB", + "state DB integrity", + "log DB integrity", + "active rollout files", + "archived rollout files", + ], + &[], + ); + out +} + +fn generic_details(parsed: &[ParsedDetail]) -> Vec { + parsed + .iter() + .map(|detail| { + if detail.label.is_empty() { + HumanDetail::Bullet(detail.value.clone()) + } else { + HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + } + } + }) + .collect() +} + +fn push_feature_flags( + out: &mut Vec, + parsed: &[ParsedDetail], + options: HumanOutputOptions, +) { + let enabled_count = value(parsed, "feature flags enabled") + .and_then(|value| value.parse::().ok()) + .unwrap_or_default(); + let overrides = list_items(value(parsed, "feature flag overrides").unwrap_or("none")); + let override_count = overrides.len(); + let hint = if !options.show_all && enabled_count > 0 { + " (full list with --all)" + } else { + "" + }; + out.push(HumanDetail::Row { + label: "feature flags".to_string(), + value: format!("{enabled_count} enabled · {override_count} overridden{hint}"), + expected: None, + }); + + if !overrides.is_empty() { + push_list_row(out, "overrides", &override_names(&overrides), options); + } + if options.show_all { + let enabled = list_items(value(parsed, "enabled feature flags").unwrap_or("none")); + if !enabled.is_empty() { + push_list_row(out, "enabled flags", &enabled, options); + } + } +} + +fn push_list_row( + out: &mut Vec, + label: &str, + items: &[String], + options: HumanOutputOptions, +) { + let limit = if options.show_all { + items.len() + } else { + items.len().min(LIST_LIMIT) + }; + let mut value = items + .iter() + .take(limit) + .cloned() + .collect::>() + .join(", "); + if limit < items.len() { + value.push_str(", … (full list with --all)"); + } + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_database_row(out: &mut Vec, parsed: &[ParsedDetail], label: &str) { + let Some(path) = value(parsed, label) else { + return; + }; + let integrity = value(parsed, &format!("{label} integrity")); + let value = integrity.map_or_else( + || path.to_string(), + |integrity| format!("{path} · integrity {integrity}"), + ); + out.push(HumanDetail::Row { + label: label.to_string(), + value, + expected: None, + }); +} + +fn push_row_if_present( + out: &mut Vec, + parsed: &[ParsedDetail], + source_label: &str, + display_label: &str, +) { + if let Some(value) = value(parsed, source_label) { + out.push(HumanDetail::Row { + label: display_label.to_string(), + value: value.to_string(), + expected: None, + }); + } +} + +fn push_remaining( + out: &mut Vec, + parsed: &[ParsedDetail], + consumed_labels: &[&str], + consumed_prefixes: &[&str], +) { + for detail in parsed { + if detail.value == "ignored inherited package-manager launch env for cargo-built binary" { + continue; + } + if consumed_labels.contains(&detail.label.as_str()) + || consumed_prefixes + .iter() + .any(|prefix| detail.label.starts_with(prefix)) + { + continue; + } + if detail.label.is_empty() { + out.push(HumanDetail::Bullet(detail.value.clone())); + } else { + out.push(HumanDetail::Row { + label: display_label(&detail.label), + value: detail.value.clone(), + expected: None, + }); + } + } +} + +fn humanize_detail(detail: HumanDetail, options: HumanOutputOptions) -> HumanDetail { + match detail { + HumanDetail::Row { + label, + value, + expected, + } => HumanDetail::Row { + label, + value: humanize_value(&value, options), + expected, + }, + HumanDetail::Continuation(value) => { + HumanDetail::Continuation(humanize_value(&value, options)) + } + HumanDetail::Bullet(value) => HumanDetail::Bullet(humanize_value(&value, options)), + HumanDetail::Remedy(value) => HumanDetail::Remedy(value), + } +} + +fn attach_issue_metadata(detail: HumanDetail, check: &DoctorCheck) -> HumanDetail { + let HumanDetail::Row { + label, + value, + expected, + } = detail + else { + return detail; + }; + let expected = expected.or_else(|| issue_expected_for_label(check, &label)); + HumanDetail::Row { + label, + value, + expected, + } +} + +fn issue_expected_for_label(check: &DoctorCheck, label: &str) -> Option { + check + .issues + .iter() + .find(|issue| { + issue + .fields + .iter() + .any(|field| display_label(field) == label || field == label) + }) + .and_then(|issue| issue.expected.clone()) +} + +fn issue_remedies(check: &DoctorCheck) -> Vec { + let mut seen = BTreeSet::new(); + check + .issues + .iter() + .filter_map(|issue| issue.remedy.as_ref()) + .filter(|remedy| seen.insert((*remedy).clone())) + .cloned() + .map(HumanDetail::Remedy) + .collect() +} + +fn humanize_value(value: &str, _options: HumanOutputOptions) -> String { + if looks_like_path(value) { + return shorten_path_prefix(value); + } + if let Some(timestamp) = humanize_timestamp(value) { + return timestamp; + } + value.to_string() +} + +fn humanize_timestamp(value: &str) -> Option { + if value.len() < 17 || !value.ends_with('Z') { + return None; + } + let (date, time) = value.split_once('T')?; + let hour_minute = time.get(..5)?; + Some(format!("{date} {hour_minute} UTC")) +} + +fn shorten_path_prefix(value: &str) -> String { + let (path, suffix) = value.split_once(" (").map_or_else( + || (value, String::new()), + |(path, suffix)| (path, format!(" ({suffix}")), + ); + let home_shortened = home_shortened_path(path); + let shortened = middle_truncate(&home_shortened, PATH_LIMIT); + format!("{shortened}{suffix}") +} + +fn home_shortened_path(path: &str) -> String { + let Some(home) = env::var_os("HOME").and_then(|home| home.into_string().ok()) else { + return path.to_string(); + }; + if path == home { + "~".to_string() + } else { + path.strip_prefix(&format!("{home}/")) + .map_or_else(|| path.to_string(), |tail| format!("~/{tail}")) + } +} + +fn middle_truncate(value: &str, max_chars: usize) -> String { + let char_count = value.chars().count(); + if char_count <= max_chars { + return value.to_string(); + } + let head_len = max_chars / 2; + let tail_len = max_chars.saturating_sub(head_len + 1); + let head = value.chars().take(head_len).collect::(); + let tail = value + .chars() + .rev() + .take(tail_len) + .collect::() + .chars() + .rev() + .collect::(); + format!("{head}…{tail}") +} + +fn looks_like_path(value: &str) -> bool { + value.starts_with('/') + || value.starts_with("~/") + || value.starts_with("./") + || value.starts_with("../") +} + +fn numbered_values(parsed: &[ParsedDetail], prefix: &str) -> Vec { + parsed + .iter() + .filter(|detail| detail.label.starts_with(prefix)) + .map(|detail| detail.value.clone()) + .collect() +} + +fn value<'a>(parsed: &'a [ParsedDetail], label: &str) -> Option<&'a str> { + parsed + .iter() + .find(|detail| detail.label == label) + .map(|detail| detail.value.as_str()) +} + +fn display_label(label: &str) -> String { + match label { + "codex-linux-sandbox helper" => "linux helper", + "optional reachability failed" => "optional reachability", + "check for update on startup" => "startup update check", + other => other, + } + .to_string() +} + +fn list_items(value: &str) -> Vec { + if is_falsy(value) { + return Vec::new(); + } + value + .split(',') + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() +} + +fn override_names(items: &[String]) -> Vec { + items + .iter() + .map(|item| item.split_once('=').map_or(item.as_str(), |(name, _)| name)) + .map(str::to_string) + .collect() +} + +fn yes_no(value: &str) -> &'static str { + if value == "true" { "yes" } else { "no" } +} + +pub(super) fn is_falsy(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "" | "false" | "none" | "not set" | "unknown" | "missing" | "absent" | "no" | "—" | "-" + ) +} diff --git a/codex-rs/cli/src/doctor/progress.rs b/codex-rs/cli/src/doctor/progress.rs new file mode 100644 index 0000000000..ba8d65a357 --- /dev/null +++ b/codex-rs/cli/src/doctor/progress.rs @@ -0,0 +1,139 @@ +use std::io; +use std::io::IsTerminal; +use std::io::Write; +use std::sync::Mutex; +use std::time::Duration; + +use super::CheckStatus; + +/// Receives check lifecycle events while doctor builds the final report. +/// +/// Progress implementations must not write to stdout. The final report owns +/// stdout so JSON and redirected human reports stay clean. +pub(super) trait DoctorProgress: Send + Sync { + fn begin(&self, label: &'static str); + fn heartbeat(&self, label: &'static str, elapsed: Duration); + fn finish(&self, label: &'static str, status: CheckStatus); + fn settle(&self); +} + +/// Selects the progress implementation for the current output mode. +/// +/// JSON output is always quiet so stdout remains valid JSON. Human output uses a +/// transient stderr line only for interactive terminals, then clears it before +/// the final report is printed. +pub(super) fn doctor_progress(json: bool) -> std::sync::Arc { + if should_show_progress( + json, + std::env::var("TERM").ok().as_deref(), + io::stderr().is_terminal(), + ) { + std::sync::Arc::new(StderrProgress::default()) + } else { + std::sync::Arc::new(QuietProgress) + } +} + +fn should_show_progress(json: bool, term: Option<&str>, stderr_is_tty: bool) -> bool { + !json && stderr_is_tty && term != Some("dumb") +} + +struct QuietProgress; + +impl DoctorProgress for QuietProgress { + fn begin(&self, _label: &'static str) {} + + fn heartbeat(&self, _label: &'static str, _elapsed: Duration) {} + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) {} +} + +#[derive(Default)] +struct StderrProgress { + state: Mutex, +} + +#[derive(Default)] +struct StderrProgressState { + wrote_line: bool, +} + +impl StderrProgress { + fn render(&self, message: String) { + let Ok(mut state) = self.state.lock() else { + return; + }; + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K{message}"); + let _ = stderr.flush(); + state.wrote_line = true; + } +} + +impl DoctorProgress for StderrProgress { + fn begin(&self, label: &'static str) { + self.render(format!("Checking {label}...")); + } + + fn heartbeat(&self, label: &'static str, elapsed: Duration) { + self.render(format!("Still checking {label}... {}s", elapsed.as_secs())); + } + + fn finish(&self, _label: &'static str, _status: CheckStatus) {} + + fn settle(&self) { + let Ok(mut state) = self.state.lock() else { + return; + }; + if !state.wrote_line { + return; + } + let mut stderr = io::stderr().lock(); + let _ = write!(stderr, "\r\x1b[2K"); + let _ = stderr.flush(); + state.wrote_line = false; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn progress_is_quiet_for_json() { + assert!(!should_show_progress( + /*json*/ true, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_quiet_for_non_tty() { + assert!(!should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ false, + )); + } + + #[test] + fn progress_is_quiet_for_dumb_terminal() { + assert!(!should_show_progress( + /*json*/ false, + Some("dumb"), + /*stderr_is_tty*/ true, + )); + } + + #[test] + fn progress_is_shown_for_human_tty_output() { + assert!(should_show_progress( + /*json*/ false, + Some("xterm-256color"), + /*stderr_is_tty*/ true, + )); + } +} diff --git a/codex-rs/cli/src/doctor/runtime.rs b/codex-rs/cli/src/doctor/runtime.rs new file mode 100644 index 0000000000..96e806762c --- /dev/null +++ b/codex-rs/cli/src/doctor/runtime.rs @@ -0,0 +1,141 @@ +//! Captures how this Codex process was launched. +//! +//! Runtime diagnostics answer provenance questions that are hard to infer from +//! user reports: which binary is running, which install channel it resembles, +//! which platform it targets, and whether the search command comes from bundled +//! standalone resources or from PATH. + +use std::env; +use std::process::Command; + +use codex_install_context::InstallContext; + +use super::CheckStatus; +use super::DoctorCheck; +use super::describe_install_context; +use super::doctor_install_context; +use super::push_path_detail; + +/// Builds the process provenance row for the current Codex executable. +/// +/// This check is informational and should not fail on its own; inconsistent +/// install state is reported by the installation and update checks instead. +pub(super) fn runtime_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let os = env::consts::OS; + let arch = env::consts::ARCH; + let platform = format!("{os}-{arch}"); + let install_method = install_method_name(&install_context); + let mut details = vec![ + format!("version: {}", env!("CARGO_PKG_VERSION")), + format!("platform: {platform}"), + format!( + "install method: {}", + describe_install_context(&install_context) + ), + format!("commit: {}", build_commit()), + ]; + push_path_detail(&mut details, "current executable", current_exe.as_deref()); + + DoctorCheck::new( + "runtime.provenance", + "runtime", + CheckStatus::Ok, + format!("running {install_method} on {platform}"), + ) + .details(details) +} + +/// Verifies that the search command selected by the install context is usable. +/// +/// Standalone installs should point at a bundled ripgrep binary, while local or +/// package-managed installs usually resolve rg from PATH. A warning here means +/// features that depend on file search may degrade even when the CLI launches. +pub(super) fn search_check() -> DoctorCheck { + let current_exe = env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let rg_command = install_context.rg_command(); + let provider = search_provider(&install_context); + let mut details = vec![ + format!("search command: {}", rg_command.display()), + format!("search provider: {provider}"), + ]; + + let status = if rg_command.components().count() > 1 { + match std::fs::metadata(&rg_command) { + Ok(metadata) if metadata.is_file() => { + details.push("search command readiness: file exists".to_string()); + CheckStatus::Ok + } + Ok(_) => { + details.push("search command readiness: path is not a file".to_string()); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + } else { + match Command::new(&rg_command).arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout) + .lines() + .next() + .unwrap_or("rg version unknown") + .to_string(); + details.push(format!("search command readiness: {version}")); + CheckStatus::Ok + } + Ok(output) => { + details.push(format!( + "search command readiness: exited with status {}", + output.status + )); + CheckStatus::Warning + } + Err(err) => { + details.push(format!("search command readiness: {err}")); + CheckStatus::Warning + } + } + }; + + let summary = match status { + CheckStatus::Ok => format!("search is OK ({provider})"), + CheckStatus::Warning => "search command could not be verified".to_string(), + CheckStatus::Fail => unreachable!(), + }; + let mut check = DoctorCheck::new("runtime.search", "search", status, summary).details(details); + if status != CheckStatus::Ok { + check = check.remediation("Install ripgrep or repair the bundled standalone resources."); + } + check +} + +fn install_method_name(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { .. } => "standalone", + InstallContext::Npm => "npm", + InstallContext::Bun => "bun", + InstallContext::Brew => "brew", + InstallContext::Other => "local build", + } +} + +fn search_provider(context: &InstallContext) -> &'static str { + match context { + InstallContext::Standalone { + resources_dir: Some(resources_dir), + .. + } if context.rg_command().starts_with(resources_dir) => "bundled", + _ => "system", + } +} + +fn build_commit() -> &'static str { + option_env!("CODEX_BUILD_COMMIT") + .or(option_env!("GIT_COMMIT")) + .unwrap_or("unknown") +} diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs new file mode 100644 index 0000000000..3d728fc07b --- /dev/null +++ b/codex-rs/cli/src/doctor/updates.rs @@ -0,0 +1,227 @@ +//! Diagnoses whether Codex update paths target the running installation. +//! +//! Update diagnostics combine cached version metadata, install-channel hints, +//! and bounded latest-version probes. For npm-managed launches, this module also +//! verifies that npm install -g would update the package root that launched the +//! current process, which catches PATH and prefix mismatches before the user runs +//! an update command. + +use std::path::Path; + +use codex_core::config::Config; +use codex_install_context::InstallContext; +use serde::Deserialize; + +use super::CheckStatus; +use super::DoctorCheck; +use super::NpmRootCheck; +use super::doctor_install_context; +use super::doctor_managed_by_npm; +use super::npm_global_root_check; +use super::run_command; + +const VERSION_FILE_NAME: &str = "version.json"; +const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; +const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json"; + +/// Builds the update-health row for the current installation. +/// +/// Network failures while fetching latest-version metadata degrade the row to a +/// warning instead of failing doctor outright; update freshness is useful +/// support context but should not mask more direct install/config failures. +pub(super) fn updates_check(config: &Config) -> DoctorCheck { + let current_exe = std::env::current_exe().ok(); + let install_context = doctor_install_context(current_exe.as_deref()); + let mut details = vec![ + format!( + "check for update on startup: {}", + config.check_for_update_on_startup + ), + format!("update action: {}", update_action_label(&install_context)), + ]; + let version_file = config.codex_home.join(VERSION_FILE_NAME); + push_cached_version_details(&mut details, &version_file); + + let mut status = CheckStatus::Ok; + let mut summary = "update configuration is locally consistent".to_string(); + let mut remediation = None; + + 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 = "update would target a different npm install".to_string(); + details.push(format!( + "running package root: {}", + running_package_root.display() + )); + details.push(format!("npm package root: {}", npm_package_root.display())); + 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() + )); + } + NpmRootCheck::MissingPackageRoot => { + status = status.max(CheckStatus::Warning); + summary = "npm update target could not be proven".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 update target could not be inspected".to_string(); + details.push(format!("npm root -g failed: {error}")); + } + } + } + + match fetch_latest_version(&install_context) { + Ok(latest_version) => { + details.push(format!("latest version: {latest_version}")); + if is_newer(&latest_version, env!("CARGO_PKG_VERSION")) == Some(true) { + details.push("latest version status: newer version is available".to_string()); + } else { + details.push("latest version status: current version is not older".to_string()); + } + } + Err(err) => { + status = status.max(CheckStatus::Warning); + details.push(format!("latest version probe: {err}")); + } + } + + let mut check = DoctorCheck::new("updates.status", "updates", status, summary).details(details); + if let Some(remediation) = remediation { + check = check.remediation(remediation); + } + check +} + +fn push_cached_version_details(details: &mut Vec, version_file: &Path) { + details.push(format!("version cache: {}", version_file.display())); + match std::fs::read_to_string(version_file) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(info) => { + details.push(format!("cached latest version: {}", info.latest_version)); + if let Some(last_checked_at) = info.last_checked_at { + details.push(format!("last checked at: {last_checked_at}")); + } + if let Some(dismissed_version) = info.dismissed_version { + details.push(format!("dismissed version: {dismissed_version}")); + } + } + Err(err) => details.push(format!("version cache parse: {err}")), + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + details.push("version cache: missing".to_string()); + } + Err(err) => details.push(format!("version cache read: {err}")), + } +} + +fn update_action_label(context: &InstallContext) -> &'static str { + match context { + InstallContext::Npm => "npm install -g @openai/codex", + InstallContext::Bun => "bun install -g @openai/codex", + InstallContext::Brew => "brew upgrade --cask codex", + InstallContext::Standalone { .. } => "standalone installer", + InstallContext::Other => "manual or unknown", + } +} + +fn fetch_latest_version(context: &InstallContext) -> Result { + match context { + InstallContext::Brew => fetch_homebrew_cask_version(), + InstallContext::Npm + | InstallContext::Bun + | InstallContext::Standalone { .. } + | InstallContext::Other => fetch_latest_github_release_version(), + } +} + +fn fetch_latest_github_release_version() -> Result { + #[derive(Deserialize)] + struct ReleaseInfo { + tag_name: String, + } + + let info = http_get_json::(GITHUB_LATEST_RELEASE_URL)?; + info.tag_name + .strip_prefix("rust-v") + .map(str::to_string) + .ok_or_else(|| format!("failed to parse latest tag {}", info.tag_name)) +} + +fn fetch_homebrew_cask_version() -> Result { + #[derive(Deserialize)] + struct HomebrewCaskInfo { + version: String, + } + + http_get_json::(HOMEBREW_CASK_API_URL).map(|info| info.version) +} + +fn http_get_json(url: &str) -> Result +where + T: for<'de> Deserialize<'de>, +{ + let body = run_command("curl", ["-fsSL", "--max-time", "5", url])?; + serde_json::from_str::(&body).map_err(|err| err.to_string()) +} + +fn is_newer(latest: &str, current: &str) -> Option { + match (parse_version(latest), parse_version(current)) { + (Some(latest), Some(current)) => Some(latest > current), + (Some(_), None) | (None, Some(_)) | (None, None) => None, + } +} + +fn parse_version(value: &str) -> Option<(u64, u64, u64)> { + let mut parts = value.trim().split('.'); + let major = parts.next()?.parse::().ok()?; + let minor = parts.next()?.parse::().ok()?; + let patch = parts.next()?.parse::().ok()?; + Some((major, minor, patch)) +} + +#[derive(Deserialize)] +struct VersionInfo { + latest_version: String, + #[serde(default)] + last_checked_at: Option, + #[serde(default)] + dismissed_version: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_newer_compares_plain_semver() { + assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true)); + assert_eq!(is_newer("1.2.3", "1.2.4"), Some(false)); + assert_eq!(is_newer("1.2.3-beta.1", "1.2.2"), None); + } + + #[test] + fn update_action_labels_install_contexts() { + assert_eq!( + update_action_label(&InstallContext::Npm), + "npm install -g @openai/codex" + ); + assert_eq!( + update_action_label(&InstallContext::Other), + "manual or unknown" + ); + } +} diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index ef54aa52cd..00c798517c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -46,6 +46,7 @@ use supports_color::Stream; mod app_cmd; #[cfg(any(target_os = "macos", target_os = "windows"))] mod desktop_app; +mod doctor; mod marketplace_cmd; mod mcp_cmd; #[cfg(not(windows))] @@ -53,6 +54,7 @@ mod wsl_paths; use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; +use doctor::DoctorCommand; use codex_core::build_models_manager; use codex_core::config::ConfigBuilder; @@ -142,6 +144,9 @@ enum Subcommand { /// Update Codex to the latest version. Update, + /// Diagnose local Codex installation, config, auth, and runtime health. + Doctor(DoctorCommand), + /// Run commands within a Codex-provided sandbox. Sandbox(SandboxArgs), @@ -1163,6 +1168,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; run_update_command()?; } + Some(Subcommand::Doctor(doctor_cli)) => { + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "doctor", + )?; + doctor::run_doctor( + doctor_cli, + root_config_overrides.clone(), + &interactive, + &arg0_paths, + ) + .await?; + } Some(Subcommand::Cloud(mut cloud_cli)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1688,7 +1707,8 @@ fn unsupported_subcommand_name_for_strict_config( | Some(Subcommand::Review(_)) | Some(Subcommand::McpServer(_)) | Some(Subcommand::Resume(_)) - | Some(Subcommand::Fork(_)) => None, + | Some(Subcommand::Fork(_)) + | Some(Subcommand::Doctor(_)) => None, Some(Subcommand::AppServer(app_server)) if app_server.subcommand.is_none() => None, Some(Subcommand::AppServer(app_server)) => { Some(app_server_subcommand_name(app_server.subcommand.as_ref())) diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs index c16687ff28..70c52b9580 100644 --- a/codex-rs/codex-api/src/endpoint/mod.rs +++ b/codex-rs/codex-api/src/endpoint/mod.rs @@ -24,4 +24,6 @@ pub use realtime_websocket::session_update_session_json; pub use responses::ResponsesClient; pub use responses::ResponsesOptions; pub use responses_websocket::ResponsesWebsocketClient; +pub use responses_websocket::ResponsesWebsocketClose; pub use responses_websocket::ResponsesWebsocketConnection; +pub use responses_websocket::ResponsesWebsocketProbe; diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index c5a682b328..f0a0019817 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -35,6 +35,7 @@ use tokio_tungstenite::connect_async_tls_with_config; use tokio_tungstenite::tungstenite::Error as WsError; use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::protocol::CloseFrame; use tracing::Instrument; use tracing::Span; use tracing::debug; @@ -321,12 +322,40 @@ impl ResponsesWebsocketConnection { } } +/// Client for connecting to the Responses WebSocket endpoint for one provider. pub struct ResponsesWebsocketClient { provider: Provider, auth: SharedAuthProvider, } +/// Close frame information captured by a handshake probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketClose { + /// WebSocket close code returned by the server. + pub code: String, + /// Human-readable close reason returned by the server. + pub reason: String, +} + +/// Result of a handshake-only Responses WebSocket probe. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResponsesWebsocketProbe { + /// Redacted by callers before displaying or serializing support reports. + pub url: String, + /// HTTP status returned by the successful WebSocket upgrade. + pub status: StatusCode, + /// Whether the server reported reasoning support in the upgrade response. + pub reasoning_included: bool, + /// Whether the server returned a model catalog ETag in the upgrade response. + pub models_etag_present: bool, + /// Whether the server returned a server-selected model in the upgrade response. + pub server_model_present: bool, + /// Close frame received immediately after upgrade, when one arrives quickly. + pub immediate_close: Option, +} + impl ResponsesWebsocketClient { + /// Creates a Responses WebSocket client for an already-resolved provider and auth source. pub fn new(provider: Provider, auth: SharedAuthProvider) -> Self { Self { provider, auth } } @@ -353,7 +382,7 @@ impl ResponsesWebsocketClient { merge_request_headers(&self.provider.headers, extra_headers, default_headers); self.auth.add_auth_headers(&mut headers); - let (stream, server_reasoning_included, models_etag, server_model) = + let (stream, _status, server_reasoning_included, models_etag, server_model) = connect_websocket(ws_url, headers, turn_state.clone()).await?; Ok(ResponsesWebsocketConnection::new( stream, @@ -364,6 +393,64 @@ impl ResponsesWebsocketClient { telemetry, )) } + + /// Opens a WebSocket connection long enough to validate the upgrade response. + /// + /// The probe uses the same URL construction, headers, authentication, TLS, + /// and custom-CA path as a real Responses WebSocket connection, but it does + /// not send a request frame. After the HTTP 101 upgrade succeeds, it waits + /// briefly for an immediate server close frame so diagnostics can distinguish + /// a usable connection from a policy rejection that closes right away. + pub async fn probe_handshake( + &self, + extra_headers: HeaderMap, + default_headers: HeaderMap, + immediate_close_timeout: Duration, + ) -> Result { + let ws_url = self + .provider + .websocket_url_for_path("responses") + .map_err(|err| ApiError::Stream(format!("failed to build websocket URL: {err}")))?; + + let mut headers = + merge_request_headers(&self.provider.headers, extra_headers, default_headers); + self.auth.add_auth_headers(&mut headers); + + let (mut stream, status, reasoning_included, models_etag, server_model) = + connect_websocket(ws_url.clone(), headers, /*turn_state*/ None).await?; + let immediate_close = tokio::time::timeout(immediate_close_timeout, stream.next()) + .await + .ok() + .flatten() + .transpose() + .map_err(|err| { + ApiError::Stream(format!("failed to read websocket probe event: {err}")) + })? + .and_then(immediate_close_from_message); + + Ok(ResponsesWebsocketProbe { + url: ws_url.to_string(), + status, + reasoning_included, + models_etag_present: models_etag.is_some(), + server_model_present: server_model.is_some(), + immediate_close, + }) + } +} + +fn immediate_close_from_message(message: Message) -> Option { + let Message::Close(frame) = message else { + return None; + }; + frame.map(close_frame_to_probe) +} + +fn close_frame_to_probe(frame: CloseFrame) -> ResponsesWebsocketClose { + ResponsesWebsocketClose { + code: frame.code.to_string(), + reason: frame.reason.to_string(), + } } fn merge_request_headers( @@ -385,7 +472,7 @@ async fn connect_websocket( url: Url, headers: HeaderMap, turn_state: Option>>, -) -> Result<(WsStream, bool, Option, Option), ApiError> { +) -> Result<(WsStream, StatusCode, bool, Option, Option), ApiError> { ensure_rustls_crypto_provider(); info!("connecting to websocket: {url}"); @@ -445,6 +532,7 @@ async fn connect_websocket( } Ok(( WsStream::new(stream), + response.status(), reasoning_included, models_etag, server_model, diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs index d7017c8d3f..99470cac59 100644 --- a/codex-rs/codex-api/src/lib.rs +++ b/codex-rs/codex-api/src/lib.rs @@ -55,7 +55,9 @@ pub use crate::endpoint::RealtimeWebsocketWriter; pub use crate::endpoint::ResponsesClient; pub use crate::endpoint::ResponsesOptions; pub use crate::endpoint::ResponsesWebsocketClient; +pub use crate::endpoint::ResponsesWebsocketClose; pub use crate::endpoint::ResponsesWebsocketConnection; +pub use crate::endpoint::ResponsesWebsocketProbe; pub use crate::endpoint::session_update_session_json; pub use crate::error::ApiError; pub use crate::files::upload_local_file; diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs index 0adf864d64..9a8caf489a 100644 --- a/codex-rs/feedback/src/lib.rs +++ b/codex-rs/feedback/src/lib.rs @@ -27,6 +27,8 @@ pub use feedback_diagnostics::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; pub use feedback_diagnostics::FeedbackDiagnostic; pub use feedback_diagnostics::FeedbackDiagnostics; +/// Filename used for the redacted `codex doctor --json` feedback attachment. +pub const DOCTOR_REPORT_ATTACHMENT_FILENAME: &str = "codex-doctor-report.json"; const DEFAULT_MAX_BYTES: usize = 4 * 1024 * 1024; // 4 MiB const SENTRY_DSN: &str = "https://ae32ed50620d7a7792c1ce5df38b3e3e@o33249.ingest.us.sentry.io/4510195390611458"; @@ -344,11 +346,37 @@ pub struct FeedbackAttachmentPath { pub attachment_filename_override: Option, } +/// In-memory attachment to include in a feedback upload. +/// +/// Use this for generated diagnostics that should not be materialized on disk, +/// such as the redacted doctor report. File-backed artifacts should use +/// `FeedbackAttachmentPath` so upload-time read failures can be logged and +/// skipped independently. +pub struct FeedbackAttachment { + /// Attachment filename shown in Sentry and in the feedback consent UI. + pub filename: String, + /// Optional MIME type for consumers that render or classify attachments. + pub content_type: Option, + /// Attachment bytes captured before the upload starts. + pub buffer: Vec, +} + +/// Inputs that control one feedback upload to Sentry. +/// +/// The caller is responsible for applying any user-consent gate before setting +/// `include_logs` or passing diagnostic attachments. This type only describes +/// what to upload once that decision has been made. pub struct FeedbackUploadOptions<'a> { pub classification: &'a str, pub reason: Option<&'a str>, pub tags: Option<&'a BTreeMap>, pub include_logs: bool, + /// Generated attachments that are already buffered and safe to upload. + /// + /// These are included after `codex-logs.log` and before path-backed rollout + /// attachments. They are only passed by the caller after any user consent + /// gate has decided logs and diagnostics should be uploaded. + pub extra_attachments: &'a [FeedbackAttachment], pub extra_attachment_paths: &'a [FeedbackAttachmentPath], pub session_source: Option, pub logs_override: Option>, @@ -444,6 +472,7 @@ impl FeedbackSnapshot { for attachment in self.feedback_attachments( options.include_logs, + options.extra_attachments, options.extra_attachment_paths, options.logs_override, ) { @@ -507,6 +536,7 @@ impl FeedbackSnapshot { fn feedback_attachments( &self, include_logs: bool, + extra_attachments: &[FeedbackAttachment], extra_attachment_paths: &[FeedbackAttachmentPath], logs_override: Option>, ) -> Vec { @@ -523,6 +553,13 @@ impl FeedbackSnapshot { }); } + attachments.extend(extra_attachments.iter().map(|attachment| Attachment { + buffer: attachment.buffer.clone(), + filename: attachment.filename.clone(), + content_type: attachment.content_type.clone(), + ty: None, + })); + if let Some(text) = self.feedback_diagnostics_attachment_text(include_logs) { attachments.push(Attachment { buffer: text.into_bytes(), @@ -704,6 +741,11 @@ mod tests { let attachments_with_diagnostics = snapshot_with_diagnostics.feedback_attachments( /*include_logs*/ true, + &[FeedbackAttachment { + filename: DOCTOR_REPORT_ATTACHMENT_FILENAME.to_string(), + content_type: Some("application/json".to_string()), + buffer: b"{\"overallStatus\":\"ok\"}".to_vec(), + }], std::slice::from_ref(&extra_attachment_path), Some(vec![1]), ); @@ -715,6 +757,7 @@ mod tests { .collect::>(), vec![ "codex-logs.log", + DOCTOR_REPORT_ATTACHMENT_FILENAME, FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME, extra_filename.as_str() ] @@ -722,17 +765,21 @@ mod tests { assert_eq!(attachments_with_diagnostics[0].buffer, vec![1]); assert_eq!( attachments_with_diagnostics[1].buffer, + b"{\"overallStatus\":\"ok\"}".to_vec() + ); + assert_eq!( + attachments_with_diagnostics[2].buffer, b"Connectivity diagnostics\n\n- Proxy environment variables are set and may affect connectivity.\n - HTTPS_PROXY = https://example.com:443".to_vec() ); - assert_eq!(attachments_with_diagnostics[2].buffer, b"rollout".to_vec()); + assert_eq!(attachments_with_diagnostics[3].buffer, b"rollout".to_vec()); assert_eq!( - OsStr::new(attachments_with_diagnostics[2].filename.as_str()), + OsStr::new(attachments_with_diagnostics[3].filename.as_str()), OsStr::new(extra_filename.as_str()) ); let attachments_without_diagnostics = CodexFeedback::new() .snapshot(/*session_id*/ None) .with_feedback_diagnostics(FeedbackDiagnostics::default()) - .feedback_attachments(/*include_logs*/ true, &[], Some(vec![1])); + .feedback_attachments(/*include_logs*/ true, &[], &[], Some(vec![1])); assert_eq!( attachments_without_diagnostics diff --git a/codex-rs/state/src/lib.rs b/codex-rs/state/src/lib.rs index 3c98a77978..43c02faae2 100644 --- a/codex-rs/state/src/lib.rs +++ b/codex-rs/state/src/lib.rs @@ -55,6 +55,7 @@ pub use runtime::ThreadGoalAccountingOutcome; pub use runtime::ThreadGoalUpdate; pub use runtime::logs_db_filename; pub use runtime::logs_db_path; +pub use runtime::sqlite_integrity_check; pub use runtime::state_db_filename; pub use runtime::state_db_path; pub use telemetry::DbTelemetry; diff --git a/codex-rs/state/src/runtime.rs b/codex-rs/state/src/runtime.rs index a5748da9eb..d6ed7bfcc0 100644 --- a/codex-rs/state/src/runtime.rs +++ b/codex-rs/state/src/runtime.rs @@ -300,11 +300,30 @@ pub fn logs_db_path(codex_home: &Path) -> PathBuf { codex_home.join(logs_db_filename()) } +/// Run SQLite's built-in integrity check against an existing database file. +pub async fn sqlite_integrity_check(path: &Path) -> anyhow::Result> { + let options = SqliteConnectOptions::new() + .filename(path) + .create_if_missing(false) + .read_only(true) + .log_statements(LevelFilter::Off); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await?; + let rows = sqlx::query_scalar::<_, String>("PRAGMA integrity_check") + .fetch_all(&pool) + .await?; + pool.close().await; + Ok(rows) +} + #[cfg(test)] mod tests { use super::StateRuntime; use super::open_state_sqlite; use super::runtime_state_migrator; + use super::sqlite_integrity_check; use super::state_db_path; use super::test_support::unique_temp_dir; use crate::DB_INIT_METRIC; @@ -380,6 +399,34 @@ mod tests { .expect("open sqlite pool") } + #[tokio::test] + async fn sqlite_integrity_check_reports_ok_for_valid_db() { + let codex_home = unique_temp_dir(); + tokio::fs::create_dir_all(&codex_home) + .await + .expect("create codex home"); + let path = state_db_path(codex_home.as_path()); + let pool = SqlitePool::connect_with( + SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .expect("open sqlite db"); + sqlx::query("CREATE TABLE sample (id INTEGER PRIMARY KEY)") + .execute(&pool) + .await + .expect("create sample table"); + pool.close().await; + + let result = sqlite_integrity_check(&path) + .await + .expect("integrity check should run"); + + assert_eq!(result, vec!["ok".to_string()]); + let _ = tokio::fs::remove_dir_all(codex_home).await; + } + #[tokio::test] async fn open_state_sqlite_tolerates_newer_applied_migrations() { let codex_home = unique_temp_dir(); diff --git a/codex-rs/terminal-detection/src/lib.rs b/codex-rs/terminal-detection/src/lib.rs index 6474c189a5..c42733380d 100644 --- a/codex-rs/terminal-detection/src/lib.rs +++ b/codex-rs/terminal-detection/src/lib.rs @@ -64,7 +64,10 @@ pub enum Multiplexer { version: Option, }, /// zellij terminal multiplexer. - Zellij {}, + Zellij { + /// Zellij version string when ZELLIJ_VERSION is available. + version: Option, + }, } /// tmux client terminal identification captured via `tmux display-message`. @@ -207,7 +210,7 @@ impl TerminalInfo { /// Returns whether the active terminal multiplexer is Zellij. pub fn is_zellij(&self) -> bool { - matches!(self.multiplexer, Some(Multiplexer::Zellij {})) + matches!(self.multiplexer, Some(Multiplexer::Zellij { .. })) } } @@ -237,6 +240,11 @@ trait Environment { /// Returns tmux client details when available. fn tmux_client_info(&self) -> TmuxClientInfo; + + /// Returns Zellij version details when available. + fn zellij_version(&self) -> Option { + self.var_non_empty("ZELLIJ_VERSION") + } } /// Reads environment variables from the running process. @@ -257,6 +265,11 @@ impl Environment for ProcessEnvironment { fn tmux_client_info(&self) -> TmuxClientInfo { tmux_client_info() } + + fn zellij_version(&self) -> Option { + self.var_non_empty("ZELLIJ_VERSION") + .or_else(zellij_version_from_command) + } } /// Returns a sanitized terminal identifier for User-Agent strings. @@ -385,7 +398,9 @@ fn detect_multiplexer(env: &dyn Environment) -> Option { || env.has_non_empty("ZELLIJ_SESSION_NAME") || env.has_non_empty("ZELLIJ_VERSION") { - return Some(Multiplexer::Zellij {}); + return Some(Multiplexer::Zellij { + version: env.zellij_version(), + }); } None @@ -456,6 +471,32 @@ fn tmux_display_message(format: &str) -> Option { none_if_whitespace(value.trim().to_string()) } +fn zellij_version_from_command() -> Option { + // Best-effort fallback: missing or broken zellij binaries should not affect + // terminal detection. + let output = std::process::Command::new("zellij") + .arg("--version") + .output() + .ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + parse_zellij_version(stdout.trim()) +} + +fn parse_zellij_version(value: &str) -> Option { + let value = none_if_whitespace(value.to_string())?; + let mut parts = value.split_whitespace(); + match (parts.next(), parts.next()) { + (Some(command), Some(version)) if command.eq_ignore_ascii_case("zellij") => { + Some(version.to_string()) + } + _ => Some(value), + } +} + /// Sanitizes a terminal token for use in User-Agent headers. /// /// Invalid header characters are replaced with underscores. diff --git a/codex-rs/terminal-detection/src/terminal_tests.rs b/codex-rs/terminal-detection/src/terminal_tests.rs index ec2a3b1166..54f0a7a5a9 100644 --- a/codex-rs/terminal-detection/src/terminal_tests.rs +++ b/codex-rs/terminal-detection/src/terminal_tests.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; struct FakeEnvironment { vars: HashMap, tmux_client_info: TmuxClientInfo, + zellij_version: Option, } impl FakeEnvironment { @@ -12,6 +13,7 @@ impl FakeEnvironment { Self { vars: HashMap::new(), tmux_client_info: TmuxClientInfo::default(), + zellij_version: None, } } @@ -27,6 +29,11 @@ impl FakeEnvironment { }; self } + + fn with_zellij_version(mut self, version: &str) -> Self { + self.zellij_version = Some(version.to_string()); + self + } } impl Environment for FakeEnvironment { @@ -37,6 +44,12 @@ impl Environment for FakeEnvironment { fn tmux_client_info(&self) -> TmuxClientInfo { self.tmux_client_info.clone() } + + fn zellij_version(&self) -> Option { + self.zellij_version + .clone() + .or_else(|| self.var_non_empty("ZELLIJ_VERSION")) + } } fn terminal_info( @@ -129,7 +142,7 @@ fn terminal_info_reports_is_zellij() { /*term_program*/ None, /*version*/ None, /*term*/ None, - Some(Multiplexer::Zellij {}), + Some(Multiplexer::Zellij { version: None }), ); assert!(zellij.is_zellij()); @@ -312,12 +325,62 @@ fn detects_zellij_multiplexer() { term_program: None, version: None, term: None, - multiplexer: Some(Multiplexer::Zellij {}), + multiplexer: Some(Multiplexer::Zellij { version: None }), }, "zellij_multiplexer" ); } +#[test] +fn detects_zellij_multiplexer_version() { + let env = FakeEnvironment::new().with_var("ZELLIJ_VERSION", "0.43.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Zellij { + version: Some("0.43.1".to_string()), + }), + ), + "zellij_multiplexer_version" + ); +} + +#[test] +fn detects_zellij_multiplexer_command_version() { + let env = FakeEnvironment::new() + .with_var("ZELLIJ", "1") + .with_zellij_version("0.44.1"); + let terminal = detect_terminal_info_from_env(&env); + assert_eq!( + terminal, + terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Zellij { + version: Some("0.44.1".to_string()), + }), + ), + "zellij_multiplexer_command_version" + ); +} + +#[test] +fn parses_zellij_version_output() { + assert_eq!( + parse_zellij_version("zellij 0.44.1"), + Some("0.44.1".to_string()) + ); + assert_eq!(parse_zellij_version("0.44.1"), Some("0.44.1".to_string())); + assert_eq!(parse_zellij_version(""), None); +} + #[test] fn detects_tmux_client_termtype() { let env = FakeEnvironment::new() diff --git a/codex-rs/tui/src/bottom_pane/feedback_view.rs b/codex-rs/tui/src/bottom_pane/feedback_view.rs index 59e30aba7b..04bcbd0b00 100644 --- a/codex-rs/tui/src/bottom_pane/feedback_view.rs +++ b/codex-rs/tui/src/bottom_pane/feedback_view.rs @@ -1,3 +1,4 @@ +use codex_feedback::DOCTOR_REPORT_ATTACHMENT_FILENAME; use codex_feedback::FEEDBACK_DIAGNOSTICS_ATTACHMENT_FILENAME; use codex_feedback::FeedbackDiagnostics; use crossterm::event::KeyCode; @@ -502,6 +503,11 @@ pub(crate) fn feedback_upload_consent_params( Line::from("").into(), Line::from("The following files will be sent:".dim()).into(), Line::from(vec![" • ".into(), "codex-logs.log".into()]).into(), + Line::from(vec![ + " • ".into(), + DOCTOR_REPORT_ATTACHMENT_FILENAME.into(), + ]) + .into(), ]; if let Some(path) = rollout_path.as_deref() && let Some(name) = path.file_name().map(|s| s.to_string_lossy().to_string()) @@ -538,7 +544,7 @@ pub(crate) fn feedback_upload_consent_params( super::SelectionItem { name: "Yes".to_string(), description: Some( - "Share the current Codex session logs with the team for troubleshooting." + "Share the current Codex session logs and diagnostics with the team for troubleshooting." .to_string(), ), actions: vec![yes_action], @@ -572,7 +578,18 @@ mod tests { let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); view.render(area, &mut buf); + render_buffer(area, &buf) + } + fn render_renderable(renderable: &dyn Renderable, width: u16) -> String { + let height = renderable.desired_height(width); + let area = Rect::new(0, 0, width, height); + let mut buf = Buffer::empty(area); + renderable.render(area, &mut buf); + render_buffer(area, &buf) + } + + fn render_buffer(area: Rect, buf: &Buffer) -> String { let mut lines: Vec = (0..area.height) .map(|row| { let mut line = String::new(); @@ -670,6 +687,23 @@ mod tests { insta::assert_snapshot!("feedback_view_with_connectivity_diagnostics", rendered); } + #[test] + fn feedback_upload_consent_lists_doctor_report() { + let (tx_raw, _rx) = tokio::sync::mpsc::unbounded_channel::(); + let tx = AppEventSender::new(tx_raw); + let params = feedback_upload_consent_params( + tx, + FeedbackCategory::Bug, + Some(std::path::PathBuf::from("rollout.jsonl")), + Some("auto-review-rollout.jsonl".to_string()), + &FeedbackDiagnostics::default(), + ); + + let rendered = render_renderable(params.header.as_ref(), /*width*/ 60); + + insta::assert_snapshot!("feedback_upload_consent_lists_doctor_report", rendered); + } + #[test] fn submit_feedback_emits_submit_event_with_trimmed_note() { let (tx_raw, mut rx) = tokio::sync::mpsc::unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap new file mode 100644 index 0000000000..6cca54542d --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_upload_consent_lists_doctor_report.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/bottom_pane/feedback_view.rs +assertion_line: 704 +expression: rendered +--- +Upload logs? + +The following files will be sent: + • codex-logs.log + • codex-doctor-report.json + • rollout.jsonl + • auto-review-rollout.jsonl diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap index bafa94b09d..98fabc8444 100644 --- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__feedback_view__tests__feedback_view_render.snap @@ -11,7 +11,7 @@ expression: rendered -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No 3. Cancel diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap index 2ba27d409f..cdeedc7b78 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap @@ -6,11 +6,12 @@ expression: popup The following files will be sent: • codex-logs.log + • codex-doctor-report.json • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index b027333ad7..354e473a18 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -6,6 +6,7 @@ expression: popup The following files will be sent: • codex-logs.log + • codex-doctor-report.json • auto-review-rollout-thread-1.jsonl • codex-connectivity-diagnostics.txt @@ -13,8 +14,8 @@ expression: popup - Proxy environment variables are set and may affect connectivity. - HTTPS_PROXY = hello -› 1. Yes Share the current Codex session logs with the team for - troubleshooting. +› 1. Yes Share the current Codex session logs and diagnostics with the team + for troubleshooting. 2. No Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs index 25a20c3528..cf241aee9f 100644 --- a/codex-rs/tui/src/pets/image_protocol.rs +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -132,7 +132,7 @@ fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { Some(Multiplexer::Tmux { .. }) => { return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux); } - Some(Multiplexer::Zellij {}) => { + Some(Multiplexer::Zellij { .. }) => { return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij); } None => {} @@ -389,7 +389,7 @@ mod tests { assert_eq!( pet_image_support_for_terminal(&terminal_info_for_test( TerminalName::Kitty, - Some(Multiplexer::Zellij {}), + Some(Multiplexer::Zellij { version: None }), Some("kitty"), /*term*/ None, )),