mirror of
https://github.com/openai/codex.git
synced 2026-05-29 23:40:29 +00:00
feat(cli): add codex doctor diagnostics (#22336)
## Why Users and support need a single command that captures the local Codex runtime, configuration, auth, terminal, network, and state shape without asking the user to know which diagnostic depth to choose first. `codex doctor` now runs the useful checks by default and makes the detailed human output the default because the command is usually run when someone already needs context. The command also targets concrete support failure modes we have seen while iterating on the design: - update-target mismatches like #21956, where the installed package manager target can differ from the running executable - terminal and multiplexer issues that depend on `TERM`, tmux/zellij state, color handling, and TTY metadata - provider-specific HTTP/WebSocket connectivity, including ChatGPT WebSocket handshakes and API-key/provider endpoint reachability - local state/log SQLite integrity problems and large rollout directories - feedback reports that need an attached, redacted diagnostic snapshot without asking the user to run a second command ## What Changed - Adds `codex doctor` as a grouped CLI diagnostic report with default detailed output and `--summary` for the compact view. - Adds stable report sections for Environment, Configuration, Updates, Connectivity, and Background Server, plus a top Notes block that promotes anomalies such as available updates, large rollout directories, optional MCP issues, and mixed auth signals. - Adds runtime provenance, install consistency, bundled/system search readiness, terminal/multiplexer metadata, `config.toml` parse status, auth mode details, sandbox details, feature flag summaries, update cache/latest-version state, app-server daemon state, SQLite integrity checks, rollout statistics, and provider-aware network diagnostics. - Adds ChatGPT WebSocket diagnostics that report the negotiated HTTP upgrade as `HTTP 101 Switching Protocols` and include timeout, DNS, auth, and provider context in detailed output. - Makes reachability provider-aware: API-key OpenAI setups check the API endpoint, ChatGPT auth checks the ChatGPT path, and custom/AWS/local providers check configured HTTP endpoints when available. - Adds structured, redacted JSON output where `checks` is keyed by check id and `details` is a key/value object for support tooling. - Integrates doctor with feedback uploads by attaching a best-effort `codex-doctor-report.json` report and adding derived Sentry tags for overall status and failing/warning checks. - Updates the TUI feedback consent copy so users can see that the doctor report is included when logs/diagnostics are uploaded. - Updates the CLI bug issue template to ask reporters for `codex doctor --json` and render pasted reports as JSON. ## Example Output The examples below are sanitized from local smoke runs with `--no-color` so the structure is reviewable in plain text. ### `codex doctor` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build version 0.0.0 install method other commit unknown executable ~/code/codex.fcoury-doct…x-rs/target/debug/codex ✓ install consistent context other managed by npm: no · bun: no · package root — PATH entries (2) ~/.local/share/mise/installs/node/24/bin/codex ~/.local/share/mise/shims/codex ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color terminal Ghostty TERM_PROGRAM ghostty terminal version 1.3.2-main-+b0f827665 TERM xterm-256color multiplexer tmux 3.6a tmux extended-keys on tmux allow-passthrough on tmux set-clipboard on ✓ state databases healthy CODEX_HOME ~/.codex (dir) state DB ~/.codex/state_5.sqlite (file) · integrity ok log DB ~/.codex/logs_2.sqlite (file) · integrity ok active rollouts 1,526 files · 2.53 GB (avg 1.70 MB) archived rollouts 8 files · 3.84 MB (avg 491.11 KB) Configuration ✓ config loaded model gpt-5.5 · openai cwd ~/code/codex.fcoury-doctor/codex-rs config.toml ~/.codex/config.toml config.toml parse ok MCP servers 1 feature flags 36 enabled · 7 overridden (full list with --all) overrides code_mode, code_mode_only, memories, chronicle, goals, remote_control, prevent_idle_sleep ✓ auth auth is configured auth storage mode File auth file ~/.codex/auth.json auth env vars present OPENAI_API_KEY stored auth mode chatgpt stored API key false stored ChatGPT tokens true stored agent identity false ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. configured servers 1 disabled servers 0 streamable_http servers 1 optional reachability openaiDeveloperDocs: https://developers.openai.com/mcp (HEAD connect failed; GET connect failed) ✓ sandbox restricted fs + restricted network · approval OnRequest approval policy OnRequest filesystem sandbox restricted network sandbox restricted Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout model provider openai provider name OpenAI wire API responses supports websockets true connect timeout 15000 ms auth mode chatgpt endpoint wss://chatgpt.com/backend-api/<redacted> DNS 2 IPv4, 2 IPv6, first IPv6 handshake result HTTP 101 Switching Protocols ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. reachability mode API key auth openai API https://api.openai.com/v1 connect failed (required) Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report ``` ### `codex doctor --summary` ```text Codex Doctor v0.0.0 · macos-aarch64 Notes ↑ updates 0.130.0 available (current 0.0.0, dismissed 0.128.0) ⚠ rollouts 1,526 active files · 2.53 GB on disk ⚠ mcp MCP configuration has optional issues ⚠ auth mixed auth signals: ChatGPT login plus API key env var; HTTP reachability uses API-key mode ───────────────────────────────────────────────────────────── Environment ✓ runtime local debug build ✓ install consistent ✓ search ripgrep 15.1.0 (system, `rg`) ✓ terminal Ghostty 1.3.2-main-+b0f827665 · tmux 3.6a · TERM=xterm-256color ✓ state databases healthy Configuration ✓ config loaded ✓ auth auth is configured ⚠ mcp MCP configuration has optional issues — Set the missing MCP env vars or disable the affected server. ✓ sandbox restricted fs + restricted network · approval OnRequest Updates ✓ updates update configuration is locally consistent Connectivity ✓ network network-related environment looks readable ✓ websocket connected (HTTP 101 Switching Protocols) · 15s timeout ✗ reachability one or more required provider endpoints are unreachable over HTTP — Check proxy, VPN, firewall, DNS, and custom CA configuration. Background Server ○ app-server not running (ephemeral mode) ───────────────────────────────────────────────────────────── 11 ok · 1 idle · 4 notes · 1 warn · 1 fail failed Run codex doctor without --summary for detailed diagnostics. --all expand truncated lists --json redacted report ``` ### `codex doctor --json` shape ```json { "schema_version": 1, "overall_status": "fail", "checks": { "runtime.provenance": { "id": "runtime.provenance", "category": "Environment", "status": "ok", "summary": "local debug build", "details": { "version": "0.0.0", "install method": "other", "commit": "unknown" } }, "sandbox.helpers": { "id": "sandbox.helpers", "category": "Configuration", "status": "ok", "summary": "restricted fs + restricted network · approval OnRequest", "details": { "approval policy": "OnRequest", "filesystem sandbox": "restricted", "network sandbox": "restricted" } } } } ``` ### `/feedback` new sentry attachment <img width="938" height="798" alt="CleanShot 2026-05-13 at 15 36 14" src="https://github.com/user-attachments/assets/715e62e0-d7b4-4fea-a35a-fd5d5d33c4c0" /> ### New section in CLI issue template <img width="1164" height="435" alt="CleanShot 2026-05-13 at 15 47 24" src="https://github.com/user-attachments/assets/9081dc25-a28c-4afa-8ba1-e299c2b4031d" /> ## How to Test 1. Run `cargo run --bin codex -- doctor --no-color`. 2. Confirm the detailed report is the default and includes promoted Notes, grouped sections, terminal details, state DB integrity, rollout stats, provider reachability, WebSocket diagnostics, and app-server status. 3. Run `cargo run --bin codex -- doctor --summary --no-color`. 4. Confirm the compact view keeps the same sections and summary counts but omits detailed key/value rows. 5. Run `cargo run --bin codex -- doctor --json`. 6. Confirm the output is redacted JSON, `checks` is an object keyed by check id, and each check's `details` is a key/value object. 7. Preview the CLI bug issue template and confirm the `Codex doctor report` field appears after the terminal field, asks for `codex doctor --json`, and renders pasted output as JSON. 8. Start a feedback flow that includes logs. 9. Confirm the upload consent copy lists `codex-doctor-report.json` alongside the log attachments. Targeted tests: - `cargo test -p codex-cli doctor` - `cargo test -p codex-app-server doctor_report_tags_summarize_status_counts` - `cargo test -p codex-feedback` - `cargo test -p codex-tui feedback_view` - `just argument-comment-lint` - `git diff --check`
This commit is contained in:
12
.github/ISSUE_TEMPLATE/3-cli.yml
vendored
12
.github/ISSUE_TEMPLATE/3-cli.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
6
codex-rs/Cargo.lock
generated
6
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String, String>,
|
||||
}
|
||||
|
||||
/// 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<DoctorFeedbackReport> {
|
||||
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<String, String> {
|
||||
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<dyn Iterator<Item = &Value> + '_> {
|
||||
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::<String>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
3844
codex-rs/cli/src/doctor.rs
Normal file
3844
codex-rs/cli/src/doctor.rs
Normal file
File diff suppressed because it is too large
Load Diff
150
codex-rs/cli/src/doctor/background.rs
Normal file
150
codex-rs/cli/src/doctor/background.rs
Normal file
@@ -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<String>, 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
|
||||
}
|
||||
}
|
||||
1555
codex-rs/cli/src/doctor/output.rs
Normal file
1555
codex-rs/cli/src/doctor/output.rs
Normal file
File diff suppressed because it is too large
Load Diff
648
codex-rs/cli/src/doctor/output/detail.rs
Normal file
648
codex-rs/cli/src/doctor/output/detail.rs
Normal file
@@ -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<String>,
|
||||
},
|
||||
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<HumanDetail> {
|
||||
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::<Vec<_>>();
|
||||
details.extend(issue_remedies(check));
|
||||
details
|
||||
}
|
||||
|
||||
pub(super) fn detail_value(check: &DoctorCheck, label: &str) -> Option<String> {
|
||||
parsed_details(check)
|
||||
.into_iter()
|
||||
.find(|detail| detail.label == label)
|
||||
.map(|detail| detail.value)
|
||||
}
|
||||
|
||||
pub(super) fn rollout_summary(value: &str) -> Option<String> {
|
||||
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::<u64>().ok()?;
|
||||
let total_bytes = total_bytes.trim().parse::<u64>().ok()?;
|
||||
let average_bytes = average_bytes.trim().parse::<u64>().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::<u64>().ok()?,
|
||||
total_bytes.trim().parse::<u64>().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<ParsedDetail> {
|
||||
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<HumanDetail> {
|
||||
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<HumanDetail> {
|
||||
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<HumanDetail> {
|
||||
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<HumanDetail> {
|
||||
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<HumanDetail> {
|
||||
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<HumanDetail>,
|
||||
parsed: &[ParsedDetail],
|
||||
options: HumanOutputOptions,
|
||||
) {
|
||||
let enabled_count = value(parsed, "feature flags enabled")
|
||||
.and_then(|value| value.parse::<usize>().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<HumanDetail>,
|
||||
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::<Vec<_>>()
|
||||
.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<HumanDetail>, 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<HumanDetail>,
|
||||
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<HumanDetail>,
|
||||
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<String> {
|
||||
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<HumanDetail> {
|
||||
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<String> {
|
||||
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::<String>();
|
||||
let tail = value
|
||||
.chars()
|
||||
.rev()
|
||||
.take(tail_len)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>();
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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" | "—" | "-"
|
||||
)
|
||||
}
|
||||
139
codex-rs/cli/src/doctor/progress.rs
Normal file
139
codex-rs/cli/src/doctor/progress.rs
Normal file
@@ -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<dyn DoctorProgress> {
|
||||
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<StderrProgressState>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
));
|
||||
}
|
||||
}
|
||||
141
codex-rs/cli/src/doctor/runtime.rs
Normal file
141
codex-rs/cli/src/doctor/runtime.rs
Normal file
@@ -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")
|
||||
}
|
||||
227
codex-rs/cli/src/doctor/updates.rs
Normal file
227
codex-rs/cli/src/doctor/updates.rs
Normal file
@@ -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<String>, 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::<VersionInfo>(&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<String, String> {
|
||||
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<String, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct ReleaseInfo {
|
||||
tag_name: String,
|
||||
}
|
||||
|
||||
let info = http_get_json::<ReleaseInfo>(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<String, String> {
|
||||
#[derive(Deserialize)]
|
||||
struct HomebrewCaskInfo {
|
||||
version: String,
|
||||
}
|
||||
|
||||
http_get_json::<HomebrewCaskInfo>(HOMEBREW_CASK_API_URL).map(|info| info.version)
|
||||
}
|
||||
|
||||
fn http_get_json<T>(url: &str) -> Result<T, String>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
let body = run_command("curl", ["-fsSL", "--max-time", "5", url])?;
|
||||
serde_json::from_str::<T>(&body).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
fn is_newer(latest: &str, current: &str) -> Option<bool> {
|
||||
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::<u64>().ok()?;
|
||||
let minor = parts.next()?.parse::<u64>().ok()?;
|
||||
let patch = parts.next()?.parse::<u64>().ok()?;
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct VersionInfo {
|
||||
latest_version: String,
|
||||
#[serde(default)]
|
||||
last_checked_at: Option<String>,
|
||||
#[serde(default)]
|
||||
dismissed_version: Option<String>,
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ResponsesWebsocketClose>,
|
||||
}
|
||||
|
||||
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<ResponsesWebsocketProbe, ApiError> {
|
||||
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<ResponsesWebsocketClose> {
|
||||
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<Arc<OnceLock<String>>>,
|
||||
) -> Result<(WsStream, bool, Option<String>, Option<String>), ApiError> {
|
||||
) -> Result<(WsStream, StatusCode, bool, Option<String>, Option<String>), 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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// Attachment bytes captured before the upload starts.
|
||||
pub buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
/// 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<String, String>>,
|
||||
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<SessionSource>,
|
||||
pub logs_override: Option<Vec<u8>>,
|
||||
@@ -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<u8>>,
|
||||
) -> Vec<sentry::protocol::Attachment> {
|
||||
@@ -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<_>>(),
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
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();
|
||||
|
||||
@@ -64,7 +64,10 @@ pub enum Multiplexer {
|
||||
version: Option<String>,
|
||||
},
|
||||
/// zellij terminal multiplexer.
|
||||
Zellij {},
|
||||
Zellij {
|
||||
/// Zellij version string when ZELLIJ_VERSION is available.
|
||||
version: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
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<Multiplexer> {
|
||||
|| 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<String> {
|
||||
none_if_whitespace(value.trim().to_string())
|
||||
}
|
||||
|
||||
fn zellij_version_from_command() -> Option<String> {
|
||||
// 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<String> {
|
||||
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.
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
struct FakeEnvironment {
|
||||
vars: HashMap<String, String>,
|
||||
tmux_client_info: TmuxClientInfo,
|
||||
zellij_version: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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()
|
||||
|
||||
@@ -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<String> = (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::<AppEvent>();
|
||||
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::<AppEvent>();
|
||||
|
||||
@@ -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
|
||||
@@ -11,7 +11,7 @@ expression: rendered
|
||||
<LOG_PATH>
|
||||
|
||||
|
||||
› 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)),
|
||||
|
||||
Reference in New Issue
Block a user