From d65d31593926f26bc6dd030240bc009be87ef75d Mon Sep 17 00:00:00 2001 From: chess Date: Mon, 17 Aug 2026 20:11:23 +0000 Subject: [PATCH] Add desktop update diagnostics to `codex doctor` (#39074) ## What changed - Probe the installed desktop app's update endpoint on macOS and Windows and report update-CDN reachability alongside the existing network diagnostics. - Report newer Windows Store builds and macOS updates that Sparkle has staged for installation in the updates check and human-readable notes. - Validate Windows update manifests against the production app identity, and use the persisted production appcast configuration when selecting the macOS feed. ## Testing - Cover Windows version comparison and manifest identity validation. - Cover macOS appcast selection and staged-bundle discovery. - Verify the human-readable desktop update note. GitOrigin-RevId: 1af5aa750144346b4b31f2b27a20371daf40d3c0 --- codex-rs/cli/src/doctor.rs | 5 + codex-rs/cli/src/doctor/desktop.rs | 8 +- codex-rs/cli/src/doctor/desktop/platform.rs | 92 ++-- codex-rs/cli/src/doctor/network.rs | 36 +- codex-rs/cli/src/doctor/output.rs | 33 ++ ..._doctor_human_report_environment_rows.snap | 5 +- codex-rs/cli/src/doctor/updates.rs | 410 ++++++++++++++++++ .../cli/tests/doctor_enterprise_network.rs | 9 +- 8 files changed, 535 insertions(+), 63 deletions(-) diff --git a/codex-rs/cli/src/doctor.rs b/codex-rs/cli/src/doctor.rs index 2aaf70a3e3..b1823d8a60 100644 --- a/codex-rs/cli/src/doctor.rs +++ b/codex-rs/cli/src/doctor.rs @@ -543,6 +543,11 @@ async fn build_report( progress.begin("desktop"); if let Some(desktop) = desktop::collect().await { + #[cfg(any(target_os = "macos", target_os = "windows"))] + if let Some(application) = desktop.application.as_ref() { + updates::append_desktop_update(&mut checks, config_result.as_ref().ok(), application) + .await; + } progress.finish("desktop", overall_status(&desktop.checks)); checks.extend(desktop.checks); } diff --git a/codex-rs/cli/src/doctor/desktop.rs b/codex-rs/cli/src/doctor/desktop.rs index acc788fa5b..96b6a0607c 100644 --- a/codex-rs/cli/src/doctor/desktop.rs +++ b/codex-rs/cli/src/doctor/desktop.rs @@ -16,7 +16,7 @@ use super::DoctorCheck; #[cfg(target_os = "macos")] mod macos_security; -mod platform; +pub(super) mod platform; #[cfg(any(target_os = "windows", test))] mod windows_security; @@ -28,6 +28,8 @@ const HANDSHAKE_CHECK_ID: &str = "desktop.app_server.handshake"; pub(super) struct DesktopDiagnostics { pub(super) checks: Vec, + #[cfg(any(target_os = "macos", target_os = "windows"))] + pub(super) application: Option, } struct DesktopLog { @@ -67,6 +69,8 @@ pub(super) async fn collect() -> Option { #[cfg(target_os = "windows")] windows_security::collect().await, ], + #[cfg(any(target_os = "macos", target_os = "windows"))] + application: None, }); } }; @@ -100,6 +104,8 @@ pub(super) async fn collect() -> Option { #[cfg(target_os = "windows")] windows_security::collect().await, ], + #[cfg(any(target_os = "macos", target_os = "windows"))] + application: Some(application), }) } diff --git a/codex-rs/cli/src/doctor/desktop/platform.rs b/codex-rs/cli/src/doctor/desktop/platform.rs index b990794ec4..b06685a8d2 100644 --- a/codex-rs/cli/src/doctor/desktop/platform.rs +++ b/codex-rs/cli/src/doctor/desktop/platform.rs @@ -13,6 +13,8 @@ use std::os::windows::io::FromRawHandle; #[cfg(target_os = "windows")] use std::os::windows::io::OwnedHandle; #[cfg(target_os = "macos")] +use std::path::Path; +#[cfg(target_os = "macos")] use std::path::PathBuf; #[cfg(target_os = "macos")] use std::process::Stdio; @@ -43,20 +45,19 @@ 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, +pub(in crate::doctor) struct InstalledApp { + pub(in crate::doctor) identity: &'static str, + pub(in crate::doctor) version: String, #[cfg(target_os = "windows")] package_family: &'static str, #[cfg(target_os = "macos")] - pub(super) bundle: PathBuf, + pub(in crate::doctor) bundle: PathBuf, #[cfg(target_os = "macos")] - #[allow(dead_code, reason = "used by downstream desktop diagnostics")] - pub(super) build: u64, + pub(in crate::doctor) build: u64, } #[derive(Clone, Copy, Debug)] -pub(super) struct DiscoveryError; +pub(in crate::doctor) struct DiscoveryError; pub(super) async fn installed_app() -> Result, DiscoveryError> { #[cfg(target_os = "windows")] @@ -232,42 +233,51 @@ async fn installed_macos_app() -> Result, DiscoveryError> { 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); + if let Some(application) = inspect_macos_bundle(&bundle).await? { + return Ok(Some(application)); } - 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) } + +#[cfg(target_os = "macos")] +pub(in crate::doctor) async fn inspect_macos_bundle( + bundle: &Path, +) -> Result, 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") { + return Ok(None); + } + 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)?; + + Ok(Some(InstalledApp { + identity: "com.openai.codex", + version: version.to_string(), + bundle: bundle.to_path_buf(), + build, + })) +} diff --git a/codex-rs/cli/src/doctor/network.rs b/codex-rs/cli/src/doctor/network.rs index c3e8ec8e25..d6cff63662 100644 --- a/codex-rs/cli/src/doctor/network.rs +++ b/codex-rs/cli/src/doctor/network.rs @@ -138,23 +138,7 @@ pub(super) async fn probe_status( .send() .await } - .map_err(|error| { - match error.failure_class() { - Some(RouteFailureClass::TlsError) => "TLS handshake or certificate validation failed", - Some(RouteFailureClass::ProxyAuthenticationRequired) => "proxy authentication required", - Some(RouteFailureClass::InvalidProxyConfig) => "invalid proxy configuration", - Some(RouteFailureClass::ProxyResolutionUnavailable) => { - "system proxy configuration unavailable" - } - Some(RouteFailureClass::ConnectTimeout) => "request timed out", - Some(RouteFailureClass::UnsupportedProxyScheme) => "unsupported proxy configuration", - Some(RouteFailureClass::ResolverError) => "proxy resolution failed", - None if error.is_timeout() => "request timed out", - None if error.is_connect() => "connect failed", - None => "request failed", - } - .to_string() - })?; + .map_err(request_error)?; let status = response.status().as_u16(); if status == 407 { return Err("proxy authentication required (HTTP 407)".to_string()); @@ -162,6 +146,24 @@ pub(super) async fn probe_status( Ok(status) } +pub(super) fn request_error(error: RouteAwareRequestError) -> String { + match error.failure_class() { + Some(RouteFailureClass::TlsError) => "TLS handshake or certificate validation failed", + Some(RouteFailureClass::ProxyAuthenticationRequired) => "proxy authentication required", + Some(RouteFailureClass::InvalidProxyConfig) => "invalid proxy configuration", + Some(RouteFailureClass::ProxyResolutionUnavailable) => { + "system proxy configuration unavailable" + } + Some(RouteFailureClass::ConnectTimeout) => "request timed out", + Some(RouteFailureClass::UnsupportedProxyScheme) => "unsupported proxy configuration", + Some(RouteFailureClass::ResolverError) => "proxy resolution failed", + None if error.is_timeout() => "request timed out", + None if error.is_connect() => "connect failed", + None => "request failed", + } + .to_string() +} + #[cfg(test)] #[path = "network_tests.rs"] mod tests; diff --git a/codex-rs/cli/src/doctor/output.rs b/codex-rs/cli/src/doctor/output.rs index a70a05dd37..74c40f7f7d 100644 --- a/codex-rs/cli/src/doctor/output.rs +++ b/codex-rs/cli/src/doctor/output.rs @@ -499,6 +499,9 @@ fn notes_for_report(report: &DoctorReport) -> Vec { update_note(check, report) .into_iter() .for_each(|note| notes.push(note)); + desktop_update_note(check) + .into_iter() + .for_each(|note| notes.push(note)); } if let Some(check) = find_check(report, "state") { rollout_note(check) @@ -548,6 +551,21 @@ fn update_note(check: &DoctorCheck, report: &DoctorReport) -> Option }) } +fn desktop_update_note(check: &DoctorCheck) -> Option { + let status = detail::detail_value(check, "desktop update status")?; + let build = detail::detail_value(check, "desktop latest build")?; + let summary = match status.as_str() { + "ready to install" => format!("build {build} available (ready to install)"), + "available" => format!("build {build} available"), + _ => return None, + }; + Some(DoctorNote { + status: DisplayStatus::Update, + name: "desktop".to_string(), + summary, + }) +} + fn rollout_note(check: &DoctorCheck) -> Option { let active = detail::detail_value(check, "active rollout files")?; let (files, bytes) = detail::rollout_files_and_bytes(&active)?; @@ -1350,6 +1368,21 @@ Background Server "the desktop app-server initialized successfully", ), ]); + let update = report + .checks + .iter_mut() + .find(|check| check.category == "updates") + .unwrap(); + for (status, expected) in [ + ("available", "build 123 available"), + ("ready to install", "build 123 available (ready to install)"), + ] { + update.details = vec![ + format!("desktop update status: {status}"), + "desktop latest build: 123".to_string(), + ]; + assert_eq!(desktop_update_note(update).unwrap().summary, expected); + } insta::assert_snapshot!( "doctor_human_report_environment_rows", 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 36548b58bb..ec93326efa 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 @@ -5,6 +5,7 @@ expression: "render_human_report(&report, detailed_no_color_unicode_options())" Codex Doctor v0.0.0 Notes + ↑ desktop build 123 available (ready to install) ⚠ 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/ @@ -56,6 +57,8 @@ Desktop App Updates ✓ updates update configuration is locally consistent + desktop update status ready to install + desktop latest build 123 Connectivity ✓ network network environment readable @@ -66,7 +69,7 @@ Background Server ✓ app-server background server is not running ───────────────────────────────────────────────────────────── -15 ok · 4 notes · 3 warn · 1 fail failed +15 ok · 5 notes · 3 warn · 1 fail failed --summary compact output --all expand truncated lists --json redacted report diff --git a/codex-rs/cli/src/doctor/updates.rs b/codex-rs/cli/src/doctor/updates.rs index b13de06d14..7c2add2308 100644 --- a/codex-rs/cli/src/doctor/updates.rs +++ b/codex-rs/cli/src/doctor/updates.rs @@ -7,23 +7,50 @@ //! an update command. use std::path::Path; +#[cfg(target_os = "macos")] +use std::path::PathBuf; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use std::time::Duration; use codex_core::config::Config; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use codex_http_client::ClientRouteClass; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use codex_http_client::RouteAwareClientPool; use codex_install_context::InstallContext; use codex_install_context::InstallMethod; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use http::Method; use serde::Deserialize; +#[cfg(target_os = "macos")] +use url::Url; use super::CheckStatus; use super::DoctorCheck; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use super::DoctorIssue; use super::NpmRootCheck; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use super::desktop::platform::InstalledApp; use super::doctor_install_context; use super::doctor_managed_by_npm; +#[cfg(any(target_os = "macos", target_os = "windows"))] +use super::network; use super::npm_global_root_check; use super::run_command; const VERSION_FILE_NAME: &str = "version.json"; const GITHUB_LATEST_RELEASE_URL: &str = "https://api.github.com/repos/openai/codex/releases/latest"; const HOMEBREW_CASK_API_URL: &str = "https://formulae.brew.sh/api/cask/codex.json"; +#[cfg(all(target_os = "macos", target_arch = "x86_64"))] +const DESKTOP_UPDATE_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/appcast-x64.xml"; +#[cfg(all(target_os = "macos", not(target_arch = "x86_64")))] +const DESKTOP_UPDATE_URL: &str = "https://persistent.oaistatic.com/codex-app-prod/appcast.xml"; +#[cfg(target_os = "macos")] +const BACKEND_DESKTOP_UPDATE_URL: &str = "https://chatgpt.com/backend-api/wham/app/appcast"; +#[cfg(target_os = "windows")] +const DESKTOP_UPDATE_URL: &str = + "https://persistent.oaistatic.com/codex-app-prod/windows-store-update.json"; /// Builds the update-health row for the current installation. /// @@ -107,6 +134,278 @@ pub(super) fn updates_check(config: &Config) -> DoctorCheck { check } +#[cfg(any(target_os = "macos", target_os = "windows"))] +pub(super) async fn append_desktop_update( + checks: &mut [DoctorCheck], + config: Option<&Config>, + application: &InstalledApp, +) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + #[cfg(target_os = "macos")] + if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) + && let Some(build) = latest_macos_staged_build( + &home + .join("Library/Caches") + .join(application.identity) + .join("org.sparkle-project.Sparkle/Installation"), + application.build, + ) + .await + && let Some(update) = checks.iter_mut().find(|check| check.id == "updates.status") + { + update.details.extend([ + "desktop update status: ready to install".to_string(), + format!("desktop latest build: {build}"), + format!("desktop application: {}", application.identity), + ]); + } + + let Some(config) = config else { + return; + }; + let Some(reachability_index) = checks + .iter() + .position(|check| check.id == "network.provider_reachability") + else { + return; + }; + #[cfg(target_os = "macos")] + let desktop_update_url = std::env::var_os("HOME") + .map(PathBuf::from) + .map(|home| { + macos_desktop_update_url(&home, application, &os_info::get().version().to_string()) + }) + .unwrap_or_else(|| DESKTOP_UPDATE_URL.to_string()); + #[cfg(target_os = "windows")] + let desktop_update_url = DESKTOP_UPDATE_URL; + #[cfg(target_os = "macos")] + let desktop_update_url = desktop_update_url.as_str(); + let desktop_update_display_url = desktop_update_url + .split_once('?') + .map_or(desktop_update_url, |(endpoint, _)| endpoint); + let client = RouteAwareClientPool::new_without_request_logging( + config.http_client_factory(), + ClientRouteClass::Other, + ); + let outcome = match client + .request(Method::GET, desktop_update_url) + .timeout(deadline.saturating_duration_since(tokio::time::Instant::now())) + .send() + .await + { + Ok(response) => { + let status = response.status().as_u16(); + #[cfg(target_os = "windows")] + if status == 404 && response.url().scheme() == "https" { + checks[reachability_index].details.push(format!( + "desktop assets CDN: {desktop_update_display_url} reachable (HTTP 404; no update available)" + )); + return; + } + if cfg!(target_os = "windows") && response.url().scheme() != "https" { + Err("update manifest redirected to a non-HTTPS URL".to_string()) + } else if status == 407 { + Err("proxy authentication required (HTTP 407)".to_string()) + } else if !(200..=299).contains(&status) { + Err(format!("HTTP {status}")) + } else { + checks[reachability_index].details.push(format!( + "desktop assets CDN: {desktop_update_display_url} reachable (HTTP {status})" + )); + #[cfg(target_os = "windows")] + if let Some(update) = checks.iter_mut().find(|check| check.id == "updates.status") { + match response.bytes().await { + Ok(body) => match windows_store_update(&body, &application.version) { + Ok(Some(build)) => update.details.extend([ + "desktop update status: available".to_string(), + format!("desktop latest build: {build}"), + format!("desktop application: {}", application.identity), + ]), + Ok(None) => {} + Err(error) => { + update.status = update.status.max(CheckStatus::Warning); + update + .details + .push(format!("desktop update manifest: {error}")); + } + }, + Err(_) => { + update.status = update.status.max(CheckStatus::Warning); + update + .details + .push("desktop update manifest: response could not be read".into()); + } + } + } + Ok(()) + } + } + Err(error) => Err(network::request_error(error)), + }; + + if let Err(error) = outcome { + let reachability = &mut checks[reachability_index]; + reachability.details.push(format!( + "desktop assets CDN: {desktop_update_display_url} {error} (optional)" + )); + if reachability.status == CheckStatus::Ok { + reachability.status = CheckStatus::Warning; + reachability.summary = "desktop update and runtime CDN is unreachable".to_string(); + } + reachability.issues.push( + DoctorIssue::new( + CheckStatus::Warning, + "desktop update and runtime CDN is unreachable", + ) + .measured(format!("{desktop_update_display_url} {error}")) + .expected("desktop update and runtime CDN reachable over HTTPS") + .remedy( + if desktop_update_display_url.starts_with("https://chatgpt.com/") { + "check proxy, firewall, DNS, and certificate access to chatgpt.com" + } else { + "check proxy, firewall, DNS, and certificate access to persistent.oaistatic.com" + }, + ) + .field("desktop assets CDN"), + ); + } +} + +#[cfg(target_os = "macos")] +fn macos_desktop_update_url(home: &Path, application: &InstalledApp, os_version: &str) -> String { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct ProductionAppcastState { + #[serde(default)] + backend_appcast_enabled: bool, + installation_id: Option, + } + + let state_path = home + .join("Library/Application Support") + .join(application.identity) + .join("production-appcast-bootstrap.json"); + let Some(state) = std::fs::read(state_path) + .ok() + .and_then(|contents| serde_json::from_slice::(&contents).ok()) + else { + return DESKTOP_UPDATE_URL.to_string(); + }; + let Some(installation_id) = state + .backend_appcast_enabled + .then_some(state.installation_id) + .flatten() + else { + return DESKTOP_UPDATE_URL.to_string(); + }; + + let Ok(mut url) = Url::parse(BACKEND_DESKTOP_UPDATE_URL) else { + return DESKTOP_UPDATE_URL.to_string(); + }; + url.query_pairs_mut().extend_pairs([ + ("installation_id", installation_id.as_str()), + ( + "arch", + if cfg!(target_arch = "x86_64") { + "x64" + } else { + "arm64" + }, + ), + ("app_version", application.version.as_str()), + ("beta", "false"), + ("os-version", os_version), + ("plan_type", "unknown"), + ]); + url.to_string() +} + +#[cfg(any(target_os = "windows", test))] +fn windows_store_update( + manifest: &[u8], + installed_version: &str, +) -> Result, &'static str> { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct StoreManifest { + schema_version: u64, + build_version: String, + store_product_id: String, + package_identity: String, + } + + let manifest: StoreManifest = + serde_json::from_slice(manifest).map_err(|_| "invalid Windows Store update manifest")?; + if manifest.schema_version == 0 + || manifest.store_product_id != "9PLM9XGG6VKS" + || manifest.package_identity != "OpenAI.Codex" + { + return Err("Windows Store update manifest does not target the production application"); + } + let version = |value: &str| -> Option<[u64; 4]> { + value + .split('.') + .map(str::parse::) + .collect::, _>>() + .ok()? + .try_into() + .ok() + }; + let latest = version(&manifest.build_version) + .ok_or("Windows Store update manifest contains an invalid build version")?; + let installed = + version(installed_version).ok_or("installed Windows application has an invalid version")?; + Ok((latest > installed).then_some(manifest.build_version)) +} + +#[cfg(target_os = "macos")] +async fn latest_macos_staged_build(root: &Path, installed_build: u64) -> Option { + const MAX_STAGED_BUNDLES: usize = 64; + + if !std::fs::symlink_metadata(root).ok()?.is_dir() { + return None; + } + let deadline = tokio::time::Instant::now() + Duration::from_secs(1); + let mut inspected = 0; + let mut latest = None; + for entry in std::fs::read_dir(root).ok()? { + if inspected == MAX_STAGED_BUNDLES || tokio::time::Instant::now() >= deadline { + break; + } + let Ok(entry) = entry else { + continue; + }; + if !entry.file_type().is_ok_and(|kind| kind.is_dir()) { + continue; + } + let extracted = entry.path().join("extracted"); + if !std::fs::symlink_metadata(&extracted).is_ok_and(|metadata| metadata.is_dir()) { + continue; + } + let bundle = extracted.join("ChatGPT.app"); + if !std::fs::symlink_metadata(&bundle).is_ok_and(|metadata| metadata.is_dir()) { + continue; + } + inspected += 1; + let Ok(result) = tokio::time::timeout_at( + deadline, + super::desktop::platform::inspect_macos_bundle(&bundle), + ) + .await + else { + break; + }; + if let Ok(Some(application)) = result + && application.build > installed_build + { + latest = Some(latest.map_or(application.build, |latest: u64| { + latest.max(application.build) + })); + } + } + latest +} + fn push_cached_version_details(details: &mut Vec, version_file: &Path) { details.push(format!("version cache: {}", version_file.display())); match std::fs::read_to_string(version_file) { @@ -209,6 +508,117 @@ struct VersionInfo { mod tests { use super::*; + #[cfg(target_os = "macos")] + #[test] + fn macos_update_probe_uses_the_persisted_production_appcast_feed() { + let home = tempfile::tempdir().expect("temporary home should be created"); + let application = InstalledApp { + identity: "com.openai.codex", + version: "26.623.10000".to_string(), + bundle: PathBuf::new(), + build: 6139, + }; + assert_eq!( + macos_desktop_update_url(home.path(), &application, "26.6.0"), + DESKTOP_UPDATE_URL + ); + + let state_directory = home + .path() + .join("Library/Application Support/com.openai.codex"); + std::fs::create_dir_all(&state_directory) + .expect("production appcast state directory should be created"); + std::fs::write( + state_directory.join("production-appcast-bootstrap.json"), + r#"{"backendAppcastEnabled":true,"installationId":"028e90f8-5f2a-47db-a05c-6a48f548d728"}"#, + ) + .expect("production appcast state should be created"); + + let arch = if cfg!(target_arch = "x86_64") { + "x64" + } else { + "arm64" + }; + assert_eq!( + macos_desktop_update_url(home.path(), &application, "26.6.0"), + format!( + "{BACKEND_DESKTOP_UPDATE_URL}?installation_id=028e90f8-5f2a-47db-a05c-6a48f548d728&arch={arch}&app_version=26.623.10000&beta=false&os-version=26.6.0&plan_type=unknown" + ) + ); + } + + #[cfg(target_os = "macos")] + #[tokio::test] + async fn macos_staged_updates_require_a_newer_matching_extracted_bundle() { + let root = tempfile::tempdir().expect("temporary Sparkle cache should be created"); + for index in 0..320 { + std::fs::create_dir(root.path().join(format!("unrelated-{index}"))) + .expect("unrelated Sparkle cache directory should be created"); + } + for (name, identity, build) in [ + ("newest", "com.openai.codex", "6268"), + ("newer", "com.openai.codex", "6168"), + ("older", "com.openai.codex", "6138"), + ("different", "com.example.other", "9999"), + ("invalid", "com.openai.codex", "invalid"), + ] { + let bundle = root.path().join(name).join("extracted/ChatGPT.app"); + write_macos_bundle(&bundle, identity, build); + } + let outside = tempfile::tempdir().expect("external fixture should be created"); + let linked = outside.path().join("ChatGPT.app"); + write_macos_bundle(&linked, "com.openai.codex", "9999"); + std::os::unix::fs::symlink(&linked, root.path().join("ChatGPT.app")) + .expect("symlinked staged app fixture should be created"); + + assert_eq!( + latest_macos_staged_build(root.path(), /*installed_build*/ 6139).await, + Some(6268) + ); + assert_eq!( + latest_macos_staged_build(root.path(), /*installed_build*/ 6268).await, + None + ); + } + + #[test] + fn windows_store_updates_compare_all_four_production_build_components() { + let mut manifest = serde_json::json!({ + "schemaVersion": 1, + "buildVersion": "26.803.5235.1", + "storeProductId": "9PLM9XGG6VKS", + "packageIdentity": "OpenAI.Codex", + }); + assert_eq!( + windows_store_update(&serde_json::to_vec(&manifest).unwrap(), "26.803.5235.0"), + Ok(Some("26.803.5235.1".to_string())) + ); + assert_eq!( + windows_store_update(&serde_json::to_vec(&manifest).unwrap(), "26.803.5235.1"), + Ok(None) + ); + manifest["storeProductId"] = "other".into(); + assert!( + windows_store_update(&serde_json::to_vec(&manifest).unwrap(), "26.803.5235.0").is_err() + ); + } + + #[cfg(target_os = "macos")] + fn write_macos_bundle(path: &Path, identity: &str, build: &str) { + let contents = path.join("Contents"); + std::fs::create_dir_all(&contents).expect("staged app fixture should be created"); + std::fs::write( + contents.join("Info.plist"), + format!( + "\ + CFBundleIdentifier{identity}\ + CFBundleVersion{build}\ + " + ), + ) + .expect("staged app metadata should be created"); + } + #[test] fn is_newer_compares_plain_semver() { assert_eq!(is_newer("1.2.4", "1.2.3"), Some(true)); diff --git a/codex-rs/cli/tests/doctor_enterprise_network.rs b/codex-rs/cli/tests/doctor_enterprise_network.rs index 4324057c28..2d384a0899 100644 --- a/codex-rs/cli/tests/doctor_enterprise_network.rs +++ b/codex-rs/cli/tests/doctor_enterprise_network.rs @@ -5,8 +5,10 @@ use std::process::Stdio; use anyhow::Context as _; use anyhow::Result; +#[cfg(target_os = "macos")] use pretty_assertions::assert_eq; use serde_json::Value; +#[cfg(target_os = "macos")] use serde_json::json; use tempfile::TempDir; use wiremock::Mock; @@ -63,9 +65,10 @@ async fn invalid_custom_ca_falls_back_to_system_roots() -> Result<()> { .context("failed to run the doctor with an invalid custom CA")?; let report: Value = serde_json::from_slice(&output.stdout)?; - assert_eq!( - report["checks"]["network.provider_reachability"]["status"], - json!("ok") + assert!( + report["checks"]["network.provider_reachability"]["details"]["local API inference URL"] + .as_str() + .is_some_and(|detail| detail.ends_with("reachable (HTTP 200)")) ); } server.verify().await;