From 6efcdad4c3c167741ac3791766152c15b03e3653 Mon Sep 17 00:00:00 2001 From: chess Date: Sat, 15 Aug 2026 18:42:16 +0000 Subject: [PATCH] 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 --- .../feedback_doctor_report.rs | 49 ++++- .../request_processors/feedback_processor.rs | 71 ++++++- codex-rs/cli/src/doctor.rs | 26 ++- codex-rs/cli/src/doctor/disk.rs | 178 ++++++++++++++++++ codex-rs/cli/src/doctor/disk_tests.rs | 160 ++++++++++++++++ codex-rs/cli/src/doctor/output.rs | 22 ++- ..._doctor_human_report_environment_rows.snap | 7 +- codex-rs/cli/src/doctor/windows_dev_drive.rs | 160 ++++++++++++++++ .../cli/src/doctor/windows_dev_drive_tests.rs | 16 ++ 9 files changed, 669 insertions(+), 20 deletions(-) create mode 100644 codex-rs/cli/src/doctor/disk.rs create mode 100644 codex-rs/cli/src/doctor/disk_tests.rs create mode 100644 codex-rs/cli/src/doctor/windows_dev_drive.rs create mode 100644 codex-rs/cli/src/doctor/windows_dev_drive_tests.rs diff --git a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs index 2c157b556f..9350357e57 100644 --- a/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs +++ b/codex-rs/app-server/src/request_processors/feedback_doctor_report.rs @@ -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, } -/// Runs `codex doctor --json` and returns a best-effort feedback attachment. +/// Runs `codex --cd 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 { +pub(crate) async fn doctor_feedback_report( + config: &Config, + workspace: &Path, +) -> Option { let executable = config .codex_self_exe .clone() .or_else(|| std::env::current_exe().ok())?; - let mut command = Command::new(&executable); - command.arg("doctor").arg("--json"); + 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 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 { 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::>(), + [ + "--cd".as_ref(), + workspace.as_os_str(), + "doctor".as_ref(), + "--json".as_ref() + ] + ); + assert_eq!( + command.get_envs().collect::>(), + [("CODEX_HOME".as_ref(), Some(codex_home.path().as_os_str()))] + ); + } #[test] fn doctor_report_tags_summarize_status_counts() { diff --git a/codex-rs/app-server/src/request_processors/feedback_processor.rs b/codex-rs/app-server/src/request_processors/feedback_processor.rs index 09c4a8ba3b..ce9222419e 100644 --- a/codex-rs/app-server/src/request_processors/feedback_processor.rs +++ b/codex-rs/app-server/src/request_processors/feedback_processor.rs @@ -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, + 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([ diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 712d63984c..aaf48fee38 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -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", diff --git a/codex-rs/cli/src/doctor/disk.rs b/codex-rs/cli/src/doctor/disk.rs new file mode 100644 index 0000000000..41da4d7f32 --- /dev/null +++ b/codex-rs/cli/src/doctor/disk.rs @@ -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, +) -> 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 { + 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::::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 { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + + let mut path = path.as_os_str().encode_wide().collect::>(); + 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 { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "filesystem capacity is unavailable on this platform", + )) +} + +#[cfg(test)] +#[path = "disk_tests.rs"] +mod tests; diff --git a/codex-rs/cli/src/doctor/disk_tests.rs b/codex-rs/cli/src/doctor/disk_tests.rs new file mode 100644 index 0000000000..fa0a9e2da0 --- /dev/null +++ b/codex-rs/cli/src/doctor/disk_tests.rs @@ -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::>(); + + 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::>(); + + 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()] + ); +} diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index 4f0380a583..af71870cf3 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -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()) ); } diff --git a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap index 2944c6c659..7235b51c3e 100644 --- a/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap +++ b/codex-rs/cli/src/doctor/snapshots/codex__doctor__output__tests__doctor_human_report_environment_rows.snap @@ -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 diff --git a/codex-rs/cli/src/doctor/windows_dev_drive.rs b/codex-rs/cli/src/doctor/windows_dev_drive.rs new file mode 100644 index 0000000000..af0a90a7ab --- /dev/null +++ b/codex-rs/cli/src/doctor/windows_dev_drive.rs @@ -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 { + let path = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + 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::() 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; diff --git a/codex-rs/cli/src/doctor/windows_dev_drive_tests.rs b/codex-rs/cli/src/doctor/windows_dev_drive_tests.rs new file mode 100644 index 0000000000..a467df8e17 --- /dev/null +++ b/codex-rs/cli/src/doctor/windows_dev_drive_tests.rs @@ -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); +}