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
This commit is contained in:
Eric Traut
2026-09-15 02:35:59 +00:00
committed by copyberry
parent 19286b8819
commit fc269b66ad
10 changed files with 625 additions and 79 deletions
+42
View File
@@ -0,0 +1,42 @@
//! Confirmation for copying the invoking CLI package into a daemon installation.
use std::io::IsTerminal;
use anyhow::Result;
use codex_app_server_daemon::InstallRequest;
pub(crate) fn confirm_install(request: &InstallRequest, yes: bool) -> Result<bool> {
eprintln!("{}", describe_install(request));
if yes {
return Ok(true);
}
anyhow::ensure!(
std::io::stdin().is_terminal() && std::io::stderr().is_terminal(),
"daemon installation requires confirmation; rerun with --yes to copy this CLI package"
);
let confirmed = crate::confirm("Copy this package? [y/N]: ")?;
if !confirmed {
eprintln!("Daemon installation cancelled.");
}
Ok(confirmed)
}
fn describe_install(request: &InstallRequest) -> String {
let mut message = format!(
"Replace installed daemon version {} with CLI version {} from {}.\nThe daemon package will be installed in {}.\nThe selected package will be pinned.",
request.installed_version.as_deref().unwrap_or("unknown"),
request.version,
request.source.display(),
request.destination.display()
);
if request.restart_required {
message.push_str(
"\nThe running daemon will restart; active or queued work may be interrupted.",
);
}
message
}
#[cfg(test)]
#[path = "daemon_install_tests.rs"]
mod tests;
+18
View File
@@ -0,0 +1,18 @@
use super::*;
#[test]
fn running_daemon_replacement_prompt() {
let request = InstallRequest {
source: "/cli/package".into(),
version: "0.0.0".into(),
destination: "/home/packages/app-server-daemon".into(),
installed_version: Some("0.152.0".into()),
restart_required: true,
};
insta::assert_snapshot!(describe_install(&request), @r"
Replace installed daemon version 0.152.0 with CLI version 0.0.0 from /cli/package.
The daemon package will be installed in /home/packages/app-server-daemon.
The selected package will be pinned.
The running daemon will restart; active or queued work may be interrupted.
");
}
+30 -5
View File
@@ -57,6 +57,7 @@ static ALLOCATOR: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod app_cmd;
mod cloud_config;
mod daemon_install;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod desktop_app;
mod doctor;
@@ -792,8 +793,15 @@ enum AppServerDaemonSubcommand {
/// Restart the local app server daemon.
Restart,
/// Update the standalone installation and restart the managed daemon (may interrupt work).
Update,
/// Update the daemon package (may interrupt running work).
Update {
/// Copy and pin this CLI package.
#[arg(long)]
from_cli: bool,
/// Confirm replacing the daemon package without an interactive prompt.
#[arg(short = 'y', long, requires = "from_cli")]
yes: bool,
},
/// Enable remote control for future starts and a currently running managed daemon.
EnableRemoteControl,
@@ -1396,6 +1404,18 @@ async fn cli_main(
AppServerDaemonSubcommand::Restart => {
print_app_server_daemon_output(AppServerLifecycleCommand::Restart).await?;
}
AppServerDaemonSubcommand::Update {
from_cli: true,
yes,
} => {
if let Some(output) = codex_app_server_daemon::update_from_cli(|request| {
daemon_install::confirm_install(request, yes)
})
.await?
{
println!("{}", serde_json::to_string(&output)?);
}
}
AppServerDaemonSubcommand::EnableRemoteControl => {
print_app_server_remote_control_output(AppServerRemoteControlMode::Enabled)
.await?;
@@ -1415,7 +1435,9 @@ async fn cli_main(
AppServerDaemonSubcommand::PidUpdateLoop {
check_package_ownership: true,
} => return Ok(()),
AppServerDaemonSubcommand::Update
AppServerDaemonSubcommand::Update {
from_cli: false, ..
}
| AppServerDaemonSubcommand::PidUpdateLoop {
check_package_ownership: false,
} => {
@@ -1428,7 +1450,10 @@ async fn cli_main(
.await
.map_err(anyhow::Error::from);
let http_client_factory = updater_http_client_factory(config);
if matches!(daemon_cli.subcommand, AppServerDaemonSubcommand::Update) {
if matches!(
daemon_cli.subcommand,
AppServerDaemonSubcommand::Update { .. }
) {
let output =
codex_app_server_daemon::update(http_client_factory).await?;
println!("{}", serde_json::to_string(&output)?);
@@ -2647,7 +2672,7 @@ fn app_server_subcommand_name(subcommand: Option<&AppServerSubcommand>) -> &'sta
AppServerDaemonSubcommand::Bootstrap(_) => "app-server daemon bootstrap",
AppServerDaemonSubcommand::Start => "app-server daemon start",
AppServerDaemonSubcommand::Restart => "app-server daemon restart",
AppServerDaemonSubcommand::Update => "app-server daemon update",
AppServerDaemonSubcommand::Update { .. } => "app-server daemon update",
AppServerDaemonSubcommand::EnableRemoteControl => {
"app-server daemon enable-remote-control"
}