Files
codex/codex-rs/app-server-daemon/src/update_loop_tests.rs
Eric Traut 1537497e52 Allow compatible feature overrides when starting the shared daemon (#46529)
## Why

Feature overrides previously forced embedded mode even when they could work with the shared daemon. A running daemon can also have different feature settings from the current invocation, including when the invocation uses defaults.

## What changed

- Allow selected Boolean feature overrides and `suppress_unstable_features_warning` in daemon mode. Keep explicit overrides that disable shared services in embedded mode.
- Pass shared-service feature overrides to newly launched daemons and persist them for restarts and updates without changing a running daemon's settings.
- Check daemon feature settings with `experimentalFeature/list` before attaching. Warn and fall back to embedded mode on mismatches, failed checks, or an incompatible code-mode host fallback policy. Skip this check for the agents overview.
- Resolve worktree configuration before daemon selection and allow worktree sessions to auto-start the daemon.
- Forward `suppress_unstable_features_warning` in thread configuration overrides.

## Testing

Add coverage for override eligibility and precedence, launch-feature persistence and reuse, and TUI fallback warnings for feature and host-policy mismatches. Extend worktree integration coverage to exercise daemon auto-start and warning suppression.

GitOrigin-RevId: 68c316f74843b1dfd8fa39bdb48bd1f649fee57f
2026-09-19 00:14:40 +00:00

968 lines
36 KiB
Rust

use std::sync::Mutex;
#[cfg(unix)]
use std::time::Duration;
use pretty_assertions::assert_eq;
#[cfg(unix)]
use tempfile::TempDir;
use super::INSTALL_URL;
use super::InstallerHttp;
use super::InstallerResponse;
use super::fetch_installer_script;
#[cfg(unix)]
use super::manual_update::run as manual_update_once;
#[cfg(unix)]
use crate::Daemon;
#[cfg(unix)]
use crate::UpdateOutput;
#[cfg(unix)]
use crate::UpdateStatus;
#[cfg(unix)]
use crate::managed_install::executable_identity;
#[cfg(unix)]
use crate::managed_install::executable_identity_from_bytes;
#[tokio::test]
async fn installer_fetch_uses_exact_url_and_preserves_bytes() {
let script = b"#!/bin/sh\nprintf 'update bytes'\n".to_vec();
let http = FakeInstallerHttp::new(InstallerResponse::Success(script.clone()));
assert_eq!(
fetch_installer_script(&http)
.await
.expect("installer fetch should succeed"),
script
);
assert_eq!(http.requested_urls(), vec![INSTALL_URL.to_string()]);
}
#[tokio::test]
async fn installer_fetch_rejects_non_success_status() {
let http = FakeInstallerHttp::new(InstallerResponse::Unsuccessful { status: 503 });
let error = fetch_installer_script(&http)
.await
.expect_err("non-success response should fail");
assert!(error.to_string().contains("503"));
assert_eq!(http.requested_urls(), vec![INSTALL_URL.to_string()]);
}
struct FakeInstallerHttp {
response: InstallerResponse,
requested_urls: Mutex<Vec<String>>,
}
#[cfg(unix)]
#[tokio::test]
async fn explicit_update_migrates_running_and_stopped_installations() {
for (running, local) in [(false, false), (true, false), (false, true), (true, true)] {
let home = TempDir::new().unwrap();
let (legacy, release) = manual_update_daemon(&home);
let root = home.path().join("packages/standalone");
if local {
let package = root.join("releases/local-development");
std::fs::create_dir(&package).unwrap();
std::fs::copy(&legacy.managed_codex_bin, package.join("codex")).unwrap();
std::fs::remove_file(root.join("current")).unwrap();
std::os::unix::fs::symlink(&package, root.join("current")).unwrap();
std::fs::remove_file(root.join("auto-update-version")).unwrap();
}
let previous = root.join("current").canonicalize().unwrap();
let scheduled = FakeInstallerHttp::new(InstallerResponse::Success(
b"# CODEX_INSTALL_IF_LATEST\ntest \"$CODEX_INSTALL_DEFER_SELECTION\" = 0 && test \"$CODEX_INSTALL_DAEMON_ONLY\" = 0\n".to_vec(),
));
super::update_once(
&scheduled,
&legacy,
&executable_identity(&legacy.managed_codex_bin)
.await
.unwrap(),
&mut test_terminate(),
super::UpdateTrigger::Scheduled,
)
.await
.unwrap();
assert_eq!(
scheduled.requested_urls(),
if local {
vec![]
} else {
vec![INSTALL_URL.to_string()]
}
);
assert_eq!(
crate::managed_install::package_root(home.path()),
home.path().join("packages/standalone")
);
let settings = format!(
r#"{{"updater":{{"autoUpdateEnabled":{running}}},"remoteControlEnabled":true}}"#
);
std::fs::write(&legacy.settings_file, &settings).unwrap();
let daemon_settings = legacy.load_settings().await.unwrap();
let old_backend = crate::backend::pid_backend(legacy.backend_paths(&daemon_settings));
let old_updater =
crate::backend::pid_update_loop_backend(legacy.backend_paths(&daemon_settings));
let server = if running {
old_backend.start().await.unwrap();
old_updater.start().await.unwrap();
Some(test_control_server(&legacy, home.path()).await)
} else {
None
};
let legacy_bin = std::fs::read(&legacy.managed_codex_bin).unwrap();
let dedicated = home.path().join("packages/app-server-daemon");
let old_installer = FakeInstallerHttp::new(InstallerResponse::Success(b"exit 99".to_vec()));
assert!(
super::migration::run(&old_installer, &legacy)
.await
.unwrap_err()
.to_string()
.contains("does not support daemon migration")
);
let script = format!(
r#"# CODEX_INSTALL_DEFER_SELECTION
set -eu
test "$CODEX_INSTALL_DEFER_SELECTION" = 1
test "$CODEX_INSTALL_IF_CURRENT" = 0
test "$CODEX_INSTALL_DAEMON_ONLY" = 1
test "$CODEX_INSTALL_IF_LATEST" = 0
root="$CODEX_HOME/packages/app-server-daemon"
mkdir -p "$root/releases/{release}/bin"
printf '#!/bin/sh\nif [ "$1" = --version ]; then echo codex 1.0.0; else exit 2; fi\n' > "$root/releases/{release}/bin/codex"
chmod +x "$root/releases/{release}/bin/codex"
ln -sfn 'releases/{release}' "$root/.migration-current"
printf '{release}' > "$root/auto-update-version"
"#
);
let http = FakeInstallerHttp::new(InstallerResponse::Success(script.as_bytes().to_vec()));
let error = super::migration::run(&http, &legacy).await.unwrap_err();
assert!(
error
.to_string()
.contains("production release does not support daemon migration")
);
assert!(!dedicated.join("current").exists());
assert_eq!(
crate::managed_install::package_root(home.path()),
home.path().join("packages/standalone")
);
assert_eq!(
(
old_backend.is_starting_or_running().await.unwrap(),
old_updater.is_starting_or_running().await.unwrap()
),
(running, running)
);
let compatible = FakeInstallerHttp::new(InstallerResponse::Success(script.replace("else exit 2", "elif [ \"$4\" = --check-package-ownership ] || [ \"$4\" = --help ]; then exit 0; else exec sleep 30").into_bytes()));
let output = super::migration::run(&compatible, &legacy).await.unwrap();
assert_eq!(output, UpdateOutput {
status: UpdateStatus::Updated,
installed_version: Some("1.0.0".to_string()),
running_version: (running).then(|| "1.0.0".to_string()),
managed_codex_path: dedicated.join("current/bin/codex"),
message: "The daemon was updated and moved to its dedicated package. The legacy CLI package was left unchanged.".to_string(),
});
assert_eq!(
crate::managed_install::package_root(home.path()),
dedicated.clone()
);
assert!(!dedicated.join(".migration-current").exists());
assert_eq!(root.join("current").canonicalize().unwrap(), previous);
assert_eq!(
std::fs::read(&legacy.managed_codex_bin).unwrap(),
legacy_bin
);
assert_eq!(
std::fs::read_to_string(&legacy.settings_file).unwrap(),
settings
);
let selected = Daemon {
pid_file: legacy.pid_file.with_file_name(crate::DAEMON_PID_FILE_NAME),
update_pid_file: legacy
.update_pid_file
.with_file_name(crate::DAEMON_UPDATE_PID_FILE_NAME),
managed_codex_bin: dedicated.join("current/bin/codex"),
..legacy.clone()
};
let new_backend = crate::backend::pid_backend(selected.backend_paths(&daemon_settings));
assert_eq!(new_backend.is_starting_or_running().await.unwrap(), running);
assert!(!old_backend.is_starting_or_running().await.unwrap());
assert!(!old_updater.is_starting_or_running().await.unwrap());
let new_updater =
crate::backend::pid_update_loop_backend(selected.backend_paths(&daemon_settings));
assert_eq!(new_updater.is_starting_or_running().await.unwrap(), running);
new_updater.stop().await.unwrap();
new_backend.stop().await.unwrap();
if let Some(server) = server {
server.abort();
}
}
}
impl FakeInstallerHttp {
fn new(response: InstallerResponse) -> Self {
Self {
response,
requested_urls: Mutex::new(Vec::new()),
}
}
fn requested_urls(&self) -> Vec<String> {
self.requested_urls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl InstallerHttp for FakeInstallerHttp {
async fn get(&self, url: &str) -> anyhow::Result<InstallerResponse> {
self.requested_urls
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(url.to_string());
Ok(self.response.clone())
}
}
#[cfg(unix)]
#[tokio::test]
async fn cancelling_installer_stops_children_and_releases_fallback_lock() {
let home = tempfile::TempDir::new().expect("home");
let ready = home.path().join("ready");
let delayed = home.path().join("delayed");
let lock = home.path().join("packages/standalone/install.lock.d");
let script = format!(
"mkdir -p '{lock}'\necho $$ > '{lock}/pid'\n(trap '' TERM; echo ready > '{ready}'; sleep 4; echo late > '{delayed}') &\nwait\n",
lock = lock.display(),
ready = ready.display(),
delayed = delayed.display(),
);
let (cancel, cancelled) = tokio::sync::oneshot::channel();
let ready_for_signal = ready.clone();
let signal_sender = tokio::spawn(async move {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !ready_for_signal.exists() {
assert!(
tokio::time::Instant::now() < deadline,
"installer did not start"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
cancel.send(()).expect("cancel installer");
});
let result = tokio::time::timeout(
Duration::from_secs(10),
super::run_installer_script(
script.as_bytes(),
super::InstallerMode::Update("0.150.0-test"),
&home.path().join("packages/standalone"),
async { cancelled.await.ok() },
),
)
.await
.expect("installer cancellation timed out")
.expect("installer cancellation failed");
signal_sender.await.expect("signal sender");
assert!(matches!(result, super::UpdateLoopControl::Stop));
assert!(!lock.exists());
tokio::time::sleep(Duration::from_secs(3)).await;
assert!(!delayed.exists());
}
#[cfg(unix)]
fn manual_update_daemon(home: &TempDir) -> (Daemon, String) {
use std::os::unix::fs::PermissionsExt;
let target = if cfg!(target_os = "macos") {
format!("{}-apple-darwin", std::env::consts::ARCH)
} else {
format!("{}-unknown-linux-musl", std::env::consts::ARCH)
};
let release = format!("1.0.0-{target}");
let standalone = home.path().join("packages/standalone");
let bin = standalone.join("releases").join(&release).join("codex");
std::fs::create_dir_all(bin.parent().expect("binary parent")).expect("release directory");
std::fs::write(
&bin,
b"#!/bin/sh\nif [ \"$1\" = '--version' ]; then echo codex 1.0.0; else exec sleep 30; fi\n",
)
.expect("managed binary");
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755))
.expect("executable binary");
std::os::unix::fs::symlink(format!("releases/{release}"), standalone.join("current"))
.expect("current release");
std::fs::write(standalone.join("auto-update-version"), &release).expect("latest marker");
let state = home.path().join("app-server-daemon");
std::fs::create_dir(&state).unwrap();
std::fs::write(state.join("app-server.stderr.log"), b"").unwrap();
(
Daemon {
socket_path: home.path().join("app-server-control/server.sock"),
pid_file: state.join("app-server.pid"),
update_pid_file: state.join("app-server-updater.pid"),
operation_lock_file: state.join("daemon.lock"),
settings_file: state.join("settings.json"),
managed_codex_bin: standalone.join("current/codex"),
},
release,
)
}
#[cfg(unix)]
fn test_terminate() -> tokio::signal::unix::Signal {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("install test signal handler")
}
#[cfg(unix)]
#[tokio::test]
async fn manual_request_retries_after_updater_replacement() {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let home = TempDir::new().expect("home");
let (daemon, _) = manual_update_daemon(&home);
let socket_path = daemon.manual_update_socket_path();
codex_uds::prepare_private_socket_directory(socket_path.parent().expect("socket parent"))
.await
.expect("socket directory");
let mut listener = codex_uds::UnixListener::bind(&socket_path)
.await
.expect("old updater socket");
let expected = UpdateOutput {
status: UpdateStatus::NoUpdate,
managed_codex_path: daemon.managed_codex_bin.clone(),
installed_version: None,
running_version: None,
message: "already current".to_string(),
};
let reply = expected.clone();
let server = tokio::spawn(async move {
let mut old = listener.accept().await.expect("first connection");
let mut request = [0; 7];
old.read_exact(&mut request).await.expect("first request");
drop(old);
drop(listener);
tokio::fs::remove_file(&socket_path)
.await
.expect("remove old socket");
let mut successor = codex_uds::UnixListener::bind(&socket_path)
.await
.expect("successor socket");
let mut connection = successor.accept().await.expect("retried connection");
connection
.read_exact(&mut request)
.await
.expect("retried request");
connection
.write_all(&serde_json::to_vec(&Ok::<_, String>(reply)).expect("serialize response"))
.await
.expect("send response");
});
assert_eq!(
super::manual_update::request(&daemon)
.await
.expect("request survives handoff"),
expected
);
server.await.expect("replacement task");
}
#[cfg(unix)]
#[tokio::test]
async fn manual_request_recovers_when_one_shot_updater_exits() {
use tokio::io::AsyncReadExt;
let home = TempDir::new().expect("home");
let (daemon, _) = manual_update_daemon(&home);
let socket_path = daemon.manual_update_socket_path();
codex_uds::prepare_private_socket_directory(socket_path.parent().expect("socket parent"))
.await
.expect("socket directory");
let mut listener = codex_uds::UnixListener::bind(&socket_path)
.await
.expect("one-shot updater socket");
let server = tokio::spawn(async move {
let mut connection = listener.accept().await.expect("request connection");
let mut request = [0; 7];
connection.read_exact(&mut request).await.expect("request");
drop(connection);
drop(listener);
tokio::fs::remove_file(socket_path)
.await
.expect("remove exited updater socket");
});
// Without the selected executable, the startup path reports unsupported. A
// retry that only waits for a successor would time out instead.
std::fs::remove_file(&daemon.managed_codex_bin).expect("remove selected binary");
let result = tokio::time::timeout(
Duration::from_secs(5),
super::manual_update::request(&daemon),
)
.await
.expect("retry should return to normal startup")
.expect("unsupported response");
assert_eq!(result.status, UpdateStatus::Unsupported);
server.await.expect("updater task");
}
#[cfg(unix)]
#[tokio::test]
async fn unsupported_request_preserves_updater_schedule() {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let home = TempDir::new().expect("home");
let (daemon, _) = manual_update_daemon(&home);
let daemon = std::sync::Arc::new(daemon);
let identity = executable_identity(&daemon.managed_codex_bin)
.await
.expect("updater identity");
let socket_path = daemon.manual_update_socket_path();
let http = FakeInstallerHttp::new(InstallerResponse::Success(Vec::new()));
let updater_daemon = std::sync::Arc::clone(&daemon);
let worker = tokio::spawn(async move {
super::run_with_http(
&http,
&updater_daemon,
&identity,
/*restore_release*/ None,
)
.await
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !socket_path.exists() {
assert!(
tokio::time::Instant::now() < deadline,
"updater did not listen"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
// An external installer can pin while this ordinary worker still listens.
// Raw IPC must not gain the manual CLI's authority to restore production.
let marker = super::selected_release(&daemon)
.unwrap()
.0
.join("auto-update-version");
let previous_marker = std::fs::read(&marker).unwrap();
std::fs::remove_file(&marker).unwrap();
let mut pinned = codex_uds::UnixStream::connect(&socket_path).await.unwrap();
pinned.write_all(b"update\n").await.unwrap();
let mut response = Vec::new();
pinned.read_to_end(&mut response).await.unwrap();
let response: Result<UpdateOutput, String> = serde_json::from_slice(&response).unwrap();
assert_eq!(response.unwrap().status, UpdateStatus::Unsupported);
assert!(!marker.exists());
std::fs::write(&marker, previous_marker).unwrap();
std::fs::remove_file(&daemon.managed_codex_bin).expect("remove selected binary");
let mut malformed = codex_uds::UnixStream::connect(&socket_path)
.await
.expect("connect malformed request");
malformed
.write_all(b"upd")
.await
.expect("send partial request");
malformed.shutdown().await.expect("disconnect request");
let mut discarded = Vec::new();
malformed
.read_to_end(&mut discarded)
.await
.expect("rejected request");
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!worker.is_finished(), "malformed request stopped updater");
assert_eq!(
super::manual_update::request(&daemon)
.await
.expect("unsupported response")
.status,
UpdateStatus::Unsupported
);
tokio::time::sleep(Duration::from_millis(100)).await;
assert!(!worker.is_finished(), "unsupported request stopped updater");
worker.abort();
}
#[cfg(unix)]
async fn test_control_server(
daemon: &Daemon,
home: &std::path::Path,
) -> tokio::task::JoinHandle<()> {
use futures::SinkExt;
use futures::StreamExt;
std::fs::create_dir_all(daemon.socket_path.parent().expect("socket parent"))
.expect("socket directory");
let mut listener = codex_uds::UnixListener::bind(&daemon.socket_path)
.await
.expect("control listener");
let codex_home = home.to_path_buf();
tokio::spawn(async move {
loop {
let connection = listener.accept().await.expect("control connection");
let mut websocket = tokio_tungstenite::accept_async(connection)
.await
.expect("websocket handshake");
websocket
.next()
.await
.expect("initialize request")
.expect("frame");
let version = if std::fs::read_to_string(
crate::managed_install::package_root(&codex_home).join("auto-update-version"),
)
.unwrap_or_default()
.starts_with("1.1.0")
{
"1.1.0"
} else {
"1.0.0"
};
websocket.send(tokio_tungstenite::tungstenite::Message::Text(
serde_json::json!({"id": 1, "result": {
"userAgent": format!("codex_app_server_daemon/{version}"),
"codexHome": codex_home, "platformFamily": "unix", "platformOs": std::env::consts::OS,
}}).to_string().into(),
)).await.expect("initialize response");
websocket
.next()
.await
.expect("initialized notification")
.expect("frame");
}
})
}
#[cfg(unix)]
#[tokio::test]
async fn manual_update_restarts_managed_daemon_with_automatic_updates_disabled() {
check_manual_update_restart("standalone").await;
}
#[cfg(unix)]
#[tokio::test]
async fn daemon_start_and_restart_preserve_launch_features() {
for features in [
std::collections::BTreeMap::new(),
std::collections::BTreeMap::from([
("api_key_model_discovery".to_string(), true),
("code_mode_host".to_string(), false),
]),
] {
let home = TempDir::new().unwrap();
let (daemon, _) = manual_update_daemon(&home);
let args_path = home.path().join("launch-args");
std::fs::write(
&daemon.settings_file,
r#"{"featureOverrides":{"auth_elicitation":true},"updater":{"autoUpdateEnabled":false}}"#,
)
.unwrap();
std::fs::write(&daemon.managed_codex_bin, format!(
"#!/bin/sh\nif [ \"$1\" = --version ]; then echo codex 1.0.0; exit; fi\nif [ \"$3\" = --help ]; then exit; fi\nprintf '%s\\n' \"$@\" > '{}'\nexec sleep 30\n",
args_path.display(),
)).unwrap();
let control = async {
let deadline = tokio::time::Instant::now() + Duration::from_secs(/*secs*/ 10);
while !args_path.exists() {
assert!(
tokio::time::Instant::now() < deadline,
"daemon did not launch"
);
tokio::time::sleep(Duration::from_millis(/*millis*/ 20)).await;
}
test_control_server(&daemon, home.path())
.await
.abort_handle()
};
let (started, server) = tokio::join!(daemon.start(&features), control);
assert_eq!(started.unwrap().status, crate::LifecycleStatus::Started);
assert_eq!(
daemon.load_settings().await.unwrap().feature_overrides,
features
);
let expected = if features.is_empty() {
"app-server\n--listen\nunix://\n--managed-daemon\n"
} else {
"app-server\n--listen\nunix://\n-c\nfeatures.api_key_model_discovery=true\n-c\nfeatures.code_mode_host=false\n--managed-daemon\n"
};
assert_eq!(std::fs::read_to_string(&args_path).unwrap(), expected);
let reused = daemon
.start(&std::collections::BTreeMap::from([(
"api_key_model_discovery".to_string(),
false,
)]))
.await
.unwrap();
assert_eq!(reused.status, crate::LifecycleStatus::AlreadyRunning);
assert_eq!(
daemon.load_settings().await.unwrap().feature_overrides,
features
);
assert_eq!(std::fs::read_to_string(&args_path).unwrap(), expected);
std::fs::remove_file(&args_path).unwrap();
let restarted = daemon.restart().await;
let deadline = tokio::time::Instant::now() + Duration::from_secs(/*secs*/ 10);
while std::fs::read_to_string(&args_path).ok().as_deref() != Some(expected)
&& tokio::time::Instant::now() < deadline
{
tokio::time::sleep(Duration::from_millis(/*millis*/ 20)).await;
}
let args = std::fs::read_to_string(args_path);
daemon.stop().await.unwrap();
server.abort();
assert_eq!(restarted.unwrap().status, crate::LifecycleStatus::Restarted);
assert_eq!(args.unwrap(), expected);
}
}
#[cfg(unix)]
#[tokio::test]
async fn manual_update_restarts_local_daemon_with_automatic_updates_disabled() {
check_manual_update_restart("app-server-daemon").await;
}
#[cfg(unix)]
async fn check_manual_update_restart(package_directory: &str) {
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
let local_package = package_directory == "app-server-daemon";
let home = TempDir::new().expect("home");
let (mut daemon, mut release) = manual_update_daemon(&home);
let standalone = home.path().join("packages").join(package_directory);
if local_package {
std::fs::rename(home.path().join("packages/standalone"), &standalone).unwrap();
let local = format!("local-development-{release}");
std::fs::rename(
standalone.join("releases").join(&release),
standalone.join("releases").join(&local),
)
.unwrap();
std::fs::remove_file(standalone.join("current")).unwrap();
std::os::unix::fs::symlink(format!("releases/{local}"), standalone.join("current"))
.unwrap();
std::fs::remove_file(standalone.join("auto-update-version")).unwrap();
daemon.managed_codex_bin = standalone.join("current/codex");
release = local;
}
let daemon = std::sync::Arc::new(daemon);
std::fs::create_dir_all(daemon.settings_file.parent().expect("state directory"))
.expect("state directory");
std::fs::write(
&daemon.settings_file,
r#"{"updater":{"autoUpdateEnabled":false}}"#,
)
.expect("disabled updater");
let server = test_control_server(&daemon, home.path()).await;
let settings = crate::settings::DaemonSettings::default();
let backend = crate::backend::pid_backend(daemon.backend_paths(&settings));
backend.start().await.expect("start daemon");
let current_pid = || {
serde_json::from_slice::<serde_json::Value>(
&std::fs::read(&daemon.pid_file).expect("daemon PID record"),
)
.expect("PID JSON")["pid"]
.as_u64()
.expect("PID")
};
let before = current_pid();
let version = if local_package { "1.0.0" } else { "1.1.0" };
let next = release
.trim_start_matches("local-development-")
.replacen("1.0.0", version, 1);
let guard = if local_package {
"CODEX_INSTALL_IF_CURRENT"
} else {
"CODEX_INSTALL_IF_LATEST"
};
let install_binary = if local_package {
// Same binary and version, but a different package: it must still restart.
format!(
"cp '{root}/releases/{release}/codex' '{root}/releases/{next}/bin/codex'",
root = standalone.display()
)
} else {
format!(
r#"printf '#!/bin/sh\nif [ "$1" = --version ]; then echo codex 1.1.0; else exec sleep 30; fi\n' > '{root}/releases/{next}/bin/codex'"#,
root = standalone.display()
)
};
let ready = home.path().join("installer-ready");
let proceed = home.path().join("installer-proceed");
let script = format!(
"#!/bin/sh\n# CODEX_INSTALL_IF_LATEST CODEX_INSTALL_IF_CURRENT CODEX_INSTALL_DAEMON_ONLY\nif [ \"$CODEX_UPDATE_FROM_RELEASE\" = '{next}' ]; then exit 0; fi\ntest \"${guard}\" = 1 || exit 4\ntest \"$CODEX_UPDATE_FROM_RELEASE\" = '{release}' || exit 5\ntouch '{ready}'\nwhile [ ! -e '{proceed}' ]; do sleep .05; done\nmkdir -p '{root}/releases/{next}/bin'\n{install_binary}\nchmod +x '{root}/releases/{next}/bin/codex'\nln -sfn 'releases/{next}' '{root}/current'\nprintf '{next}' > '{root}/auto-update-version'\n",
root = standalone.display(),
ready = ready.display(),
proceed = proceed.display(),
);
let http = FakeInstallerHttp::new(InstallerResponse::Success(script.into_bytes()));
let updater_daemon = std::sync::Arc::clone(&daemon);
let restore_release = local_package.then(|| release.clone());
let worker = tokio::spawn(async move {
super::run_with_http(
&http,
&updater_daemon,
&executable_identity_from_bytes(b"updater"),
restore_release,
)
.await
});
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while !daemon.manual_update_socket_path().exists() {
assert!(
tokio::time::Instant::now() < deadline,
"updater did not listen"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let request_daemon = std::sync::Arc::clone(&daemon);
let request = tokio::spawn(async move { super::manual_update::request(&request_daemon).await });
while !ready.exists() {
assert!(
tokio::time::Instant::now() < deadline,
"installer did not start"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
let mut queued = codex_uds::UnixStream::connect(&daemon.manual_update_socket_path())
.await
.expect("queue a second update");
queued
.write_all(b"update\n")
.await
.expect("send second update");
std::fs::write(proceed, b"go").expect("release installer");
let output = request
.await
.expect("first request task")
.expect("manual update");
assert_eq!(output.status, UpdateStatus::Updated);
assert_eq!(output.installed_version.as_deref(), Some(version));
assert_eq!(output.running_version.as_deref(), Some(version));
assert_eq!(
output.managed_codex_path,
standalone.join("current/bin/codex")
);
let restarted = current_pid();
assert_ne!(restarted, before);
let mut response = Vec::new();
queued
.read_to_end(&mut response)
.await
.expect("second response");
let second: Result<crate::UpdateOutput, String> =
serde_json::from_slice(&response).expect("valid second response");
assert_eq!(
second.expect("second update").status,
UpdateStatus::NoUpdate
);
assert_eq!(current_pid(), restarted);
tokio::time::timeout(Duration::from_secs(5), worker)
.await
.expect("one-shot updater did not exit")
.expect("updater task")
.expect("updater loop");
let no_op = FakeInstallerHttp::new(InstallerResponse::Success(
b"#!/bin/sh\n# CODEX_INSTALL_IF_LATEST CODEX_INSTALL_DAEMON_ONLY\nexit 0\n".to_vec(),
));
use std::io::Write;
std::fs::OpenOptions::new()
.append(true)
.open(
daemon
.current_managed_codex_bin()
.expect("current executable"),
)
.expect("managed executable")
.write_all(b"\n# same-version replacement\n")
.expect("replace binary bytes");
let output = manual_update_once(
&no_op,
&daemon,
&executable_identity_from_bytes(b"updater"),
&mut test_terminate(),
super::UpdateTrigger::Manual,
)
.await
.expect("retry with stale running binary");
assert_eq!(output.status, UpdateStatus::NoUpdate);
assert_ne!(current_pid(), restarted);
backend.stop().await.expect("stop daemon");
server.abort();
}
#[cfg(windows)]
#[tokio::test]
async fn powershell_installer_is_noninteractive_and_reports_script_failure() {
let valid = FakeInstallerHttp::new(InstallerResponse::Success(
br#"
function Test-Installer {
if ($env:CODEX_NON_INTERACTIVE -ne '1') { throw 'interactive installer' }
if ($env:CODEX_INSTALL_DAEMON_ONLY -ne '1') { throw 'wrong package destination' }
if ($env:CODEX_INSTALL_IF_CURRENT -ne '1' -or $env:CODEX_INSTALL_IF_LATEST -ne '0') { throw 'wrong update guard' }
}
Test-Installer
"#
.to_vec(),
));
let script = super::fetch_installer_script(&valid)
.await
.expect("fetch installer");
super::run_installer_script(
&script,
super::InstallerMode::RestoreProduction("0.150.0-x86_64-pc-windows-msvc"),
std::path::Path::new("packages/app-server-daemon"),
)
.await
.expect("installer succeeds");
let failing = FakeInstallerHttp::new(InstallerResponse::Success(
b"throw 'installer failed'".to_vec(),
));
let script = super::fetch_installer_script(&failing)
.await
.expect("fetch failing installer");
assert!(
super::run_installer_script(
&script,
super::InstallerMode::RestoreProduction("0.150.0-x86_64-pc-windows-msvc"),
std::path::Path::new("packages/app-server-daemon")
)
.await
.is_err()
);
}
#[cfg(unix)]
#[tokio::test]
async fn update_rejects_a_package_root_change_during_download() {
struct ChangingRoot<'a>(&'a std::path::Path);
impl InstallerHttp for ChangingRoot<'_> {
async fn get(&self, _url: &str) -> anyhow::Result<InstallerResponse> {
std::os::unix::fs::symlink(
self.0.join("packages/standalone"),
self.0.join("packages/app-server-daemon"),
)?;
Ok(InstallerResponse::Success(
b"# CODEX_INSTALL_IF_LATEST\nexit 99\n".to_vec(),
))
}
}
let home = TempDir::new().unwrap();
let (daemon, _) = manual_update_daemon(&home);
let identity = executable_identity(&daemon.managed_codex_bin)
.await
.unwrap();
let error = manual_update_once(
&ChangingRoot(home.path()),
&daemon,
&identity,
&mut test_terminate(),
super::UpdateTrigger::Manual,
)
.await
.unwrap_err();
assert!(error.to_string().contains("package root changed"));
}
#[cfg(unix)]
#[tokio::test]
async fn daemon_owned_updates_require_and_request_an_isolated_installer() {
let home = TempDir::new().unwrap();
let (mut daemon, release) = manual_update_daemon(&home);
let root = home.path().join("packages/app-server-daemon");
std::fs::rename(home.path().join("packages/standalone"), &root).unwrap();
daemon.managed_codex_bin = root.join("current/codex");
let identity = executable_identity(&daemon.managed_codex_bin)
.await
.unwrap();
let old = FakeInstallerHttp::new(InstallerResponse::Success(
b"# CODEX_INSTALL_IF_LATEST\nexit 0\n".to_vec(),
));
let error = manual_update_once(
&old,
&daemon,
&identity,
&mut test_terminate(),
super::UpdateTrigger::Manual,
)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("does not support daemon-owned packages")
);
let current = FakeInstallerHttp::new(InstallerResponse::Success(
b"# CODEX_INSTALL_IF_LATEST\ntest \"$CODEX_INSTALL_IF_LATEST\" = 1 && test \"$CODEX_INSTALL_DAEMON_ONLY\" = 1\n".to_vec(),
));
let output = manual_update_once(
&current,
&daemon,
&identity,
&mut test_terminate(),
super::UpdateTrigger::Manual,
)
.await
.unwrap();
assert_eq!(output.status, UpdateStatus::NoUpdate);
assert!(!home.path().join("packages/standalone").exists());
// Reuse the stopped daemon to verify explicit unpinning with either preference.
let marker = root.join("auto-update-version");
let no_op = FakeInstallerHttp::new(InstallerResponse::Success(
b"# CODEX_INSTALL_IF_CURRENT CODEX_INSTALL_DAEMON_ONLY\nexit 0\n".to_vec(),
));
let script = format!(
r#"# CODEX_INSTALL_IF_CURRENT CODEX_INSTALL_DAEMON_ONLY
set -eu
test "$CODEX_INSTALL_IF_CURRENT" = 1
test "$CODEX_INSTALL_IF_LATEST" = 0
test "$CODEX_INSTALL_DAEMON_ONLY" = 1
test "$CODEX_RELEASE" = latest
test "$CODEX_UPDATE_FROM_RELEASE" = '{release}'
printf '%s' '{release}' > '{marker}'
"#,
marker = marker.display()
);
let restore = FakeInstallerHttp::new(InstallerResponse::Success(script.into_bytes()));
for auto_update_enabled in [false, true] {
let settings = format!(r#"{{"updater":{{"autoUpdateEnabled":{auto_update_enabled}}}}}"#);
std::fs::write(&daemon.settings_file, &settings).unwrap();
std::fs::remove_file(&marker).unwrap();
let error = manual_update_once(
&no_op,
&daemon,
&identity,
&mut test_terminate(),
super::UpdateTrigger::RestoreProduction(&release),
)
.await
.unwrap_err();
assert!(
error
.to_string()
.contains("did not select a stable latest release")
);
assert!(!marker.exists());
let restored = manual_update_once(
&restore,
&daemon,
&identity,
&mut test_terminate(),
super::UpdateTrigger::RestoreProduction(&release),
)
.await
.unwrap();
// Restoring eligibility for the same package still reports noUpdate.
assert_eq!(restored, output);
assert!(!daemon.pid_file.exists());
assert!(daemon.is_stable_standalone_release().unwrap());
assert_eq!(
std::fs::read_to_string(&daemon.settings_file).unwrap(),
settings
);
}
}