mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
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:
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
+8
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user