Files
codex/codex-rs/app-server-daemon/src/managed_install.rs
T
Eric Traut fc269b66ad Add explicit daemon package replacement from the CLI (#45580)
## What changed

Add `codex app-server daemon update --from-cli` to copy and pin the invoking CLI's complete package, including downgrades and local builds. Require interactive confirmation or `--yes`, and warn that restarting a running daemon may interrupt work.

Validate and stage the package before stopping the daemon, recheck the selection and running state after confirmation, and restart only a previously running daemon. Migrate legacy daemon installations to the dedicated package directory while preserving the standalone CLI selection. Explicit replacements disable automatic package updates.

## Testing

Add coverage for cancellation, packages changed during confirmation, version pinning, and legacy migration. Extend CLI integration tests to verify noninteractive confirmation requirements, rejection of incomplete packages without stopping the daemon, and successful replacement and restart.

GitOrigin-RevId: 2d971d6f0259aa576a15a8b115ca9d0e89060fdf
2026-09-15 02:35:59 +00:00

222 lines
7.2 KiB
Rust

//! Resolves both package and legacy standalone layouts and compares installed executables.
use std::path::Path;
use std::path::PathBuf;
use std::process::Stdio;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use anyhow::anyhow;
use serde::Deserialize;
use serde::Serialize;
use tokio::fs;
use tokio::process::Command;
use tokio::time::timeout;
/// New daemons own their packages, regardless of how the calling CLI was installed.
/// Preserve legacy launch state, including logs left after a daemon is stopped;
/// settings, installer selections, and lock files alone do not prove a prior launch.
pub(crate) fn package_root(codex_home: &Path) -> PathBuf {
let dedicated = codex_home.join("packages/app-server-daemon");
if !matches!(dedicated.join("current").symlink_metadata(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound)
{
return dedicated;
}
let state = codex_home.join("app-server-daemon");
for (package, artifacts) in [
(
"app-server-daemon",
[
crate::DAEMON_PID_FILE_NAME,
"daemon.stderr.log",
crate::DAEMON_UPDATE_PID_FILE_NAME,
"daemon-updater.stderr.log",
],
),
(
"standalone",
[
crate::LEGACY_PID_FILE_NAME,
"app-server.stderr.log",
crate::LEGACY_UPDATE_PID_FILE_NAME,
"app-server-updater.stderr.log",
],
),
] {
if artifacts.iter().any(|name| {
!matches!(state.join(name).symlink_metadata(),
Err(error) if error.kind() == std::io::ErrorKind::NotFound)
}) {
return codex_home.join("packages").join(package);
}
}
dedicated
}
/// Resolve both packaged and legacy binaries without requiring a valid install.
pub(crate) fn managed_codex_bin(codex_home: &Path) -> PathBuf {
let root = package_root(codex_home);
let current = root.join("current");
let packaged = current.join("bin").join(managed_codex_file_name());
let legacy = current.join(managed_codex_file_name());
if packaged.is_file()
|| !legacy.is_file() && (cfg!(windows) || root.ends_with("app-server-daemon"))
{
packaged
} else {
legacy
}
}
/// Only latest-channel stable releases may run the public latest-version updater.
pub(crate) fn is_stable_standalone_release(codex_home: &Path, codex_bin: &Path) -> bool {
let standalone = package_root(codex_home);
let Ok(releases) = std::fs::canonicalize(standalone.join("releases")) else {
return false;
};
let Ok(release) = std::fs::canonicalize(standalone.join("current")) else {
return false;
};
if release.parent() != Some(releases.as_path()) {
return false;
}
let Some(release_name) = release.file_name().and_then(|name| name.to_str()) else {
return false;
};
// GNU packages can seed the new directory; retain legacy updater eligibility.
if standalone.ends_with("standalone") && release_name.ends_with("-gnu") {
return false;
}
let targets = [
"aarch64-apple-darwin",
"x86_64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"x86_64-unknown-linux-musl",
"aarch64-pc-windows-msvc",
"x86_64-pc-windows-msvc",
];
let Some(version) = targets
.iter()
.find_map(|target| release_name.strip_suffix(&format!("-{target}")))
else {
return false;
};
let components: Vec<_> = version.split('.').collect();
components.len() == 3
&& components.iter().all(|component| {
!component.is_empty() && component.bytes().all(|byte| byte.is_ascii_digit())
})
&& std::fs::read_to_string(standalone.join("auto-update-version"))
.is_ok_and(|selected| selected == release_name)
&& std::fs::canonicalize(codex_bin).is_ok_and(|bin| bin.starts_with(&release))
}
/// Older managed binaries can serve app-server requests without owning an updater.
pub(crate) async fn supports_daemon_update_loop(codex_bin: &Path) -> bool {
supports_daemon_command(codex_bin, &["pid-update-loop", "--help"]).await
}
/// Probe an internal daemon command without running a long-lived process.
pub(crate) async fn supports_daemon_command(codex_bin: &Path, args: &[&str]) -> bool {
let mut command = Command::new(codex_bin);
#[cfg(windows)]
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
timeout(
Duration::from_secs(5),
command
.args(["app-server", "daemon"])
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true)
.status(),
)
.await
.is_ok_and(|result| result.is_ok_and(|status| status.success()))
}
pub(crate) async fn resolved_managed_codex_bin(codex_bin: &Path) -> Result<PathBuf> {
fs::canonicalize(codex_bin).await.with_context(|| {
format!(
"failed to resolve managed Codex binary {}",
codex_bin.display()
)
})
}
pub(crate) async fn managed_codex_version(codex_bin: &Path) -> Result<String> {
let mut command = Command::new(codex_bin);
#[cfg(windows)]
command.creation_flags(windows_sys::Win32::System::Threading::CREATE_NO_WINDOW);
let output = command
.arg("--version")
.kill_on_drop(true)
.output()
.await
.with_context(|| {
format!(
"failed to invoke managed Codex binary {}",
codex_bin.display()
)
})?;
if !output.status.success() {
return Err(anyhow!(
"managed Codex binary {} exited with status {}",
codex_bin.display(),
output.status
));
}
let stdout = String::from_utf8(output.stdout).with_context(|| {
format!(
"managed Codex version was not utf-8: {}",
codex_bin.display()
)
})?;
parse_codex_version(&stdout)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct ExecutableIdentity {
digest: [u8; 32],
}
pub(crate) async fn executable_identity(executable: &Path) -> Result<ExecutableIdentity> {
let bytes = fs::read(executable)
.await
.with_context(|| format!("failed to read executable {}", executable.display()))?;
Ok(executable_identity_from_bytes(&bytes))
}
pub(crate) fn executable_identity_from_bytes(bytes: &[u8]) -> ExecutableIdentity {
ExecutableIdentity {
digest: *blake3::hash(bytes).as_bytes(),
}
}
fn managed_codex_file_name() -> &'static str {
if cfg!(windows) { "codex.exe" } else { "codex" }
}
fn parse_codex_version(output: &str) -> Result<String> {
let version = output
.split_whitespace()
.nth(1)
.filter(|version| !version.is_empty())
.ok_or_else(|| anyhow!("managed Codex version output was malformed"))?;
Ok(version.to_string())
}
#[cfg(test)]
#[path = "managed_install_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "managed_install_path_tests.rs"]
mod path_tests;