Add desktop app diagnostics to codex doctor (#39060)

## What changed

- Detect installed Codex desktop apps on macOS and Windows and report their version, running state, and redacted log location.
- Inspect bounded log data for the active desktop session and report the latest local app-server handshake outcome without exposing other log fields.
- Add a dedicated Desktop App section to human-readable doctor reports.

## Testing

- Cover local versus remote handshakes, failed handshakes with sensitive fields, bounded log reads, and stopped desktop sessions.

GitOrigin-RevId: ecfb4bd7af3589263624771ec9413b51953280eb
This commit is contained in:
chess
2026-08-17 19:43:09 +00:00
committed by copyberry
parent 14973840e0
commit 80e925c28b
7 changed files with 711 additions and 1 deletions
+2
View File
@@ -104,7 +104,9 @@ which = { workspace = true }
codex_windows_sandbox = { package = "codex-windows-sandbox", path = "../windows-sandbox-rs" }
windows-sys = { version = "0.52", features = [
"Win32_Foundation",
"Win32_Storage_Packaging_Appx",
"Win32_System_Console",
"Win32_System_Threading",
] }
[dev-dependencies]
+11
View File
@@ -72,6 +72,7 @@ use serde::Serialize;
use supports_color::Stream;
mod background;
mod desktop;
mod disk;
mod git;
mod network;
@@ -86,6 +87,10 @@ mod updates;
#[cfg(target_os = "windows")]
mod windows_dev_drive;
#[cfg(test)]
#[path = "doctor/desktop_tests.rs"]
mod desktop_tests;
use background::background_server_check;
use git::git_check;
use output::HumanOutputOptions;
@@ -536,6 +541,12 @@ async fn build_report(
}
}
progress.begin("desktop");
if let Some(desktop) = desktop::collect().await {
progress.finish("desktop", overall_status(&desktop.checks));
checks.extend(desktop.checks);
}
progress.settle();
let overall_status = overall_status(&checks);
+299
View File
@@ -0,0 +1,299 @@
use std::cmp::Reverse;
use std::env;
use std::fs;
use std::io;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
use std::time::SystemTime;
use chrono::DateTime;
use chrono::FixedOffset;
use chrono::Utc;
use super::CheckStatus;
use super::DoctorCheck;
mod platform;
const MAX_DIRECTORY_ENTRIES: usize = 256;
const MAX_LOG_FILES: usize = 64;
const MAX_SESSION_LOG_FILES: usize = 8;
const MAX_LOG_SEGMENT_BYTES: u64 = 10 * 1024 * 1024;
const HANDSHAKE_CHECK_ID: &str = "desktop.app_server.handshake";
pub(super) struct DesktopDiagnostics {
pub(super) checks: Vec<DoctorCheck>,
}
struct DesktopLog {
path: PathBuf,
session_id: String,
process_id: u32,
modified: SystemTime,
}
struct HandshakeEvent {
timestamp: DateTime<FixedOffset>,
outcome: HandshakeOutcome,
websocket: bool,
}
enum HandshakeOutcome {
Success,
Failure,
}
enum DesktopLogEvent {
ConnectionContext { local_websocket: bool },
Handshake(HandshakeEvent),
}
pub(super) async fn collect() -> Option<DesktopDiagnostics> {
let application = match platform::installed_app().await {
Ok(Some(application)) => application,
Ok(None) => return None,
Err(_) => {
return Some(DesktopDiagnostics {
checks: vec![unavailable(
"desktop.app.version",
"the desktop application installation could not be inspected",
)],
});
}
};
let directory = desktop_log_root(application.identity);
let (running, handshake) = match &directory {
Some(root) => inspect_desktop_session(
&root.join(Utc::now().format("%Y/%m/%d").to_string()),
|pid| platform::application_running(&application, pid),
),
None => (false, stopped_desktop_check()),
};
let log_directory = directory
.as_deref()
.map_or_else(|| "unavailable".to_string(), redacted_path);
let application_check = platform::desktop_check(
"desktop.app.version",
CheckStatus::Ok,
"the desktop application is installed",
)
.detail(format!("version: {}", application.version))
.detail(format!("running: {running}"))
.detail(format!("log directory: {log_directory}"));
Some(DesktopDiagnostics {
checks: vec![application_check, handshake],
})
}
pub(super) fn inspect_desktop_session(
directory: &Path,
application_running: impl Fn(u32) -> bool,
) -> (bool, DoctorCheck) {
let logs = discover_desktop_logs(directory);
let Some(session) = logs.iter().find(|log| application_running(log.process_id)) else {
return (false, stopped_desktop_check());
};
let check = latest_session_handshake(&logs, &session.session_id).map_or_else(
|| {
platform::desktop_check(
HANDSHAKE_CHECK_ID,
CheckStatus::Ok,
"no desktop app-server handshake was recorded",
)
},
|event| event.outcome.into_check(),
);
(true, check)
}
fn desktop_log_root(identity: &str) -> Option<PathBuf> {
let root = match env::consts::OS {
"macos" => PathBuf::from(env::var_os("HOME")?)
.join("Library/Logs")
.join(identity),
"windows" => {
let local = env::var_os("LOCALAPPDATA").map(PathBuf::from).or_else(|| {
env::var_os("USERPROFILE").map(|home| PathBuf::from(home).join("AppData/Local"))
})?;
local.join("Codex/Logs")
}
_ => return None,
};
Some(root)
}
fn discover_desktop_logs(directory: &Path) -> Vec<DesktopLog> {
let Ok(entries) = fs::read_dir(directory) else {
return Vec::new();
};
let mut logs = entries
.take(MAX_DIRECTORY_ENTRIES)
.filter_map(Result::ok)
.filter_map(DesktopLog::from_entry)
.collect::<Vec<_>>();
logs.sort_unstable_by_key(|log| Reverse(log.modified));
logs.truncate(MAX_LOG_FILES);
logs
}
fn latest_session_handshake(logs: &[DesktopLog], session_id: &str) -> Option<HandshakeEvent> {
let mut latest: Option<HandshakeEvent> = None;
for log in logs
.iter()
.filter(|log| log.session_id == session_id)
.take(MAX_SESSION_LOG_FILES)
{
let Ok(contents) = read_log_segment(&log.path) else {
continue;
};
let mut local_websocket = false;
for line in String::from_utf8_lossy(&contents).lines() {
match DesktopLogEvent::parse(line) {
Some(DesktopLogEvent::ConnectionContext {
local_websocket: local,
}) => {
local_websocket = local;
}
Some(DesktopLogEvent::Handshake(event))
if (!event.websocket || local_websocket)
&& latest
.as_ref()
.is_none_or(|current| event.timestamp >= current.timestamp) =>
{
latest = Some(event);
}
_ => {}
}
}
}
latest
}
impl DesktopLog {
fn from_entry(entry: fs::DirEntry) -> Option<Self> {
let metadata = entry.metadata().ok()?;
if !metadata.is_file() {
return None;
}
let name = entry.file_name();
let name = name
.to_str()?
.strip_prefix("codex-desktop-")?
.strip_suffix(".log")?;
let (session_and_pid, _) = name.split_once("-t")?;
let (session_id, process_id) = session_and_pid.rsplit_once('-')?;
Some(Self {
path: entry.path(),
session_id: session_id.to_string(),
process_id: process_id.parse().ok().filter(|pid| *pid > 0)?,
modified: metadata.modified().ok()?,
})
}
}
fn read_log_segment(path: &Path) -> io::Result<Vec<u8>> {
let mut contents = Vec::new();
fs::File::open(path)?
.take(MAX_LOG_SEGMENT_BYTES)
.read_to_end(&mut contents)?;
Ok(contents)
}
impl DesktopLogEvent {
fn parse(line: &str) -> Option<Self> {
let (timestamp, line) = line.split_once(' ')?;
let timestamp = DateTime::parse_from_rfc3339(timestamp).ok()?;
let (_, message) = line.split_once(" [AppServerConnection] ")?;
if let Some(fields) = message
.strip_prefix("Starting app-server connection ")
.or_else(|| message.strip_prefix("app_server_connection.state_changed "))
{
return Some(Self::ConnectionContext {
local_websocket: log_field(fields, "hostId") == Some("local")
&& log_field(fields, "transport") == Some("websocket"),
});
}
let fields = message.strip_prefix("initialize_handshake_result ")?;
let outcome = match log_field(fields, "outcome")? {
"success" => HandshakeOutcome::Success,
"failure" => HandshakeOutcome::Failure,
_ => return None,
};
let websocket = match log_field(fields, "transportKind")? {
"stdio" => false,
"websocket" => true,
_ => return None,
};
Some(Self::Handshake(HandshakeEvent {
timestamp,
outcome,
websocket,
}))
}
}
impl HandshakeOutcome {
fn into_check(self) -> DoctorCheck {
let (status, summary) = match self {
Self::Success => (
CheckStatus::Ok,
"the desktop app-server initialized successfully",
),
Self::Failure => (
CheckStatus::Fail,
"the desktop app-server failed to initialize",
),
};
platform::desktop_check(HANDSHAKE_CHECK_ID, status, summary)
}
}
fn log_field<'a>(fields: &'a str, name: &str) -> Option<&'a str> {
fields.split_whitespace().rev().find_map(|field| {
let (key, value) = field.split_once('=')?;
(key == name).then_some(value)
})
}
fn stopped_desktop_check() -> DoctorCheck {
platform::desktop_check(
HANDSHAKE_CHECK_ID,
CheckStatus::Ok,
"the desktop application is not running",
)
}
fn unavailable(id: &'static str, summary: &'static str) -> DoctorCheck {
platform::desktop_check(id, CheckStatus::Warning, summary)
.remediation("restore desktop diagnostic access and rerun codex doctor")
}
fn redacted_path(path: &Path) -> String {
for variable in ["HOME", "USERPROFILE"] {
if let Some(home) = env::var_os(variable)
&& let Ok(relative) = path.strip_prefix(Path::new(&home))
{
return Path::new("$HOME").join(relative).display().to_string();
}
}
path.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy()
.into_owned()
}
+273
View File
@@ -0,0 +1,273 @@
#[cfg(target_os = "macos")]
use std::ffi::CStr;
#[cfg(target_os = "macos")]
use std::ffi::OsStr;
#[cfg(target_os = "macos")]
use std::fs;
#[cfg(target_os = "macos")]
use std::os::unix::ffi::OsStrExt;
#[cfg(target_os = "windows")]
use std::os::windows::io::AsRawHandle;
#[cfg(target_os = "windows")]
use std::os::windows::io::FromRawHandle;
#[cfg(target_os = "windows")]
use std::os::windows::io::OwnedHandle;
#[cfg(target_os = "macos")]
use std::path::PathBuf;
#[cfg(target_os = "macos")]
use std::process::Stdio;
#[cfg(target_os = "macos")]
use std::time::Duration;
#[cfg(target_os = "macos")]
use serde_json::Value;
#[cfg(target_os = "macos")]
use tokio::process::Command;
#[cfg(target_os = "macos")]
use tokio::time::timeout;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Storage::Packaging::Appx::FindPackagesByPackageFamily;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Storage::Packaging::Appx::GetPackageFamilyName;
#[cfg(target_os = "windows")]
use windows_sys::Win32::Storage::Packaging::Appx::PACKAGE_FILTER_HEAD;
#[cfg(target_os = "windows")]
use windows_sys::Win32::System::Threading::OpenProcess;
#[cfg(target_os = "windows")]
use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION;
use super::super::CheckStatus;
use super::super::DoctorCheck;
pub(super) struct InstalledApp {
pub(super) identity: &'static str,
pub(super) version: String,
#[cfg(target_os = "windows")]
package_family: &'static str,
#[cfg(target_os = "macos")]
pub(super) bundle: PathBuf,
#[cfg(target_os = "macos")]
#[allow(dead_code, reason = "used by downstream desktop diagnostics")]
pub(super) build: u64,
}
#[derive(Clone, Copy, Debug)]
pub(super) struct DiscoveryError;
pub(super) async fn installed_app() -> Result<Option<InstalledApp>, DiscoveryError> {
#[cfg(target_os = "windows")]
{
installed_windows_app()
}
#[cfg(target_os = "macos")]
{
installed_macos_app().await
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
{
Ok(None)
}
}
#[cfg(target_os = "macos")]
pub(super) fn application_running(application: &InstalledApp, candidate_pid: u32) -> bool {
process_executable_path(candidate_pid)
.is_some_and(|path| path.starts_with(application.bundle.join("Contents/MacOS")))
}
#[cfg(target_os = "macos")]
fn process_executable_path(pid: u32) -> Option<PathBuf> {
let pid = i32::try_from(pid).ok()?;
let mut buffer = [0_u8; libc::PROC_PIDPATHINFO_MAXSIZE as usize];
let result =
unsafe { libc::proc_pidpath(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) };
if result <= 0 {
return None;
}
let executable = CStr::from_bytes_until_nul(&buffer).ok()?;
Some(PathBuf::from(OsStr::from_bytes(executable.to_bytes())))
}
#[cfg(target_os = "windows")]
pub(super) fn application_running(application: &InstalledApp, candidate_pid: u32) -> bool {
process_package_family(candidate_pid).as_deref() == Some(application.package_family)
}
#[cfg(target_os = "windows")]
fn process_package_family(pid: u32) -> Option<String> {
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
if handle == 0 {
return None;
}
let process = unsafe { OwnedHandle::from_raw_handle(handle as _) };
let mut family_length = 0;
let result = unsafe {
GetPackageFamilyName(
process.as_raw_handle() as _,
&mut family_length,
std::ptr::null_mut(),
)
};
if result != ERROR_INSUFFICIENT_BUFFER || family_length == 0 {
return None;
}
let mut family = vec![0_u16; family_length as usize];
let result = unsafe {
GetPackageFamilyName(
process.as_raw_handle() as _,
&mut family_length,
family.as_mut_ptr(),
)
};
if result != ERROR_SUCCESS {
return None;
}
let end = family.iter().position(|value| *value == 0)?;
String::from_utf16(&family[..end]).ok()
}
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
pub(super) fn application_running(_application: &InstalledApp, _candidate_pid: u32) -> bool {
false
}
pub(super) fn desktop_check(
id: impl Into<String>,
status: CheckStatus,
summary: impl Into<String>,
) -> DoctorCheck {
DoctorCheck::new(id, "desktop", status, summary)
}
#[cfg(target_os = "windows")]
fn installed_windows_app() -> Result<Option<InstalledApp>, DiscoveryError> {
const PACKAGE_FAMILY: &str = "OpenAI.Codex_2p2nqsd0c76g0";
let family = PACKAGE_FAMILY
.encode_utf16()
.chain(std::iter::once(0))
.collect::<Vec<_>>();
let mut count = 0;
let mut length = 0;
let result = unsafe {
FindPackagesByPackageFamily(
family.as_ptr(),
PACKAGE_FILTER_HEAD,
&mut count,
std::ptr::null_mut(),
&mut length,
std::ptr::null_mut(),
std::ptr::null_mut(),
)
};
if result == ERROR_SUCCESS && count == 0 {
return Ok(None);
}
if result != ERROR_INSUFFICIENT_BUFFER {
return Err(DiscoveryError);
}
let mut names = vec![std::ptr::null_mut(); count as usize];
let mut buffer = vec![0_u16; length as usize];
let result = unsafe {
FindPackagesByPackageFamily(
family.as_ptr(),
PACKAGE_FILTER_HEAD,
&mut count,
names.as_mut_ptr(),
&mut length,
buffer.as_mut_ptr(),
std::ptr::null_mut(),
)
};
if result != ERROR_SUCCESS {
return Err(DiscoveryError);
}
if count == 0 {
return Ok(None);
}
let end = buffer
.iter()
.position(|value| *value == 0)
.ok_or(DiscoveryError)?;
let package = String::from_utf16(&buffer[..end]).map_err(|_| DiscoveryError)?;
let version = package
.strip_prefix("OpenAI.Codex_")
.and_then(|name| name.split('_').next())
.filter(|version| {
version
.split('.')
.all(|part| !part.is_empty() && part.bytes().all(|digit| digit.is_ascii_digit()))
})
.ok_or(DiscoveryError)?;
Ok(Some(InstalledApp {
identity: "OpenAI.Codex",
version: version.to_string(),
package_family: PACKAGE_FAMILY,
}))
}
#[cfg(target_os = "macos")]
async fn installed_macos_app() -> Result<Option<InstalledApp>, DiscoveryError> {
let applications = std::iter::once(PathBuf::from("/Applications"))
.chain(std::env::var_os("HOME").map(|home| PathBuf::from(home).join("Applications")))
.flat_map(|directory| {
["ChatGPT.app", "Codex.app"]
.into_iter()
.map(move |application| directory.join(application))
});
for bundle in applications {
match fs::metadata(&bundle) {
Ok(metadata) if metadata.is_dir() => {}
Ok(_) => continue,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(_) => return Err(DiscoveryError),
}
let mut command = Command::new("/usr/bin/plutil");
command
.args(["-convert", "json", "-o", "-"])
.arg(bundle.join("Contents/Info.plist"))
.stdin(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let output = timeout(Duration::from_secs(5), command.output())
.await
.map_err(|_| DiscoveryError)?
.map_err(|_| DiscoveryError)?;
if !output.status.success() || output.stdout.len() > 64 * 1024 {
return Err(DiscoveryError);
}
let metadata: Value = serde_json::from_slice(&output.stdout).map_err(|_| DiscoveryError)?;
if metadata.get("CFBundleIdentifier").and_then(Value::as_str) != Some("com.openai.codex") {
continue;
}
let version = metadata
.get("CFBundleShortVersionString")
.or_else(|| metadata.get("CFBundleVersion"))
.and_then(Value::as_str)
.ok_or(DiscoveryError)?;
let build = metadata
.get("CFBundleVersion")
.and_then(Value::as_str)
.and_then(|value| value.parse().ok())
.ok_or(DiscoveryError)?;
return Ok(Some(InstalledApp {
identity: "com.openai.codex",
version: version.to_string(),
bundle,
build,
}));
}
Ok(None)
}
+97
View File
@@ -0,0 +1,97 @@
use std::fs;
use std::path::Path;
use pretty_assertions::assert_eq;
use super::CheckStatus;
use super::desktop::inspect_desktop_session;
const CURRENT_SESSION: &str = "12345678-1234-1234-1234-123456789abc";
const CURRENT_PROCESS: u32 = 12345;
#[test]
fn current_desktop_session_reports_the_latest_local_handshake() {
let directory = tempfile::tempdir().expect("temporary directory should be created");
write_log(
directory.path(),
concat!(
"2026-08-07T12:00:00.000Z error [AppServerConnection] initialize_handshake_result outcome=failure transportKind=stdio\n",
"2026-08-07T12:01:00.000Z info [AppServerConnection] Starting app-server connection hostId=local transport=websocket\n",
"2026-08-07T12:02:00.000Z info [AppServerConnection] initialize_handshake_result outcome=success transportKind=websocket\n",
"2026-08-07T12:03:00.000Z info [AppServerConnection] app_server_connection.state_changed hostId=remote transport=websocket\n",
"2026-08-07T12:04:00.000Z error [AppServerConnection] initialize_handshake_result outcome=failure transportKind=websocket\n",
),
);
let (running, check) = inspect_desktop_session(directory.path(), |pid| pid == CURRENT_PROCESS);
assert!(running);
assert_eq!(check.id, "desktop.app_server.handshake");
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(
check.summary,
"the desktop app-server initialized successfully"
);
}
#[test]
fn failed_handshake_does_not_expose_sensitive_log_fields() {
let directory = tempfile::tempdir().expect("temporary directory should be created");
write_log(
directory.path(),
"2026-08-07T12:00:00.000Z error [AppServerConnection] initialize_handshake_result errorMessage=\"desktop-secret outcome=success\" outcome=failure transportKind=stdio\n",
);
let (running, check) = inspect_desktop_session(directory.path(), |_| true);
assert!(running);
assert_eq!(check.status, CheckStatus::Fail);
assert_eq!(check.summary, "the desktop app-server failed to initialize");
assert!(
!serde_json::to_string(&check)
.unwrap()
.contains("desktop-secret")
);
}
#[test]
fn handshake_failure_in_the_middle_of_a_log_segment_is_detected() {
let directory = tempfile::tempdir().expect("temporary directory should be created");
let padding = "x".repeat(96 * 1024);
write_log(
directory.path(),
&format!(
"{padding}\n2026-08-07T12:00:00.000Z error [AppServerConnection] initialize_handshake_result outcome=failure transportKind=stdio\n{padding}"
),
);
let (running, check) = inspect_desktop_session(directory.path(), |_| true);
assert!(running);
assert_eq!(check.status, CheckStatus::Fail);
}
#[test]
fn stopped_desktop_does_not_report_a_failure() {
let directory = tempfile::tempdir().expect("temporary directory should be created");
write_log(
directory.path(),
"2026-08-07T12:00:00.000Z error [AppServerConnection] initialize_handshake_result outcome=failure transportKind=stdio\n",
);
let (running, check) = inspect_desktop_session(directory.path(), |_| false);
assert!(!running);
assert_eq!(check.status, CheckStatus::Ok);
assert_eq!(check.summary, "the desktop application is not running");
}
fn write_log(directory: &Path, contents: &str) {
fs::write(
directory.join(format!(
"codex-desktop-{CURRENT_SESSION}-{CURRENT_PROCESS}-t0-i0-120000-0.log"
)),
contents,
)
.expect("desktop log fixture should be created");
}
+21
View File
@@ -34,6 +34,10 @@ const GROUPS: &[OutputGroup] = &[
title: "Configuration",
keys: &["config", "auth", "mcp", "sandbox"],
},
OutputGroup {
title: "Desktop App",
keys: &["desktop"],
},
OutputGroup {
title: "Updates",
keys: &["updates"],
@@ -1329,6 +1333,23 @@ Background Server
.expect("endpoint security check should include exclusion targets");
*targets = "exclusion targets: verified Codex app and required helpers".into();
report.checks.push(security);
report.checks.extend([
DoctorCheck::new(
"desktop.app.version",
"desktop",
CheckStatus::Ok,
"the desktop application is installed",
)
.detail("version: 1.2.3")
.detail("running: true")
.detail("log directory: $HOME/Library/Logs/com.openai.codex"),
DoctorCheck::new(
"desktop.app_server.handshake",
"desktop",
CheckStatus::Ok,
"the desktop app-server initialized successfully",
),
]);
insta::assert_snapshot!(
"doctor_human_report_environment_rows",
render_human_report(&report, detailed_no_color_unicode_options())
@@ -47,6 +47,13 @@ Configuration
✗ auth token expired — Run `codex login`.
OPENAI_API_KEY present
Desktop App
✓ desktop the desktop application is installed
version 1.2.3
running true
log directory $HOME/Library/Logs/com.openai.codex
✓ desktop the desktop app-server initialized successfully
Updates
✓ updates update configuration is locally consistent
@@ -59,7 +66,7 @@ Background Server
✓ app-server background server is not running
─────────────────────────────────────────────────────────────
13 ok · 4 notes · 3 warn · 1 fail failed
15 ok · 4 notes · 3 warn · 1 fail failed
--summary compact output --all expand truncated lists
--json redacted report