mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Add storage diagnostics to codex doctor (#38795)
## What changed - Report available space for `CODEX_HOME` and the active worktree, warning below 5 GiB and failing below 1 GiB. - On Windows, report whether the active Git worktree is on a trusted Dev Drive and provide remediation when it is not. - Run attached doctor reports against the conversation workspace, including workspaces recovered from persisted thread metadata, while keeping the subprocess anchored in `CODEX_HOME` so deleted workspaces do not prevent it from starting. ## Testing - Cover disk thresholds, measurement failures, missing paths, conversation workspace selection, command construction, Windows volume detection, and human-readable output. GitOrigin-RevId: 0a99f9d278717e7d19936ffc504984b3a0958c25
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
//! attaching exactly the same JSON a user could copy from the CLI.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use std::process::Stdio;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -30,19 +31,22 @@ pub(crate) struct DoctorFeedbackReport {
|
||||
pub(crate) tags: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// Runs `codex doctor --json` and returns a best-effort feedback attachment.
|
||||
/// Runs `codex --cd <workspace> 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> {
|
||||
pub(crate) async fn doctor_feedback_report(
|
||||
config: &Config,
|
||||
workspace: &Path,
|
||||
) -> 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");
|
||||
let mut command = doctor_command(&executable, workspace, config.codex_home.as_path());
|
||||
command.stdin(Stdio::null());
|
||||
command.kill_on_drop(/*kill_on_drop*/ true);
|
||||
let output = match timeout(DOCTOR_FEEDBACK_REPORT_TIMEOUT, command.output()).await {
|
||||
@@ -99,6 +103,18 @@ pub(crate) async fn doctor_feedback_report(config: &Config) -> Option<DoctorFeed
|
||||
})
|
||||
}
|
||||
|
||||
fn doctor_command(executable: &Path, cwd: &Path, codex_home: &Path) -> Command {
|
||||
let mut command = Command::new(executable);
|
||||
command
|
||||
.arg("--cd")
|
||||
.arg(cwd)
|
||||
.arg("doctor")
|
||||
.arg("--json")
|
||||
.current_dir(codex_home)
|
||||
.env("CODEX_HOME", codex_home);
|
||||
command
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -178,6 +194,31 @@ mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn doctor_command_keeps_missing_workspace_out_of_process_cwd() {
|
||||
let codex_home = tempdir().expect("codex home");
|
||||
let workspace = codex_home.path().join("deleted-workspace");
|
||||
|
||||
let command = doctor_command(Path::new("codex"), &workspace, codex_home.path());
|
||||
let command = command.as_std();
|
||||
|
||||
assert_eq!(command.get_current_dir(), Some(codex_home.path()));
|
||||
assert_eq!(
|
||||
command.get_args().collect::<Vec<_>>(),
|
||||
[
|
||||
"--cd".as_ref(),
|
||||
workspace.as_os_str(),
|
||||
"doctor".as_ref(),
|
||||
"--json".as_ref()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
command.get_envs().collect::<Vec<_>>(),
|
||||
[("CODEX_HOME".as_ref(), Some(codex_home.path().as_os_str()))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn doctor_report_tags_summarize_status_counts() {
|
||||
|
||||
@@ -241,13 +241,22 @@ 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);
|
||||
if include_logs {
|
||||
let doctor_cwd = feedback_cwd(
|
||||
&self.thread_manager,
|
||||
self.state_db.as_ref(),
|
||||
conversation_id,
|
||||
self.config.cwd.as_path(),
|
||||
)
|
||||
.await;
|
||||
if let Some(doctor_report) =
|
||||
super::feedback_doctor_report::doctor_feedback_report(&self.config, &doctor_cwd)
|
||||
.await
|
||||
{
|
||||
extra_attachments.push(doctor_report.attachment);
|
||||
for (key, value) in doctor_report.tags {
|
||||
upload_tags.entry(key).or_insert(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,6 +312,33 @@ impl FeedbackRequestProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn feedback_cwd(
|
||||
thread_manager: &ThreadManager,
|
||||
state_db: Option<&StateDbHandle>,
|
||||
conversation_id: Option<ThreadId>,
|
||||
fallback_cwd: &Path,
|
||||
) -> PathBuf {
|
||||
let Some(conversation_id) = conversation_id else {
|
||||
return fallback_cwd.to_path_buf();
|
||||
};
|
||||
|
||||
if let Ok(conversation) = thread_manager.get_thread(conversation_id).await {
|
||||
return conversation.config_snapshot().await.cwd().to_path_buf();
|
||||
}
|
||||
|
||||
let Some(state_db) = state_db else {
|
||||
return fallback_cwd.to_path_buf();
|
||||
};
|
||||
match state_db.get_thread(conversation_id).await {
|
||||
Ok(Some(metadata)) => metadata.cwd,
|
||||
Ok(None) => fallback_cwd.to_path_buf(),
|
||||
Err(err) => {
|
||||
warn!("failed to resolve cwd for feedback thread_id={conversation_id}: {err}");
|
||||
fallback_cwd.to_path_buf()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct FeedbackTurnMetadata {
|
||||
model: String,
|
||||
@@ -434,7 +470,28 @@ mod tests {
|
||||
use super::*;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_rollout::RolloutLine;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn doctor_uses_loaded_feedback_thread_cwd() -> anyhow::Result<()> {
|
||||
let server = start_mock_server().await;
|
||||
let test = test_codex().build_with_auto_env(&server).await?;
|
||||
let daemon_workspace = tempfile::tempdir()?;
|
||||
|
||||
let cwd = feedback_cwd(
|
||||
&test.thread_manager,
|
||||
/*state_db*/ None,
|
||||
Some(test.session_configured.thread_id),
|
||||
daemon_workspace.path(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(cwd, test.cwd_path());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feedback_tags_drop_unverified_client_prompt_tags() {
|
||||
let mut upload_tags = BTreeMap::from([
|
||||
|
||||
@@ -67,6 +67,7 @@ use serde::Serialize;
|
||||
use supports_color::Stream;
|
||||
|
||||
mod background;
|
||||
mod disk;
|
||||
mod git;
|
||||
mod output;
|
||||
mod progress;
|
||||
@@ -75,6 +76,8 @@ mod system;
|
||||
mod thread_inventory;
|
||||
mod title;
|
||||
mod updates;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows_dev_drive;
|
||||
|
||||
use background::background_server_check;
|
||||
use git::git_check;
|
||||
@@ -348,6 +351,23 @@ async fn build_report(
|
||||
|
||||
progress.begin("config");
|
||||
let config_result = load_config(root_config_overrides, interactive, arg0_paths).await;
|
||||
let cwd = config_result
|
||||
.as_ref()
|
||||
.map(|config| config.cwd.as_path().to_path_buf())
|
||||
.unwrap_or_else(|_| {
|
||||
let mut cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
if let Some(requested_cwd) = &interactive.cwd {
|
||||
cwd.push(requested_cwd);
|
||||
}
|
||||
cwd
|
||||
});
|
||||
checks.push(run_sync_check("disk", progress.clone(), || {
|
||||
disk::check(config_result.as_ref().ok(), &cwd)
|
||||
}));
|
||||
#[cfg(target_os = "windows")]
|
||||
checks.push(run_sync_check("dev drive", progress.clone(), || {
|
||||
windows_dev_drive::check(&cwd)
|
||||
}));
|
||||
match &config_result {
|
||||
Ok(config) => {
|
||||
let auth_manager_result =
|
||||
@@ -446,10 +466,6 @@ async fn build_report(
|
||||
}
|
||||
Err(err) => {
|
||||
let reachability_plan = default_reachability_plan();
|
||||
let fallback_cwd = interactive
|
||||
.cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
let (
|
||||
config_check,
|
||||
network_check,
|
||||
@@ -476,7 +492,7 @@ async fn build_report(
|
||||
terminal_check(command.no_color)
|
||||
})
|
||||
},
|
||||
run_async_check("git", progress.clone(), git_check(fallback_cwd.as_path())),
|
||||
run_async_check("git", progress.clone(), git_check(&cwd)),
|
||||
async { run_sync_check("state", progress.clone(), fallback_state_check) },
|
||||
run_async_check(
|
||||
"provider reachability",
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_core::config::Config;
|
||||
|
||||
use super::CheckStatus;
|
||||
use super::DoctorCheck;
|
||||
use super::DoctorIssue;
|
||||
use super::find_codex_home;
|
||||
|
||||
const GIB: u64 = 1024 * 1024 * 1024;
|
||||
const FAIL_THRESHOLD: u64 = GIB;
|
||||
const WARNING_THRESHOLD: u64 = 5 * GIB;
|
||||
|
||||
pub(super) fn check(config: Option<&Config>, cwd: &Path) -> DoctorCheck {
|
||||
let home = config
|
||||
.map(|config| config.codex_home.as_path().to_path_buf())
|
||||
.or_else(|| find_codex_home().ok().map(Into::into))
|
||||
.or_else(|| std::env::var_os("CODEX_HOME").map(Into::into));
|
||||
|
||||
check_with_paths(home.as_deref(), cwd, available_space)
|
||||
}
|
||||
|
||||
fn check_with_paths(
|
||||
home: Option<&Path>,
|
||||
cwd: &Path,
|
||||
measure: impl Fn(&Path) -> io::Result<u64>,
|
||||
) -> DoctorCheck {
|
||||
let home = home.and_then(|path| std::path::absolute(path).ok());
|
||||
let mut check = DoctorCheck::new(
|
||||
"system.disk",
|
||||
"disk",
|
||||
CheckStatus::Ok,
|
||||
"sufficient free disk space",
|
||||
)
|
||||
.detail(format!(
|
||||
"warning threshold: {}",
|
||||
format_capacity(WARNING_THRESHOLD)
|
||||
))
|
||||
.detail(format!(
|
||||
"failure threshold: {}",
|
||||
format_capacity(FAIL_THRESHOLD)
|
||||
));
|
||||
let mut lowest = None;
|
||||
|
||||
for (label, path) in [("CODEX_HOME", home.as_deref()), ("worktree", Some(cwd))] {
|
||||
let field = format!("{label} available");
|
||||
let available = path
|
||||
.and_then(|path| path.ancestors().find(|ancestor| ancestor.is_dir()))
|
||||
.map(&measure)
|
||||
.unwrap_or_else(|| Err(io::ErrorKind::NotFound.into()));
|
||||
match available {
|
||||
Ok(available) => {
|
||||
let measured = format_capacity(available);
|
||||
check = check.detail(format!("{field}: {measured}"));
|
||||
lowest = Some(lowest.map_or(available, |lowest: u64| lowest.min(available)));
|
||||
|
||||
let status = match available {
|
||||
0..FAIL_THRESHOLD => CheckStatus::Fail,
|
||||
FAIL_THRESHOLD..WARNING_THRESHOLD => CheckStatus::Warning,
|
||||
WARNING_THRESHOLD.. => CheckStatus::Ok,
|
||||
};
|
||||
if status != CheckStatus::Ok {
|
||||
check.status = check.status.max(status);
|
||||
check = check.issue(
|
||||
DoctorIssue::new(status, format!("{label} has insufficient disk space"))
|
||||
.measured(measured)
|
||||
.expected(format!("at least {}", format_capacity(WARNING_THRESHOLD)))
|
||||
.remedy("Free disk space or move the worktree to a larger volume.")
|
||||
.field(field),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
check.status = check.status.max(CheckStatus::Warning);
|
||||
check = check
|
||||
.detail(format!("{field}: unavailable ({:?})", error.kind()))
|
||||
.issue(
|
||||
DoctorIssue::new(
|
||||
CheckStatus::Warning,
|
||||
format!("disk space for {label} could not be checked"),
|
||||
)
|
||||
.measured(format!("{:?}", error.kind()))
|
||||
.expected("readable filesystem capacity")
|
||||
.remedy("Check filesystem access and available disk space.")
|
||||
.field(field),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
check.summary = match (check.status, lowest) {
|
||||
(CheckStatus::Fail, Some(available)) => {
|
||||
format!("critically low disk space ({})", format_capacity(available))
|
||||
}
|
||||
(CheckStatus::Warning, Some(available)) if available < WARNING_THRESHOLD => {
|
||||
format!("low disk space ({})", format_capacity(available))
|
||||
}
|
||||
(CheckStatus::Warning, _) => "disk capacity could not be fully verified".to_string(),
|
||||
(CheckStatus::Ok, Some(available)) => {
|
||||
format!(
|
||||
"sufficient free disk space ({})",
|
||||
format_capacity(available)
|
||||
)
|
||||
}
|
||||
(CheckStatus::Ok | CheckStatus::Fail, None) => {
|
||||
"disk capacity could not be verified".to_string()
|
||||
}
|
||||
};
|
||||
|
||||
check
|
||||
}
|
||||
|
||||
fn format_capacity(bytes: u64) -> String {
|
||||
if bytes >= GIB {
|
||||
format!("{:.1} GiB", bytes as f64 / GIB as f64)
|
||||
} else {
|
||||
format!("{:.1} MiB", bytes as f64 / (1024 * 1024) as f64)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn available_space(path: &Path) -> io::Result<u64> {
|
||||
use std::ffi::CString;
|
||||
use std::mem::MaybeUninit;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
|
||||
let path = CString::new(path.as_os_str().as_bytes())
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains NUL"))?;
|
||||
let mut stats = MaybeUninit::<libc::statvfs>::uninit();
|
||||
if unsafe { libc::statvfs(path.as_ptr(), stats.as_mut_ptr()) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let stats = unsafe { stats.assume_init() };
|
||||
if stats.f_files > 0 && stats.f_favail == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
u64::try_from(u128::from(stats.f_bavail) * u128::from(stats.f_frsize))
|
||||
.map_err(|_| io::Error::other("available disk space exceeds u64"))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn available_space(path: &Path) -> io::Result<u64> {
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW;
|
||||
|
||||
let mut path = path.as_os_str().encode_wide().collect::<Vec<_>>();
|
||||
if path.last() != Some(&u16::from(b'\\')) {
|
||||
path.push(u16::from(b'\\'));
|
||||
}
|
||||
path.push(0);
|
||||
let mut available = 0;
|
||||
if unsafe {
|
||||
GetDiskFreeSpaceExW(
|
||||
path.as_ptr(),
|
||||
&mut available,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
|
||||
Ok(available)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn available_space(_path: &Path) -> io::Result<u64> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"filesystem capacity is unavailable on this platform",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "disk_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::cell::RefCell;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
use super::CheckStatus;
|
||||
use super::FAIL_THRESHOLD;
|
||||
use super::GIB;
|
||||
use super::WARNING_THRESHOLD;
|
||||
use super::check;
|
||||
use super::check_with_paths;
|
||||
|
||||
#[test]
|
||||
fn disk_capacity_is_reported_without_loaded_configuration() {
|
||||
let check = check(/*config*/ None, Path::new("."));
|
||||
|
||||
for label in ["CODEX_HOME available: ", "worktree available: "] {
|
||||
assert!(
|
||||
check
|
||||
.details
|
||||
.iter()
|
||||
.any(|detail| detail.starts_with(label) && !detail.contains("unavailable"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_capacity_thresholds_produce_complete_diagnostics() {
|
||||
for (available, status, measured, summary) in [
|
||||
(0, "fail", "0.0 MiB", "critically low disk space (0.0 MiB)"),
|
||||
(
|
||||
FAIL_THRESHOLD - 1,
|
||||
"fail",
|
||||
"1024.0 MiB",
|
||||
"critically low disk space (1024.0 MiB)",
|
||||
),
|
||||
(
|
||||
FAIL_THRESHOLD,
|
||||
"warning",
|
||||
"1.0 GiB",
|
||||
"low disk space (1.0 GiB)",
|
||||
),
|
||||
(
|
||||
WARNING_THRESHOLD - 1,
|
||||
"warning",
|
||||
"5.0 GiB",
|
||||
"low disk space (5.0 GiB)",
|
||||
),
|
||||
(
|
||||
WARNING_THRESHOLD,
|
||||
"ok",
|
||||
"5.0 GiB",
|
||||
"sufficient free disk space (5.0 GiB)",
|
||||
),
|
||||
(
|
||||
WARNING_THRESHOLD + GIB,
|
||||
"ok",
|
||||
"6.0 GiB",
|
||||
"sufficient free disk space (6.0 GiB)",
|
||||
),
|
||||
] {
|
||||
let issues = ["CODEX_HOME", "worktree"]
|
||||
.into_iter()
|
||||
.filter(|_| status != "ok")
|
||||
.map(|label| {
|
||||
json!({
|
||||
"severity": status,
|
||||
"cause": format!("{label} has insufficient disk space"),
|
||||
"measured": measured,
|
||||
"expected": "at least 5.0 GiB",
|
||||
"remedy": "Free disk space or move the worktree to a larger volume.",
|
||||
"fields": [format!("{label} available")],
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let check = check_with_paths(Some(Path::new(".")), Path::new("."), |_| Ok(available));
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(check).unwrap(),
|
||||
json!({
|
||||
"id": "system.disk",
|
||||
"category": "disk",
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
"details": [
|
||||
"warning threshold: 5.0 GiB",
|
||||
"failure threshold: 1.0 GiB",
|
||||
format!("CODEX_HOME available: {measured}"),
|
||||
format!("worktree available: {measured}"),
|
||||
],
|
||||
"issues": issues,
|
||||
"remediation": null,
|
||||
"durationMs": 0,
|
||||
}),
|
||||
"available bytes: {available}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_measurement_errors_produce_complete_warning_diagnostics() {
|
||||
let check = check_with_paths(Some(Path::new(".")), Path::new("."), |_| {
|
||||
Err(io::ErrorKind::PermissionDenied.into())
|
||||
});
|
||||
|
||||
let issues = ["CODEX_HOME", "worktree"]
|
||||
.into_iter()
|
||||
.map(|label| {
|
||||
json!({
|
||||
"severity": "warning",
|
||||
"cause": format!("disk space for {label} could not be checked"),
|
||||
"measured": "PermissionDenied",
|
||||
"expected": "readable filesystem capacity",
|
||||
"remedy": "Check filesystem access and available disk space.",
|
||||
"fields": [format!("{label} available")],
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(check).unwrap(),
|
||||
json!({
|
||||
"id": "system.disk",
|
||||
"category": "disk",
|
||||
"status": "warning",
|
||||
"summary": "disk capacity could not be fully verified",
|
||||
"details": [
|
||||
"warning threshold: 5.0 GiB",
|
||||
"failure threshold: 1.0 GiB",
|
||||
"CODEX_HOME available: unavailable (PermissionDenied)",
|
||||
"worktree available: unavailable (PermissionDenied)",
|
||||
],
|
||||
"issues": issues,
|
||||
"remediation": null,
|
||||
"durationMs": 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonexistent_relative_codex_home_uses_current_directory_capacity() {
|
||||
let measured_paths = RefCell::new(Vec::new());
|
||||
let check = check_with_paths(
|
||||
Some(Path::new("doctor-nonexistent-relative-home/nested")),
|
||||
Path::new("."),
|
||||
|path| {
|
||||
measured_paths.borrow_mut().push(path.to_path_buf());
|
||||
Ok(WARNING_THRESHOLD)
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(check.status, CheckStatus::Ok);
|
||||
assert_eq!(
|
||||
measured_paths.into_inner(),
|
||||
[std::env::current_dir().unwrap(), Path::new(".").into()]
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ const GROUPS: &[OutputGroup] = &[
|
||||
OutputGroup {
|
||||
title: "Environment",
|
||||
keys: &[
|
||||
"system", "runtime", "install", "search", "git", "terminal", "title", "state",
|
||||
"system", "disk", "runtime", "install", "search", "git", "terminal", "title", "state",
|
||||
"threads",
|
||||
],
|
||||
},
|
||||
@@ -1301,9 +1301,27 @@ Background Server
|
||||
|
||||
#[test]
|
||||
fn render_human_report_snapshot_covers_environment_rows() {
|
||||
let mut report = sample_report();
|
||||
report.checks.push(DoctorCheck::new(
|
||||
"system.disk",
|
||||
"disk",
|
||||
CheckStatus::Ok,
|
||||
"sufficient free disk space (42.0 GiB)",
|
||||
));
|
||||
report.checks.push(
|
||||
DoctorCheck::new(
|
||||
"git.worktree.dev_drive",
|
||||
"git",
|
||||
CheckStatus::Warning,
|
||||
"this worktree is not on a Windows Dev Drive",
|
||||
)
|
||||
.remediation(
|
||||
"create a trusted Windows Dev Drive: https://learn.microsoft.com/en-us/windows/dev-drive/",
|
||||
),
|
||||
);
|
||||
insta::assert_snapshot!(
|
||||
"doctor_human_report_environment_rows",
|
||||
render_human_report(&sample_report(), detailed_no_color_unicode_options())
|
||||
render_human_report(&report, detailed_no_color_unicode_options())
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+5
-2
@@ -1,12 +1,13 @@
|
||||
---
|
||||
source: cli/src/doctor/output.rs
|
||||
expression: "render_human_report(&sample_report(), detailed_no_color_unicode_options())"
|
||||
expression: "render_human_report(&report, detailed_no_color_unicode_options())"
|
||||
---
|
||||
Codex Doctor v0.0.0
|
||||
|
||||
Notes
|
||||
⚠ terminal narrow terminal
|
||||
✗ auth token expired - Run `codex login`.
|
||||
⚠ git this worktree is not on a Windows Dev Drive - create a trusted Windows Dev Drive: https://learn.microsoft.com/en-us/windows/dev-drive/
|
||||
─────────────────────────────────────────────────────────────
|
||||
|
||||
Environment
|
||||
@@ -19,6 +20,7 @@ Environment
|
||||
GIT_PAGER delta
|
||||
GH_PAGER less
|
||||
LESS -FRX
|
||||
✓ disk sufficient free disk space (42.0 GiB)
|
||||
✓ runtime running local build on darwin-arm64
|
||||
✓ install consistent
|
||||
managed by npm: no · bun: no · pnpm: no · package root —
|
||||
@@ -27,6 +29,7 @@ Environment
|
||||
selected git /usr/bin/git
|
||||
version git version 2.54.0
|
||||
repo detected true
|
||||
⚠ git this worktree is not on a Windows Dev Drive — create a trusted Windows Dev Drive: https://learn.microsoft.com/en-us/windows/dev-drive/
|
||||
⚠ terminal narrow terminal
|
||||
✓ title default · project codex
|
||||
title source default
|
||||
@@ -50,7 +53,7 @@ Background Server
|
||||
✓ app-server background server is not running
|
||||
|
||||
─────────────────────────────────────────────────────────────
|
||||
12 ok · 2 notes · 1 warn · 1 fail failed
|
||||
13 ok · 3 notes · 2 warn · 1 fail failed
|
||||
|
||||
--summary compact output --all expand truncated lists
|
||||
--json redacted report
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::ffi::OsString;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::ptr;
|
||||
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use os_info::Version;
|
||||
use windows_sys::Win32::Foundation::ERROR_INVALID_PARAMETER;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_READ_ATTRIBUTES;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ;
|
||||
use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE;
|
||||
use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW;
|
||||
use windows_sys::Win32::Storage::FileSystem::GetVolumePathNameW;
|
||||
use windows_sys::Win32::System::IO::DeviceIoControl;
|
||||
use windows_sys::Win32::System::WindowsProgramming::DRIVE_FIXED;
|
||||
use windows_sys::Win32::System::WindowsProgramming::DRIVE_NO_ROOT_DIR;
|
||||
use windows_sys::Win32::System::WindowsProgramming::DRIVE_UNKNOWN;
|
||||
|
||||
use super::CheckStatus;
|
||||
use super::DoctorCheck;
|
||||
|
||||
const ID: &str = "git.worktree.dev_drive";
|
||||
const DEV_VOLUME: u32 = 0x0000_2000;
|
||||
const TRUSTED_VOLUME: u32 = 0x0000_4000;
|
||||
const QUERY_PERSISTENT_VOLUME_STATE: u32 = 0x0009_023c;
|
||||
|
||||
#[repr(C)]
|
||||
struct PersistentVolumeState {
|
||||
flags: u32,
|
||||
mask: u32,
|
||||
version: u32,
|
||||
reserved: u32,
|
||||
}
|
||||
|
||||
pub(super) fn check(cwd: &Path) -> DoctorCheck {
|
||||
let Some(worktree) = get_git_repo_root(cwd) else {
|
||||
return DoctorCheck::new(ID, "git", CheckStatus::Ok, "no Git worktree is active");
|
||||
};
|
||||
if matches!(
|
||||
os_info::get().version(),
|
||||
Version::Semantic(_, _, build) if *build < 22_621
|
||||
) {
|
||||
return DoctorCheck::new(
|
||||
ID,
|
||||
"git",
|
||||
CheckStatus::Ok,
|
||||
"Windows Dev Drives are unavailable on this Windows version",
|
||||
);
|
||||
}
|
||||
|
||||
match volume_flags(&worktree) {
|
||||
Ok(flags) if flags & DEV_VOLUME == 0 => DoctorCheck::new(
|
||||
ID,
|
||||
"git",
|
||||
CheckStatus::Warning,
|
||||
"this worktree is not on a Windows Dev Drive; moving it to a trusted Dev Drive can significantly improve repository and filesystem performance",
|
||||
)
|
||||
.remediation(
|
||||
"create a trusted Windows Dev Drive for source repositories: https://learn.microsoft.com/en-us/windows/dev-drive/",
|
||||
),
|
||||
Ok(flags) if flags & TRUSTED_VOLUME == 0 => DoctorCheck::new(
|
||||
ID,
|
||||
"git",
|
||||
CheckStatus::Warning,
|
||||
"the active Git worktree is on an untrusted Windows Dev Drive",
|
||||
)
|
||||
.remediation(
|
||||
"ask your administrator to trust the Windows Dev Drive: https://learn.microsoft.com/en-us/windows/dev-drive/#how-do-i-designate-a-dev-drive-as-trusted",
|
||||
),
|
||||
Ok(_) => DoctorCheck::new(
|
||||
ID,
|
||||
"git",
|
||||
CheckStatus::Ok,
|
||||
"the active Git worktree is on a trusted Windows Dev Drive",
|
||||
),
|
||||
Err(error) => DoctorCheck::new(
|
||||
ID,
|
||||
"git",
|
||||
CheckStatus::Warning,
|
||||
"the active Git worktree's Windows Dev Drive state could not be inspected",
|
||||
)
|
||||
.detail(format!("filesystem error: {:?}", error.kind()))
|
||||
.remediation("check access to the Git worktree and its Windows storage volume"),
|
||||
}
|
||||
}
|
||||
|
||||
fn volume_flags(path: &Path) -> io::Result<u32> {
|
||||
let path = path
|
||||
.as_os_str()
|
||||
.encode_wide()
|
||||
.chain(std::iter::once(0))
|
||||
.collect::<Vec<_>>();
|
||||
let mut volume = vec![0_u16; path.len().max(261)];
|
||||
if unsafe { GetVolumePathNameW(path.as_ptr(), volume.as_mut_ptr(), volume.len() as u32) } == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
match unsafe { GetDriveTypeW(volume.as_ptr()) } {
|
||||
DRIVE_UNKNOWN | DRIVE_NO_ROOT_DIR => {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"the Windows storage volume could not be determined",
|
||||
));
|
||||
}
|
||||
DRIVE_FIXED => {}
|
||||
_ => return Ok(0),
|
||||
}
|
||||
|
||||
let length = volume
|
||||
.iter()
|
||||
.position(|character| *character == 0)
|
||||
.unwrap_or(volume.len());
|
||||
let root = PathBuf::from(OsString::from_wide(&volume[..length]));
|
||||
let handle = OpenOptions::new()
|
||||
.access_mode(FILE_READ_ATTRIBUTES)
|
||||
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE)
|
||||
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
|
||||
.open(root)?;
|
||||
let mut state = PersistentVolumeState {
|
||||
flags: 0,
|
||||
mask: DEV_VOLUME | TRUSTED_VOLUME,
|
||||
version: 1,
|
||||
reserved: 0,
|
||||
};
|
||||
let state_size = std::mem::size_of::<PersistentVolumeState>() as u32;
|
||||
let state_ptr = ptr::from_mut(&mut state);
|
||||
let mut bytes_returned = 0;
|
||||
if unsafe {
|
||||
DeviceIoControl(
|
||||
handle.as_raw_handle() as isize,
|
||||
QUERY_PERSISTENT_VOLUME_STATE,
|
||||
state_ptr.cast_const().cast(),
|
||||
state_size,
|
||||
state_ptr.cast(),
|
||||
state_size,
|
||||
&mut bytes_returned,
|
||||
ptr::null_mut(),
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
let error = io::Error::last_os_error();
|
||||
return if error.raw_os_error() == Some(ERROR_INVALID_PARAMETER as i32) {
|
||||
Ok(0)
|
||||
} else {
|
||||
Err(error)
|
||||
};
|
||||
}
|
||||
|
||||
Ok(state.flags)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "windows_dev_drive_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::DEV_VOLUME;
|
||||
use super::volume_flags;
|
||||
|
||||
#[test]
|
||||
fn the_windows_system_volume_is_not_a_dev_drive() {
|
||||
let system_root = std::env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.expect("Windows exposes its system directory");
|
||||
let flags = volume_flags(&system_root).expect("the system volume can be inspected");
|
||||
|
||||
assert_eq!(flags & DEV_VOLUME, 0);
|
||||
}
|
||||
Reference in New Issue
Block a user