From 34cab99a0ae517e4c0bc479b37202bc38464bf55 Mon Sep 17 00:00:00 2001 From: lzt404 <2596933790@qq.com> Date: Sun, 6 Sep 2026 16:25:32 +0800 Subject: [PATCH] fix(agents): detect the correct Codex App version on Windows --- src-tauri/src/agents/discovery.rs | 146 ++++++++++++++++++---- src-tauri/src/tests/platform.rs | 194 ++++++++++++++++++++++++++++++ 2 files changed, 315 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/agents/discovery.rs b/src-tauri/src/agents/discovery.rs index 1bb764c..5dcb7db 100644 --- a/src-tauri/src/agents/discovery.rs +++ b/src-tauri/src/agents/discovery.rs @@ -2008,13 +2008,8 @@ pub(crate) fn read_codex_app_installation_version( #[cfg(target_os = "windows")] { match installation { - CodexAppTarget::WindowsAppId(app_id) => { - let _ = app_id; - read_windows_codex_store_version() - } - CodexAppTarget::Application(path) => { - read_windows_executable_version(path).or_else(|| read_agent_version(path, _home)) - } + CodexAppTarget::WindowsAppId(app_id) => read_windows_codex_store_version(app_id), + CodexAppTarget::Application(path) => read_windows_codex_desktop_version(path), } } #[cfg(target_os = "macos")] @@ -2220,23 +2215,9 @@ if ($package -and $package.Version) { } #[cfg(target_os = "windows")] -pub(crate) fn read_windows_codex_store_version() -> Option { - const VERSION_SCRIPT: &str = r#" -$ErrorActionPreference = 'SilentlyContinue' -$ProgressPreference = 'SilentlyContinue' -$package = @(Get-AppxPackage) | - Where-Object { - $_.Name -in @('OpenAI.Codex', 'OpenAI.CodexBeta', 'OpenAI.ChatGPT') -or - $_.PackageFamilyName -match '^OpenAI\.(Codex|CodexBeta|ChatGPT)_' - } | - Sort-Object { [version]$_.Version } -Descending | - Select-Object -First 1 -if ($package -and $package.Version) { - Write-Output "VERSION:$($package.Version)" -} -"#; - - let encoded_command = windows_powershell_encoded_command(VERSION_SCRIPT); +pub(crate) fn read_windows_codex_store_version(app_id: &str) -> Option { + let script = windows_codex_store_executable_script(app_id)?; + let encoded_command = windows_powershell_encoded_command(&script); let mut command = Command::new(windows_powershell_executable()); command.args([ "-NoLogo", @@ -2252,7 +2233,122 @@ if ($package -and $package.Version) { if !output.status.success() { return None; } - parse_windows_codex_version_output(&String::from_utf8_lossy(&output.stdout)) + match parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout))? { + CodexAppTarget::Application(path) => read_windows_codex_desktop_version(&path), + CodexAppTarget::WindowsAppId(_) => None, + } +} + +#[cfg(target_os = "windows")] +pub(crate) fn windows_codex_store_executable_script(app_id: &str) -> Option { + let (family, application) = app_id.split_once('!')?; + if family.is_empty() || application.is_empty() { + return None; + } + let family = windows_powershell_single_quoted_literal(family); + let application = windows_powershell_single_quoted_literal(application); + Some(format!( + r#" +$ErrorActionPreference = 'SilentlyContinue' +$ProgressPreference = 'SilentlyContinue' +$package = Get-AppxPackage | Where-Object {{ $_.PackageFamilyName -eq {family} }} | + Sort-Object {{ [version]$_.Version }} -Descending | Select-Object -First 1 +if ($package) {{ + $manifest = Get-AppxPackageManifest -Package $package.PackageFullName + $application = @($manifest.Package.Applications.Application) | + Where-Object {{ $_.Id -eq {application} }} | Select-Object -First 1 + if ($application.Executable -and $package.InstallLocation) {{ + $path = Join-Path $package.InstallLocation $application.Executable + if (Test-Path -LiteralPath $path -PathType Leaf) {{ Write-Output "EXE:$path" }} + }} +}} +"# + )) +} + +#[cfg(target_os = "windows")] +pub(crate) fn read_windows_codex_desktop_version(executable: &Path) -> Option { + let resources = executable.parent()?.join("resources"); + let owl_ini = resources.join("owl-app.ini"); + let asar = resources.join("app.asar"); + fs::read_to_string(&owl_ini) + .ok() + .and_then(|content| parse_codex_owl_app_version(&content)) + .or_else(|| read_codex_asar_version(&asar)) + .or_else(|| { + // Owl's EXE reports the Chromium runtime version, not the Codex + // version. Never launch a desktop executable with --version either. + if owl_ini.exists() || asar.exists() || resources.join("owl-electron-app.json").exists() + { + None + } else { + read_windows_executable_version(executable) + } + }) +} + +#[cfg(target_os = "windows")] +pub(crate) fn parse_codex_owl_app_version(content: &str) -> Option { + let mut in_owl_section = false; + for line in content + .trim_start_matches('\u{feff}') + .lines() + .map(str::trim) + { + if line.starts_with('[') && line.ends_with(']') { + in_owl_section = line.eq_ignore_ascii_case("[Owl]"); + } else if in_owl_section { + if let Some((key, value)) = line.split_once('=') { + if key.trim().eq_ignore_ascii_case("AppVersion") { + return normalize_detected_agent_version(value); + } + } + } + } + None +} + +#[cfg(target_os = "windows")] +pub(crate) fn read_codex_asar_version(path: &Path) -> Option { + use std::io::{Read, Seek, SeekFrom}; + + // ASAR starts with two Chromium pickles: a header-size pickle followed by + // a JSON header pickle. Read only the header and the root package.json, + // not the hundreds of MB of bundled application code. + let mut file = fs::File::open(path).ok()?; + let mut prefix = [0_u8; 16]; + file.read_exact(&mut prefix).ok()?; + let word = |offset| u32::from_le_bytes(prefix[offset..offset + 4].try_into().unwrap()) as u64; + let header_size = word(4); + let json_size = word(12); + if word(0) != 4 + || !(8..=16 * 1024 * 1024).contains(&header_size) + || word(8) != header_size - 4 + || json_size > header_size - 8 + { + return None; + } + let mut header = vec![0; json_size as usize]; + file.read_exact(&mut header).ok()?; + let header: serde_json::Value = serde_json::from_slice(&header).ok()?; + let entry = header.get("files")?.get("package.json")?; + if entry.get("unpacked").and_then(serde_json::Value::as_bool) == Some(true) { + return None; + } + let size = entry.get("size")?.as_u64()?; + if size > 1024 * 1024 { + return None; + } + let offset = entry.get("offset")?.as_str()?.parse::().ok()?; + let position = (8 + header_size).checked_add(offset)?; + if position.checked_add(size)? > file.metadata().ok()?.len() { + return None; + } + file.seek(SeekFrom::Start(position)).ok()?; + let mut package = vec![0; size as usize]; + file.read_exact(&mut package).ok()?; + let package: serde_json::Value = serde_json::from_slice(&package).ok()?; + normalize_detected_agent_version(package.get("version")?.as_str()?) } #[cfg(target_os = "windows")] diff --git a/src-tauri/src/tests/platform.rs b/src-tauri/src/tests/platform.rs index 36ac13b..267305b 100644 --- a/src-tauri/src/tests/platform.rs +++ b/src-tauri/src/tests/platform.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "windows")] +use super::support::agent_test_home; use super::*; #[cfg(target_os = "windows")] @@ -101,3 +103,195 @@ fn windows_chatgpt_discovery_parser_accepts_registered_app_and_executable() { ) .is_none()); } + +#[cfg(target_os = "windows")] +#[test] +fn codex_owl_version_uses_app_metadata_not_package_or_runtime_version() { + assert_eq!( + parse_codex_owl_app_version( + "\u{feff}[Owl]\r\nUserDataDirectoryName=Codex\r\nAppVersion=26.901.51231\r\n" + ) + .as_deref(), + Some("26.901.51231") + ); + assert_eq!( + parse_codex_owl_app_version("[owl]\n AppVersion = 26.901.51231 \n").as_deref(), + Some("26.901.51231") + ); + for content in [ + "AppVersion=26.901.51231", + "[Other]\nAppVersion=26.901.51231", + "[Owl]\n[Other]\nAppVersion=26.901.51231", + "[Owl]\nAppVersion=unknown", + "[Owl]\nAppVersion=", + "[Owl]\nAppVersion=1.2.3\0invalid", + ] { + assert!( + parse_codex_owl_app_version(content).is_none(), + "{content:?}" + ); + } +} + +#[cfg(target_os = "windows")] +fn codex_version_test_asar(package: &[u8], offset: &str, size: u64) -> Vec { + let header = serde_json::to_vec(&serde_json::json!({ + "files": { "package.json": { "offset": offset, "size": size } } + })) + .unwrap(); + let payload_size = (4 + header.len()).next_multiple_of(4) as u32; + let header_size = 4 + payload_size; + let mut archive = Vec::new(); + for value in [4, header_size, payload_size, header.len() as u32] { + archive.extend_from_slice(&value.to_le_bytes()); + } + archive.extend(header); + archive.resize(8 + header_size as usize, 0); + archive.extend_from_slice(package); + archive +} + +#[cfg(target_os = "windows")] +#[test] +fn codex_desktop_version_prefers_owl_then_asar_without_launching_the_app() { + let home = agent_test_home("codex-desktop-version"); + let resources = home.join("resources"); + fs::create_dir_all(&resources).unwrap(); + let executable = home.join("ChatGPT.exe"); + fs::write(&executable, b"not an executable").unwrap(); + let package = br#"{"name":"openai-codex-electron","version":"26.901.51231"}"#; + let mut data = b"other archive data".to_vec(); + let offset = data.len().to_string(); + data.extend_from_slice(package); + let asar = resources.join("app.asar"); + fs::write( + &asar, + codex_version_test_asar(&data, &offset, package.len() as u64), + ) + .unwrap(); + assert_eq!( + read_codex_asar_version(&asar).as_deref(), + Some("26.901.51231") + ); + assert_eq!( + read_windows_codex_desktop_version(&executable).as_deref(), + Some("26.901.51231") + ); + + let ini = resources.join("owl-app.ini"); + fs::write(&ini, "[Owl]\nAppVersion=26.902.12345\n").unwrap(); + assert_eq!( + read_windows_codex_desktop_version(&executable).as_deref(), + Some("26.902.12345") + ); + fs::write(&ini, "[Owl]\nAppVersion=unknown\n").unwrap(); + assert_eq!( + read_windows_codex_desktop_version(&executable).as_deref(), + Some("26.901.51231") + ); + fs::write(&asar, b"broken archive").unwrap(); + assert!(read_windows_codex_desktop_version(&executable).is_none()); + fs::remove_file(&asar).unwrap(); + assert!(read_windows_codex_desktop_version(&executable).is_none()); + fs::remove_dir_all(home).unwrap(); +} + +#[cfg(target_os = "windows")] +#[test] +fn codex_asar_version_rejects_invalid_sizes_offsets_and_metadata() { + let home = agent_test_home("codex-invalid-asar-version"); + let asar = home.join("app.asar"); + let package = br#"{"version":"26.901.51231"}"#; + let valid = codex_version_test_asar(package, "0", package.len() as u64); + let mut oversized_header = valid.clone(); + oversized_header[4..8].copy_from_slice(&u32::MAX.to_le_bytes()); + let mut oversized_json = valid.clone(); + oversized_json[12..16].copy_from_slice(&u32::MAX.to_le_bytes()); + for archive in [ + Vec::new(), + valid[..15].to_vec(), + valid[..valid.len() - 1].to_vec(), + oversized_header, + oversized_json, + codex_version_test_asar(package, "18446744073709551615", package.len() as u64), + codex_version_test_asar(package, "-1", package.len() as u64), + codex_version_test_asar(package, "0", 1024 * 1024 + 1), + codex_version_test_asar(b"{}", "0", 2), + codex_version_test_asar(b"invalid", "0", 7), + ] { + fs::write(&asar, archive).unwrap(); + assert!(read_codex_asar_version(&asar).is_none()); + } + fs::remove_dir_all(home).unwrap(); +} + +#[cfg(target_os = "windows")] +#[test] +fn codex_store_version_resolves_only_the_selected_family_and_application() { + let home = agent_test_home("codex-store-version-target"); + let executable = home.join("ChatGPT.exe"); + fs::write(&executable, b"test").unwrap(); + let install_location = windows_powershell_single_quoted_literal(&path_to_string(&home)); + let mocks = format!( + r#" +function Get-AppxPackage {{ + [pscustomobject]@{{ PackageFamilyName='OpenAI.ChatGPT_publisher'; Version='99.0.0.0'; PackageFullName='wrong-chatgpt' }} + [pscustomobject]@{{ PackageFamilyName='OpenAI.CodexBeta_publisher'; Version='98.0.0.0'; PackageFullName='wrong-beta' }} + [pscustomobject]@{{ PackageFamilyName='OpenAI.Codex_publisher'; Version='26.800.1.0'; PackageFullName='old-codex' }} + [pscustomobject]@{{ PackageFamilyName='OpenAI.Codex_publisher'; Version='26.901.6511.0'; PackageFullName='selected-codex'; InstallLocation={install_location} }} +}} +function Get-AppxPackageManifest($Package) {{ + if ($Package -ne 'selected-codex') {{ throw 'wrong package' }} + [pscustomobject]@{{ Package=@{{ Applications=@{{ Application=@( + [pscustomobject]@{{ Id='Other'; Executable='wrong.exe' }}, + [pscustomobject]@{{ Id='App'; Executable='ChatGPT.exe' }} + ) }} }} }} +}} +"# + ); + for (app_id, expected) in [ + ("OpenAI.Codex_publisher!App", true), + ("OpenAI.Codex_publisher!Missing", false), + ("OpenAI.Codex_missing!App", false), + ] { + let script = format!( + "{mocks}\n{}", + windows_codex_store_executable_script(app_id).unwrap() + ); + let mut command = Command::new(windows_powershell_executable()); + command.args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-EncodedCommand", + &windows_powershell_encoded_command(&script), + ]); + configure_background_command(&mut command); + let output = command_output_with_timeout(&mut command, Duration::from_secs(10)) + .unwrap() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let target = + parse_windows_codex_app_discovery_output(&String::from_utf8_lossy(&output.stdout)); + if expected { + assert!( + matches!(target, Some(CodexAppTarget::Application(path)) if path == executable) + ); + } else { + assert!(target.is_none()); + } + } + assert!(windows_codex_store_executable_script("invalid").is_none()); + assert!(windows_codex_store_executable_script("!App").is_none()); + assert!(windows_codex_store_executable_script("OpenAI.Codex_publisher!").is_none()); + assert!( + windows_codex_store_executable_script("OpenAI.Codex_o'brien!App") + .unwrap() + .contains("'OpenAI.Codex_o''brien'") + ); + fs::remove_dir_all(home).unwrap(); +}