mirror of
https://github.com/openai/codex.git
synced 2026-10-21 03:54:55 +00:00
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:
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user