fix(agents): detect the correct Codex App version on Windows
This commit is contained in:
1 parent
cc26fd645d
commit
34cab99a0a
2 files changed
+315
-25
No files matched your search
@@ -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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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::<u64>().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")]
|
||||
|
||||
@@ -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<u8> {
|
||||
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();
|
||||
}
|
||||
Reference in new issue
Block a user