mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
## 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
968 lines
36 KiB
Rust
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(
|
|
¤t,
|
|
&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
|
|
);
|
|
}
|
|
}
|