From eb1d7065f8b1b2fb3d197bc66448deb432ab0a12 Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 14 Oct 2026 00:42:30 +0000 Subject: [PATCH] Support daemon installation from Windows WinGet packages (#53811) ## Why WinGet archives place the CLI and code mode host at the package root, while daemon installation expects them under `bin/`. This layout mismatch prevents installing the daemon from a WinGet CLI package. ## What changed Validate WinGet packages in their original layout before converting the staged copy to the daemon layout. Move the executables under `bin/`, update the package entrypoint and optional voice manifest checksum key, and hash the final tree for release reuse. Leave the source package unchanged. ## Testing Add a Windows integration test covering daemon startup, repeated `update --from-cli --yes` calls, rewritten manifests, and rejection of an incomplete package without changing the selected release or source manifests. GitOrigin-RevId: e78c461a557a4d005fe1f5fe37fb5ba05bb38608 --- .../app-server-daemon/src/prepare_install.rs | 70 +++++++--- .../src/prepare_install_tests.rs | 2 +- .../cli/tests/app_server_daemon_windows.rs | 126 ++++++++++++++++++ 3 files changed, 181 insertions(+), 17 deletions(-) create mode 100644 codex-rs/cli/tests/app_server_daemon_windows.rs diff --git a/codex-rs/app-server-daemon/src/prepare_install.rs b/codex-rs/app-server-daemon/src/prepare_install.rs index 3b09a604db..a313e940f7 100644 --- a/codex-rs/app-server-daemon/src/prepare_install.rs +++ b/codex-rs/app-server-daemon/src/prepare_install.rs @@ -1,5 +1,6 @@ //! Installs complete CLI packages after confirmation. Initial starts preserve //! existing selections; explicit replacements migrate to dedicated packages and stay pinned. +//! WinGet sources are verified in their original layout before staging a canonical daemon package. use std::path::Path; use std::path::PathBuf; @@ -154,17 +155,21 @@ async fn prepare_from_package( let manifest: CodexPackageManifest = serde_json::from_slice(&manifest_bytes)?; let version = manifest.version.to_string(); let target = platform_target()?; - let metadata: serde_json::Value = serde_json::from_slice(&manifest_bytes)?; + let mut metadata: serde_json::Value = serde_json::from_slice(&manifest_bytes)?; let entrypoint = if cfg!(windows) { "bin/codex.exe" } else { "bin/codex" }; + let source_entrypoint = metadata["entrypoint"].as_str().unwrap_or_default(); + let winget = cfg!(windows) + && metadata["layoutVersion"] == 1 + && source_entrypoint == format!("codex-{target}.exe"); anyhow::ensure!( - metadata["target"] == target && metadata["entrypoint"] == entrypoint, + metadata["target"] == target && (source_entrypoint == entrypoint || winget), "the CLI package does not match this platform or executable" ); - validate_package(source)?; + validate_package(source, source_entrypoint)?; // A local build may replace the executable while confirmation is pending. let running_identity = managed_install::executable_identity(running_exe).await?; if mode == InstallMode::Replace { @@ -227,11 +232,10 @@ async fn prepare_from_package( .prefix(".staging.") .tempdir_in(&releases)?; let digest = package_tree(source, Some(stage.path()))?; - validate_package(stage.path())?; let staged_exe = if cfg!(target_os = "macos") && stage.path().join("CodexCLI.app").is_dir() { stage.path().join("CodexCLI.app/Contents/MacOS/codex") } else { - stage.path().join(entrypoint) + stage.path().join(source_entrypoint) }; anyhow::ensure!( package_tree(source, /*destination*/ None)? == digest @@ -241,6 +245,42 @@ async fn prepare_from_package( .same_contents(&running_identity), "the CLI package changed while preparing the daemon or differs from the running executable" ); + // Reverse the WinGet archive's root filenames only after verifying the copied + // source. Hash the final tree for reuse, since it now differs from the source. + let digest = if winget { + std::fs::create_dir_all(stage.path().join("bin"))?; + #[cfg(windows)] + { + windows::publish_release(&staged_exe, &stage.path().join(entrypoint)).await?; + windows::publish_release( + &stage.path().join("codex-code-mode-host.exe"), + &stage.path().join("bin/codex-code-mode-host.exe"), + ) + .await?; + } + let voice_path = stage.path().join("codex-resources/voice/manifest.json"); + if voice_path.try_exists()? { + let mut voice: serde_json::Value = + serde_json::from_slice(&std::fs::read(&voice_path)?)?; + let checksums = voice["sha256"] + .as_object_mut() + .context("invalid WinGet voice manifest checksums")?; + let checksum = checksums + .remove(source_entrypoint) + .context("WinGet voice manifest is missing the CLI checksum")?; + checksums.insert(entrypoint.to_string(), checksum); + std::fs::write(voice_path, serde_json::to_vec_pretty(&voice)?)?; + } + metadata["entrypoint"] = entrypoint.into(); + std::fs::write( + stage.path().join("codex-package.json"), + serde_json::to_vec_pretty(&metadata)?, + )?; + package_tree(stage.path(), /*destination*/ None)? + } else { + digest + }; + validate_package(stage.path(), entrypoint)?; let binary_version = managed_install::managed_codex_version(&stage.path().join(entrypoint)).await?; anyhow::ensure!( @@ -429,19 +469,17 @@ fn stable_version(value: &str) -> Option { .then_some(version) } -fn validate_package(root: &Path) -> Result<()> { +fn validate_package(root: &Path, entrypoint: &str) -> Result<()> { + let code_mode_host = Path::new(entrypoint).with_file_name(if cfg!(windows) { + "codex-code-mode-host.exe" + } else { + "codex-code-mode-host" + }); + let code_mode_host = code_mode_host.to_string_lossy(); let mut names = vec![ "codex-package.json", - if cfg!(windows) { - "bin/codex.exe" - } else { - "bin/codex" - }, - if cfg!(windows) { - "bin/codex-code-mode-host.exe" - } else { - "bin/codex-code-mode-host" - }, + entrypoint, + code_mode_host.as_ref(), if cfg!(windows) { "codex-path/rg.exe" } else { diff --git a/codex-rs/app-server-daemon/src/prepare_install_tests.rs b/codex-rs/app-server-daemon/src/prepare_install_tests.rs index eaa89527c7..8901063ab5 100644 --- a/codex-rs/app-server-daemon/src/prepare_install_tests.rs +++ b/codex-rs/app-server-daemon/src/prepare_install_tests.rs @@ -78,7 +78,7 @@ async fn seeds_full_package() { std::fs::read_to_string(standalone.join("auto-update-version")).expect("marker"), selected.file_name().expect("name").to_string_lossy() ); - assert!(validate_package(&selected).is_ok()); + assert!(validate_package(&selected, "bin/codex").is_ok()); } #[tokio::test] diff --git a/codex-rs/cli/tests/app_server_daemon_windows.rs b/codex-rs/cli/tests/app_server_daemon_windows.rs new file mode 100644 index 0000000000..5f955cd11d --- /dev/null +++ b/codex-rs/cli/tests/app_server_daemon_windows.rs @@ -0,0 +1,126 @@ +//! Exercises daemon installation from the Windows WinGet archive layout. +#![cfg(windows)] + +use anyhow::Result; +use anyhow::ensure; +use codex_utils_cargo_bin::copy_executable; +use pretty_assertions::assert_eq; +use serde_json::Value; +use sha2::Digest; +use sha2::Sha256; +use std::process::Command; + +#[test] +fn winget_package_starts_and_reselects_daemon() -> Result<()> { + if codex_app_server_daemon::is_elevated()? { + eprintln!("requires a non-elevated Windows runner"); + return Ok(()); + } + let temp = tempfile::tempdir()?; + let home = temp.path().join("home"); + let package = temp.path().join("WinGet/Packages/OpenAI.Codex"); + let target = format!("{}-pc-windows-msvc", std::env::consts::ARCH); + let entrypoint = format!("codex-{target}.exe"); + let codex = package.join(&entrypoint); + for directory in ["bin", "codex-path", "codex-resources/voice"] { + std::fs::create_dir_all(package.join(directory))?; + } + copy_executable(&codex_utils_cargo_bin::cargo_bin("codex")?, &codex)?; + for helper in [ + "codex-code-mode-host.exe", + "codex-path/rg.exe", + "codex-resources/codex-command-runner.exe", + "codex-resources/codex-windows-sandbox-setup.exe", + ] { + std::fs::write(package.join(helper), b"runtime fixture")?; + } + let mut metadata = serde_json::json!({ + "layoutVersion": 1, "version": env!("CARGO_PKG_VERSION"), + "target": target, "entrypoint": entrypoint + }); + let manifest_bytes = serde_json::to_vec(&metadata)?; + std::fs::write(package.join("codex-package.json"), &manifest_bytes)?; + let checksum = format!("{:x}", Sha256::digest(std::fs::read(&codex)?)); + let voice_bytes = serde_json::to_vec(&serde_json::json!({ + "sha256": { &entrypoint: checksum, "codex-resources/voice/runtime": "untouched" } + }))?; + std::fs::write( + package.join("codex-resources/voice/manifest.json"), + &voice_bytes, + )?; + + let current = home.join("packages/app-server-daemon/current"); + // Drop before temp so assertion failures also release the managed executable. + struct StopDaemon(Command); + impl Drop for StopDaemon { + fn drop(&mut self) { + let _ = self.0.output(); + } + } + let mut stop = Command::new(&codex); + stop.env("CODEX_HOME", &home) + .args(["app-server", "daemon", "stop"]); + let mut daemon = StopDaemon(stop); + for args in [ + vec!["start"], + vec!["update", "--from-cli", "--yes"], + vec!["update", "--from-cli", "--yes"], + ] { + let output = Command::new(&codex) + .env("CODEX_HOME", &home) + .args(["app-server", "daemon"]) + .args(&args) + .output()?; + ensure!( + output.status.success(), + "daemon {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + let selected_metadata: Value = + serde_json::from_slice(&std::fs::read(current.join("codex-package.json"))?)?; + metadata["entrypoint"] = "bin/codex.exe".into(); + assert_eq!(selected_metadata, metadata); + let selected_voice: Value = serde_json::from_slice(&std::fs::read( + current.join("codex-resources/voice/manifest.json"), + )?)?; + assert_eq!( + selected_voice, + serde_json::json!({ + "sha256": { + "bin/codex.exe": checksum, + "codex-resources/voice/runtime": "untouched" + } + }) + ); + assert_eq!( + std::fs::read(current.join("bin/codex-code-mode-host.exe"))?, + b"runtime fixture" + ); + let selected = current.canonicalize()?; + std::fs::remove_file(package.join("codex-code-mode-host.exe"))?; + let invalid = Command::new(&codex) + .env("CODEX_HOME", &home) + .args(["app-server", "daemon", "update", "--from-cli", "--yes"]) + .output()?; + ensure!( + !invalid.status.success(), + "an incomplete WinGet package was accepted" + ); + assert_eq!(current.canonicalize()?, selected); + assert_eq!( + std::fs::read(package.join("codex-package.json"))?, + manifest_bytes + ); + assert_eq!( + std::fs::read(package.join("codex-resources/voice/manifest.json"))?, + voice_bytes + ); + let stopped = daemon.0.output()?; + ensure!( + stopped.status.success(), + "failed to stop daemon: {}", + String::from_utf8_lossy(&stopped.stderr) + ); + Ok(()) +}