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
This commit is contained in:
Eric Traut
2026-10-14 00:49:27 +00:00
committed by copyberry
parent ca2b076fff
commit eb1d7065f8
3 changed files with 181 additions and 17 deletions
@@ -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<semver::Version> {
.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 {
@@ -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]
@@ -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(())
}