//! 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::collections::BTreeMap; use std::path::Path; use std::path::PathBuf; use anyhow::Context; use anyhow::Result; use anyhow::anyhow; use codex_install_context::CodexPackageManifest; use codex_install_context::InstallContext; use crate::Daemon; use crate::install_lock::acquire_install_lock; use crate::managed_install; use crate::settings::DaemonSettings; /// The complete CLI package offered for a daemon installation. pub struct InstallRequest { pub source: PathBuf, pub version: String, pub destination: PathBuf, pub installed_version: Option, pub restart_required: bool, } /// Prepare a missing package while the caller holds the daemon operation lock. pub(super) async fn prepare(daemon: &Daemon, settings: &DaemonSettings) -> Result<()> { let source = InstallContext::current().package_layout.as_ref(); // Keep package replacement state out of the CLI dispatcher's async stack frame. Box::pin(prepare_from_package( daemon, settings, InstallMode::Missing, source.map(|layout| layout.package_dir.as_path()), &std::env::current_exe()?, |_| Ok(true), )) .await .map(|_| ()) } /// Select this CLI's complete package and pin it, restarting only a running daemon. /// Returns None when the user cancels without changing the installation. pub async fn update_from_cli( confirm: impl FnOnce(&InstallRequest) -> Result, ) -> Result> { crate::ensure_supported_platform()?; #[cfg(windows)] crate::backend::windows::ensure_not_elevated()?; let daemon = Daemon::from_environment()?; let settings = daemon.load_settings().await?; let source = InstallContext::current().package_layout.as_ref(); if !Box::pin(prepare_from_package( &daemon, &settings, InstallMode::Replace, source.map(|layout| layout.package_dir.as_path()), &std::env::current_exe()?, confirm, )) .await? { return Ok(None); } let managed_codex_path = daemon.current_managed_codex_bin()?; Ok(Some(crate::UpdateOutput { status: crate::UpdateStatus::Updated, installed_version: Some(managed_install::managed_codex_version(&managed_codex_path).await?), running_version: crate::client::probe(&daemon.socket_path) .await .ok() .map(|info| info.app_server_version), managed_codex_path, message: "The CLI package is selected and pinned. Run `codex app-server daemon update` to return to production updates.".to_string(), })) } #[derive(Clone, Copy, PartialEq, Eq)] enum InstallMode { Missing, Replace, } async fn prepare_from_package( daemon: &Daemon, settings: &DaemonSettings, mode: InstallMode, source: Option<&Path>, running_exe: &Path, confirm: impl FnOnce(&InstallRequest) -> Result, ) -> Result { let home = daemon .settings_file .parent() .and_then(Path::parent) .context("daemon settings path has no Codex home")?; let previous_root = managed_install::package_root(home); let root = home.join("packages/app-server-daemon"); anyhow::ensure!( daemon.managed_codex_bin.starts_with(&previous_root), "daemon package location changed; retry the command" ); if mode == InstallMode::Missing { if previous_root != root || daemon.running_backend_instance(settings).await?.is_some() || crate::client::probe(&daemon.socket_path).await.is_ok() { return Ok(true); } if !matches!(root.join("current").symlink_metadata(), Err(error) if error.kind() == std::io::ErrorKind::NotFound) || ["daemon.pid", "daemon.stderr.log", "daemon-updater.pid", "daemon-updater.stderr.log"] .iter().any(|name| !matches!(home.join("app-server-daemon").join(name).symlink_metadata(), Err(error) if error.kind() == std::io::ErrorKind::NotFound)) { daemon.ensure_managed_codex_bin()?; return Ok(true); } } else { anyhow::ensure!( previous_root.join("current").symlink_metadata().is_ok(), "no daemon package is selected; run `codex app-server daemon start` first" ); } std::fs::create_dir_all(&root)?; anyhow::ensure!( managed_install::package_root(home) == previous_root, "daemon package location changed; retry the command" ); let backend = daemon.running_backend_instance(settings).await?; anyhow::ensure!( backend.is_some() || crate::client::probe(&daemon.socket_path).await.is_err(), "app server is running but is not managed by codex app-server daemon" ); let selected = managed_install::managed_codex_bin(home); let previous_release = previous_root.join("current").canonicalize().ok(); if mode == InstallMode::Missing { if selected.is_file() { return Ok(true); } anyhow::ensure!( backend.is_none() && matches!(root.join("current").symlink_metadata(), Err(error) if error.kind() == std::io::ErrorKind::NotFound), "the selected daemon package is incomplete; repair its installation before starting" ); } let source = source.context( "this CLI has no complete local package; install a packaged Codex CLI or use the standalone installer", )?; anyhow::ensure!( !root.canonicalize()?.starts_with(source.canonicalize()?), "CODEX_HOME must be outside the source CLI package" ); let manifest_bytes = std::fs::read(source.join("codex-package.json"))?; let manifest: CodexPackageManifest = serde_json::from_slice(&manifest_bytes)?; let version = manifest.version.to_string(); let target = platform_target()?; 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 && (source_entrypoint == entrypoint || winget), "the CLI package does not match this platform or executable" ); 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 { if !confirm(&InstallRequest { source: source.to_path_buf(), version: version.clone(), destination: root.clone(), installed_version: tokio::time::timeout( std::time::Duration::from_secs(5), managed_install::managed_codex_version(&selected), ) .await .ok() .and_then(Result::ok), restart_required: backend.is_some(), })? { return Ok(false); } } else { daemon.diagnostic(format_args!( "Installing daemon from CLI version {version} into {}...", root.display() )); } // Confirmation must not block lifecycle commands. Recheck the approved // selection and running state once this operation owns both locks. let _operation_lock = if mode == InstallMode::Replace { Some(daemon.acquire_operation_lock().await?) } else { None // Initial startup already holds the operation lock. }; let current_settings = if mode == InstallMode::Replace { Some(daemon.load_settings().await?) } else { None }; let settings = current_settings.as_ref().unwrap_or(settings); let _install_lock = acquire_install_lock(&root).await?; anyhow::ensure!( managed_install::package_root(home) == previous_root, "daemon package location changed; retry the command" ); if mode == InstallMode::Missing && managed_install::managed_codex_bin(home).is_file() { return Ok(true); } anyhow::ensure!( previous_root.join("current").canonicalize().ok() == previous_release, "daemon selection changed while awaiting confirmation; retry the command" ); let current_backend = daemon.running_backend_instance(settings).await?; anyhow::ensure!( current_backend.is_some() == backend.is_some(), "daemon running state changed while awaiting confirmation; retry the command" ); let backend = current_backend; let stable = stable_version(&version).is_some(); let releases = root.join("releases"); std::fs::create_dir_all(&releases)?; let stage = tempfile::Builder::new() .prefix(".staging.") .tempdir_in(&releases)?; let mut staged_digests = BTreeMap::new(); let digest = package_tree(source, Some(stage.path()), &mut staged_digests)?; let staged_exe = if cfg!(target_os = "macos") && stage.path().join("CodexCLI.app").is_dir() { Path::new("CodexCLI.app/Contents/MacOS/codex") } else { Path::new(source_entrypoint) }; anyhow::ensure!( package_tree(source, /*destination*/ None, &mut BTreeMap::new())? == digest && std::fs::read(stage.path().join("codex-package.json"))? == manifest_bytes && staged_digests.get(staged_exe).map(blake3::Hash::as_bytes) == Some(&running_identity.digest), "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)] for (from, to) in [ (staged_exe, Path::new(entrypoint)), ( Path::new("codex-code-mode-host.exe"), Path::new("bin/codex-code-mode-host.exe"), ), ] { windows::publish_release(&stage.path().join(from), &stage.path().join(to)).await?; if let Some(digest) = staged_digests.remove(from) { staged_digests.insert(to.to_path_buf(), digest); } } 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); let bytes = serde_json::to_vec_pretty(&voice)?; std::fs::write(voice_path, &bytes)?; staged_digests.insert( PathBuf::from("codex-resources/voice/manifest.json"), blake3::hash(&bytes), ); } metadata["entrypoint"] = entrypoint.into(); let bytes = serde_json::to_vec_pretty(&metadata)?; std::fs::write(stage.path().join("codex-package.json"), &bytes)?; staged_digests.insert(PathBuf::from("codex-package.json"), blake3::hash(&bytes)); package_tree(stage.path(), /*destination*/ None, &mut staged_digests)? } else { digest }; validate_package(stage.path(), entrypoint)?; let binary_version = managed_install::managed_codex_version(&stage.path().join(entrypoint)).await?; anyhow::ensure!( !stable || version == binary_version, "the CLI package version does not match its executable" ); let name = if stable && mode == InstallMode::Missing { format!("{version}-{target}") } else { format!("local-{digest}-{target}") }; let release = releases.join(&name); if release.try_exists()? { anyhow::ensure!( !release.symlink_metadata()?.file_type().is_symlink() && package_tree(&release, /*destination*/ None, &mut BTreeMap::new())? == digest, "an existing daemon release has different contents; refusing to overwrite it" ); } else { #[cfg(unix)] if !stage.path().join("codex").exists() { std::os::unix::fs::symlink("bin/codex", stage.path().join("codex"))?; } #[cfg(windows)] windows::publish_release(stage.path(), &release).await?; #[cfg(not(windows))] std::fs::rename(stage.path(), &release)?; } let release = release.canonicalize()?; let standalone = home.join("packages/standalone"); let canonical_source = source.canonicalize()?; let follows_latest = mode == InstallMode::Missing && stable && (standalone.join("current").canonicalize().ok().as_deref() != Some(canonical_source.as_path()) || std::fs::read_to_string(standalone.join("auto-update-version")) .ok() .as_deref() == canonical_source.file_name().and_then(|name| name.to_str())); anyhow::ensure!( managed_install::package_root(home) == previous_root && (backend.is_some() || crate::client::probe(&daemon.socket_path).await.is_err()), "daemon package location or socket ownership changed while preparing its package; retry the command" ); #[cfg(windows)] if backend.is_some() { crate::backend::windows::ensure_detached_launch(&release.join(entrypoint))?; } #[cfg(windows)] windows::validate_selection(&root)?; if mode == InstallMode::Replace { let stopped = async { crate::backend::pid_update_loop_backend(daemon.backend_paths(settings)) .stop() .await?; anyhow::ensure!( managed_install::package_root(home) == previous_root && previous_root.join("current").canonicalize().ok() == previous_release, "daemon selection changed while preparing its package; retry the command" ); if let Some(backend) = &backend { if let Err(error) = crate::thread_recovery::discard_pending(daemon) { eprintln!( "warning: failed to clear stale daemon recovery before replacement: {error}" ); } backend .stop_with_grace(settings.shutdown_grace_seconds) .await?; } Ok::<(), anyhow::Error>(()) } .await; if let Err(error) = stopped { if let Err(restore_error) = async { daemon .current_installation()? .ensure_managed_updater(settings) .await } .await { eprintln!( "warning: failed to restore the daemon updater after replacement failed: {restore_error:#}" ); } return Err(error); } } let marker = root.join("auto-update-version"); if follows_latest { let temporary = tempfile::NamedTempFile::new_in(&root)?; std::fs::write(temporary.path(), &name)?; temporary.persist(marker)?; } else if marker.exists() { std::fs::remove_file(marker)?; } if previous_release.as_deref() != Some(release.as_path()) { #[cfg(unix)] { let temporary = tempfile::TempDir::new_in(&root)?; let link = temporary.path().join("current"); std::os::unix::fs::symlink(&release, &link)?; std::fs::rename(link, root.join("current"))?; } #[cfg(windows)] windows::select_release(&root, &release)?; if let Err(error) = crate::release_pruning::record_selection(&root, previous_release.as_deref()) { tracing::warn!(%error, "could not record daemon selection for pruning"); } } if backend.is_some() { let selected = Daemon { pid_file: daemon.pid_file.with_file_name(crate::DAEMON_PID_FILE_NAME), update_pid_file: daemon .update_pid_file .with_file_name(crate::DAEMON_UPDATE_PID_FILE_NAME), managed_codex_bin: root.join("current").join(entrypoint), ..daemon.clone() }; selected.start_managed_backend(settings).await.context( "daemon package selected but could not start; retry with `codex app-server daemon start`", )?; selected.wait_until_ready().await?; } if let Err(error) = crate::release_pruning::prune(daemon, &root, settings).await { tracing::debug!(%error, "skipped daemon release pruning"); } Ok(true) } /// Hash the complete tree and optionally copy those same bytes. Relative file /// links are materialized; escaping links and directory links are rejected. /// Only reuse digests for a private staging directory; source and release /// validation must pass a fresh map. fn package_tree( root: &Path, destination: Option<&Path>, file_digests: &mut BTreeMap, ) -> Result { let canonical_root = root.canonicalize()?; let mut hasher = blake3::Hasher::new(); let mut paths = vec![root.to_path_buf()]; while let Some(path) = paths.pop() { let relative = path.strip_prefix(root)?; // The Unix installer adds this alias outside the package layout. if cfg!(unix) && relative == Path::new("codex") && std::fs::read_link(&path).ok().as_deref() == Some(Path::new("bin/codex")) { continue; } anyhow::ensure!( path.canonicalize()?.starts_with(&canonical_root), "package link escapes its root" ); let metadata = path.metadata()?; hasher.update(relative.as_os_str().as_encoded_bytes()); hasher.update(&[0]); if metadata.is_dir() { anyhow::ensure!( !path.symlink_metadata()?.file_type().is_symlink(), "package contains a directory link" ); hasher.update(b"directory"); if let Some(destination) = destination && !relative.as_os_str().is_empty() { std::fs::create_dir(destination.join(relative))?; } let mut entries = std::fs::read_dir(&path)? .map(|entry| entry.map(|entry| entry.path())) .collect::>>()?; entries.sort(); paths.extend(entries); } else { anyhow::ensure!(metadata.is_file(), "package contains an unsupported file"); let digest = if let Some(digest) = file_digests.get(relative) && destination.is_none() { *digest } else { let bytes = std::fs::read(&path)?; let digest = blake3::hash(&bytes); if let Some(destination) = destination { let target = destination.join(relative); std::fs::write(&target, bytes)?; std::fs::set_permissions(target, metadata.permissions())?; } file_digests.insert(relative.to_path_buf(), digest); digest }; hasher.update(b"file"); hasher.update(digest.as_bytes()); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; hasher.update(&(metadata.permissions().mode() & 0o777).to_le_bytes()); } } } Ok(hasher.finalize().to_hex().to_string()) } fn stable_version(value: &str) -> Option { let version = semver::Version::parse(value).ok()?; (version.pre.is_empty() && version.build.is_empty() && (version.major, version.minor, version.patch) != (0, 0, 0)) .then_some(version) } 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", entrypoint, code_mode_host.as_ref(), if cfg!(windows) { "codex-path/rg.exe" } else { "codex-path/rg" }, ]; if cfg!(windows) { names.extend([ "codex-resources/codex-command-runner.exe", "codex-resources/codex-windows-sandbox-setup.exe", ]); } else if cfg!(target_os = "linux") { names.push("codex-resources/bwrap"); } for name in names { if !root.join(name).is_file() { return Err(anyhow!( "local Codex package is missing {name}; reinstall the CLI or use the standalone installer" )); } #[cfg(unix)] if name != "codex-package.json" { use std::os::unix::fs::PermissionsExt; if std::fs::metadata(root.join(name))?.permissions().mode() & 0o111 == 0 { return Err(anyhow!( "local Codex package file {name} is not executable; reinstall the CLI or use the standalone installer" )); } } } Ok(()) } fn platform_target() -> Result<&'static str> { match (std::env::consts::OS, std::env::consts::ARCH) { ("macos", "aarch64") => Ok("aarch64-apple-darwin"), ("macos", "x86_64") => Ok("x86_64-apple-darwin"), ("linux", "aarch64") if cfg!(target_env = "gnu") => Ok("aarch64-unknown-linux-gnu"), ("linux", "aarch64") => Ok("aarch64-unknown-linux-musl"), ("linux", "x86_64") if cfg!(target_env = "gnu") => Ok("x86_64-unknown-linux-gnu"), ("linux", "x86_64") => Ok("x86_64-unknown-linux-musl"), ("windows", "aarch64") => Ok("aarch64-pc-windows-msvc"), ("windows", "x86_64") => Ok("x86_64-pc-windows-msvc"), (os, arch) => Err(anyhow!("unsupported packaged daemon platform {os}/{arch}")), } } #[cfg(windows)] #[path = "prepare_install_windows.rs"] mod windows; #[cfg(test)] #[path = "prepare_install_tests.rs"] mod tests;