diff --git a/codex-rs/.config/nextest.toml b/codex-rs/.config/nextest.toml index 1204de58d7..1f73dad8e6 100644 --- a/codex-rs/.config/nextest.toml +++ b/codex-rs/.config/nextest.toml @@ -5,6 +5,11 @@ slow-timeout = { period = "30s", terminate-after = 2 } retries = 1 +[[profile.default.overrides]] +# These cases copy a full debug CLI package and launch its new executable. +filter = 'package(codex-cli) & binary(app_server_daemon) & test(packaged_daemon_)' +slow-timeout = { period = "1m", terminate-after = 2 } + [profile.default.junit] path = "junit.xml" diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a278dff942..9c9c1a6ec1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2309,11 +2309,13 @@ dependencies = [ "codex-app-server-protocol", "codex-app-server-transport", "codex-http-client", + "codex-install-context", "codex-uds", "codex-utils-home-dir", "futures", "libc", "pretty_assertions", + "semver", "serde", "serde_json", "tempfile", diff --git a/codex-rs/app-server-daemon/Cargo.toml b/codex-rs/app-server-daemon/Cargo.toml index 3c98b42f68..48e94a0782 100644 --- a/codex-rs/app-server-daemon/Cargo.toml +++ b/codex-rs/app-server-daemon/Cargo.toml @@ -17,12 +17,14 @@ anyhow = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-transport = { workspace = true } codex-http-client = { workspace = true } +codex-install-context = { workspace = true } codex-utils-home-dir = { workspace = true } codex-uds = { workspace = true } futures = { workspace = true } libc = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +semver = { workspace = true } blake3 = { workspace = true } tokio = { workspace = true, features = [ "fs", diff --git a/codex-rs/app-server-daemon/README.md b/codex-rs/app-server-daemon/README.md index 7707b9e9a4..74fb9ff2c0 100644 --- a/codex-rs/app-server-daemon/README.md +++ b/codex-rs/app-server-daemon/README.md @@ -45,7 +45,7 @@ should parse that JSON rather than relying on human-readable text. Lifecycle responses report the resolved backend, socket path, local CLI version, and running app-server version when applicable. -Standalone-managed daemons check for updates after five minutes, then hourly by +Eligible managed daemons check for updates after five minutes, then hourly by default. Edit `CODEX_HOME/app-server-daemon/settings.json` to change this: ```json @@ -59,8 +59,9 @@ enabled state; the next updater wait reads a new interval. The preference does not affect an explicit `codex update` command or `daemon update`. `daemon update` checks the latest stable release once, even with automatic -updates disabled. It requires a Codex installer-owned latest-channel standalone -install. JSON reports `updated`, `noUpdate`, or `unsupported`, with installed +updates disabled. It requires an installer-owned latest-channel package in either +the dedicated daemon root or the retained legacy root. JSON reports `updated`, +`noUpdate`, or `unsupported`, with installed and running versions. The updater owns scheduled and manual installs. A manual update restarts a running managed daemon, so active or queued work may be interrupted. @@ -90,17 +91,29 @@ $codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.c & "$codexHome\packages\standalone\current\bin\codex.exe" app-server daemon bootstrap --remote-control ``` -`bootstrap` requires the standalone managed install. It records the daemon -settings under `CODEX_HOME/app-server-daemon/`, starts app-server as a +`bootstrap` can use any complete CLI package. If no daemon package is installed, +it copies the invoking package into `CODEX_HOME/packages/app-server-daemon` and +prints an installation message without asking for confirmation. Existing daemon +packages are reused, including legacy installations; a broken selection is not +silently replaced. A bare executable cannot supply a new installation. + +It records the daemon settings under `CODEX_HOME/app-server-daemon/`, starts app-server as a pidfile-backed detached process. It launches a detached updater loop when automatic updates are enabled, the installer selected the stable `latest` channel, and the managed binary supports the updater command. ## Installation and update cases -The daemon uses the standalone installer (`install.sh` on Unix, `install.ps1` -on Windows) and its managed binary under `CODEX_HOME/packages/standalone/current`: -`bin/codex` or `bin/codex.exe`, falling back to the legacy flat layout when present. +New daemons use `CODEX_HOME/packages/app-server-daemon/current/bin/codex` +(`codex.exe` on Windows). The package contains the executable and its helpers. +Daemon-only installer updates leave the user's CLI command and shell setup alone. + +Previously launched legacy daemons retain `CODEX_HOME/packages/standalone/current`, +including its flat binary layout when present. Starts and scheduled updates keep +using that location. An explicit production update prepares and validates a +compatible dedicated package before stopping the legacy updater and daemon, +selecting the new package, and restarting only a previously running daemon. +The old CLI package files and selection remain unchanged. | Situation | What starts | Does this daemon fetch new binaries? | Does a running app-server eventually move to a newer binary on its own? | | --- | --- | --- | --- | @@ -108,11 +121,12 @@ on Windows) and its managed binary under `CODEX_HOME/packages/standalone/current | Installer selected an explicit release; `bootstrap` is used | Managed binary only | No; the selected release stays pinned. | No; an explicit restart uses the selected binary. | | Another tool updates the managed binary | A fresh start or explicit restart uses it; a running server is reused. | Yes, when a latest-channel updater is running, on the configured cadence. | An updater that was running through the change compares binary contents on its next successful installer pass and refreshes the server first. | -### Standalone installs +### Managed packages -For installs created by either platform's standalone installer: +For dedicated and retained legacy daemon installations: -- lifecycle commands always use the standalone managed binary path +- lifecycle commands use the selected daemon package, regardless of the invoking + CLI version; they do not implicitly replace an existing package - `bootstrap` is supported - managed `start`, `restart`, and `bootstrap` ensure a single detached pid-backed updater loop only when automatic updates are enabled for a stable latest-channel diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index ee821a3876..6420d93715 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -6,6 +6,7 @@ use backend::windows::try_lock_file; mod client; mod install_lock; mod managed_install; +mod prepare_install; mod remote_control_client; mod settings; mod thread_recovery; @@ -217,23 +218,22 @@ pub async fn run(command: LifecycleCommand) -> Result { if matches!(command, LifecycleCommand::Start | LifecycleCommand::Restart) { backend::windows::ensure_not_elevated()?; } - Daemon::from_environment()?.run(command).await + // Keep daemon package preparation off callers' async stack frames. + Box::pin(Daemon::from_environment()?.run(command)).await } pub async fn bootstrap(options: BootstrapOptions) -> Result { ensure_supported_platform()?; #[cfg(windows)] backend::windows::ensure_not_elevated()?; - Daemon::from_environment()?.bootstrap(options).await + Box::pin(Daemon::from_environment()?.bootstrap(options)).await } pub async fn ensure_remote_control_ready() -> Result { ensure_supported_platform()?; #[cfg(windows)] backend::windows::ensure_not_elevated()?; - Daemon::from_environment()? - .ensure_remote_control_ready() - .await + Box::pin(Daemon::from_environment()?.ensure_remote_control_ready()).await } pub async fn enable_remote_control_on_socket( @@ -261,7 +261,7 @@ pub async fn set_remote_control(mode: RemoteControlMode) -> Result Result { + let mut managed = self.clone(); let settings = self.load_settings().await?; let (status, backend, pid, info) = if let Ok(info) = client::probe(&self.socket_path).await { @@ -402,8 +403,10 @@ impl Daemon { if let Err(err) = thread_recovery::discard_pending(self) { eprintln!("warning: failed to clear stale daemon recovery before start: {err}"); } - self.ensure_managed_codex_bin()?; - let pid = self.start_managed_backend(&settings).await?; + prepare_install::prepare(self, &settings).await?; + managed.managed_codex_bin = self.current_managed_codex_bin()?; + managed.ensure_managed_codex_bin()?; + let pid = managed.start_managed_backend(&settings).await?; ( LifecycleStatus::Started, Some(BackendKind::Pid), @@ -412,11 +415,11 @@ impl Daemon { ) }; if backend.is_some() - && let Err(err) = self.ensure_managed_updater(&settings).await + && let Err(err) = managed.ensure_managed_updater(&settings).await { eprintln!("warning: failed to ensure managed updater after app-server start: {err:#}"); } - Ok(self + Ok(managed .output(status, backend, pid, Some(info.app_server_version)) .await) } @@ -430,13 +433,16 @@ impl Daemon { "app server is running but is not managed by codex app-server daemon" )); } + prepare_install::prepare(self, &settings).await?; + let mut managed = self.clone(); + managed.managed_codex_bin = self.current_managed_codex_bin()?; if !settings.auto_update_enabled { backend::pid_update_loop_backend(self.backend_paths(&settings)) .stop() .await?; } - self.ensure_managed_codex_bin()?; + managed.ensure_managed_codex_bin()?; if let Some(backend) = self.running_backend_instance(&settings).await? { if let Err(err) = thread_recovery::discard_pending(self) { eprintln!("warning: failed to clear stale daemon recovery before restart: {err}"); @@ -446,14 +452,14 @@ impl Daemon { .await?; } - let pid = self.start_managed_backend(&settings).await?; + let pid = managed.start_managed_backend(&settings).await?; let info = self.wait_until_ready().await?; - if let Err(err) = self.ensure_managed_updater(&settings).await { + if let Err(err) = managed.ensure_managed_updater(&settings).await { eprintln!( "warning: failed to ensure managed updater after app-server restart: {err:#}" ); } - Ok(self + Ok(managed .output( LifecycleStatus::Restarted, Some(BackendKind::Pid), @@ -746,8 +752,6 @@ impl Daemon { } async fn bootstrap_locked(&self, options: BootstrapOptions) -> Result { - self.ensure_managed_codex_bin()?; - let mut settings = self.load_settings().await?; settings.remote_control_enabled = options.remote_control_enabled; if client::probe(&self.socket_path).await.is_ok() @@ -757,6 +761,10 @@ impl Daemon { "app server is running but is not managed by codex app-server daemon" )); } + prepare_install::prepare(self, &settings).await?; + let mut managed = self.clone(); + managed.managed_codex_bin = self.current_managed_codex_bin()?; + managed.ensure_managed_codex_bin()?; settings.save(&self.settings_file).await?; backend::pid_update_loop_backend(self.backend_paths(&settings)) @@ -771,17 +779,17 @@ impl Daemon { .await?; } - let backend = backend::pid_backend(self.backend_paths(&settings)); + let backend = backend::pid_backend(managed.backend_paths(&settings)); backend.start().await?; let info = self.wait_until_ready().await?; - let auto_update_enabled = self.ensure_managed_updater(&settings).await?; - let managed_codex_version = self.managed_codex_version_best_effort().await; + let auto_update_enabled = managed.ensure_managed_updater(&settings).await?; + let managed_codex_version = managed.managed_codex_version_best_effort().await; Ok(BootstrapOutput { status: BootstrapStatus::Bootstrapped, backend: BackendKind::Pid, auto_update_enabled, remote_control_enabled: settings.remote_control_enabled, - managed_codex_path: self.managed_codex_bin.clone(), + managed_codex_path: managed.managed_codex_bin, managed_codex_version, socket_path: self.socket_path.clone(), cli_version: env!("CARGO_PKG_VERSION").to_string(), @@ -913,17 +921,8 @@ impl Daemon { } let managed_codex_path = self.managed_codex_bin.display(); - let install_command = if cfg!(windows) { - "irm https://chatgpt.com/codex/install.ps1 | iex" - } else { - "curl -fsSL https://chatgpt.com/codex/install.sh | sh" - }; Err(anyhow!( - "managed standalone Codex install not found at {managed_codex_path}\n\n\ - This command requires the standalone install managed by the Codex installer, because \ - the daemon starts and updates app-server from that fixed path.\n\n\ - Install it with:\n {install_command}\n\n\ - Then rerun the command you just tried." + "daemon executable not found at {managed_codex_path}; repair the existing installation, or run `codex app-server daemon start` to install a missing daemon" )) } diff --git a/codex-rs/app-server-daemon/src/managed_install.rs b/codex-rs/app-server-daemon/src/managed_install.rs index 099c1b71c9..76d9f016d1 100644 --- a/codex-rs/app-server-daemon/src/managed_install.rs +++ b/codex-rs/app-server-daemon/src/managed_install.rs @@ -14,7 +14,7 @@ use tokio::fs; use tokio::process::Command; use tokio::time::timeout; -/// Dedicated daemon packages take precedence over standalone installations. +/// 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 { @@ -52,13 +52,6 @@ pub(crate) fn package_root(codex_home: &Path) -> PathBuf { return codex_home.join("packages").join(package); } } - // Retain CLI installs until the next stage adds dedicated package seeding. - let standalone = codex_home.join("packages/standalone"); - if !matches!(standalone.join("current").symlink_metadata(), - Err(error) if error.kind() == std::io::ErrorKind::NotFound) - { - return standalone; - } dedicated } diff --git a/codex-rs/app-server-daemon/src/managed_install_path_tests.rs b/codex-rs/app-server-daemon/src/managed_install_path_tests.rs index 174c634005..b227c7ebf2 100644 --- a/codex-rs/app-server-daemon/src/managed_install_path_tests.rs +++ b/codex-rs/app-server-daemon/src/managed_install_path_tests.rs @@ -21,7 +21,7 @@ fn discovers_package_and_legacy_installs() { // A CLI install and a previous stop/status operation do not establish ownership. assert_eq!( super::package_root(home.path()), - home.path().join("packages/standalone") + home.path().join("packages/app-server-daemon") ); std::fs::write(state.join("app-server.stderr.log"), b"").unwrap(); assert_eq!(super::managed_codex_bin(home.path()), legacy); diff --git a/codex-rs/app-server-daemon/src/prepare_install.rs b/codex-rs/app-server-daemon/src/prepare_install.rs new file mode 100644 index 0000000000..cd9ad8d97f --- /dev/null +++ b/codex-rs/app-server-daemon/src/prepare_install.rs @@ -0,0 +1,317 @@ +//! Prepares complete local CLI packages for a stopped daemon. Legacy standalone +//! installations are left to their installer; new releases are immutable. + +use std::path::Path; + +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; + +/// 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(); + prepare_from_package( + daemon, + settings, + source.map(|layout| layout.package_dir.as_path()), + &std::env::current_exe()?, + ) + .await +} + +async fn prepare_from_package( + daemon: &Daemon, + settings: &DaemonSettings, + source: Option<&Path>, + running_exe: &Path, +) -> Result<()> { + let home = daemon + .settings_file + .parent() + .and_then(Path::parent) + .context("daemon settings path has no Codex home")?; + let root = managed_install::package_root(home); + anyhow::ensure!( + daemon.managed_codex_bin.starts_with(&root), + "daemon package location changed; retry the command" + ); + if !root.ends_with("app-server-daemon") + || daemon.running_backend_instance(settings).await?.is_some() + || crate::client::probe(&daemon.socket_path).await.is_ok() + { + return Ok(()); + } + 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(()); + } + std::fs::create_dir_all(&root)?; + let _install_lock = acquire_install_lock(&root).await?; + // An older CLI may have launched a legacy daemon while this CLI waited. + anyhow::ensure!( + managed_install::package_root(home) == root, + "daemon package location changed; retry the command" + ); + anyhow::ensure!( + daemon.running_backend_instance(settings).await?.is_none() + && crate::client::probe(&daemon.socket_path).await.is_err(), + "an app server started while preparing the daemon; retry the command" + ); + let selected = managed_install::managed_codex_bin(home); + let missing = !selected.is_file(); + if !missing { + return Ok(()); + } + anyhow::ensure!( + 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 metadata: serde_json::Value = serde_json::from_slice(&manifest_bytes)?; + let entrypoint = if cfg!(windows) { + "bin/codex.exe" + } else { + "bin/codex" + }; + anyhow::ensure!( + metadata["target"] == target && metadata["entrypoint"] == entrypoint, + "the CLI package does not match this platform or executable" + ); + validate_package(source)?; + eprintln!( + "Installing daemon from CLI version {version} into {}...", + root.display() + ); + let stable = stable_version(&version).is_some(); + let running_identity = managed_install::executable_identity(running_exe).await?; + let releases = root.join("releases"); + std::fs::create_dir_all(&releases)?; + let stage = tempfile::Builder::new() + .prefix(".staging.") + .tempdir_in(&releases)?; + let digest = package_tree(source, Some(stage.path()))?; + validate_package(stage.path())?; + let staged_exe = if cfg!(target_os = "macos") && stage.path().join("CodexCLI.app").is_dir() { + stage.path().join("CodexCLI.app/Contents/MacOS/codex") + } else { + stage.path().join(entrypoint) + }; + anyhow::ensure!( + package_tree(source, /*destination*/ None)? == digest + && std::fs::read(stage.path().join("codex-package.json"))? == manifest_bytes + && managed_install::executable_identity(&staged_exe).await? == running_identity, + "the CLI package changed while preparing the daemon or differs from the running executable" + ); + 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 { + 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)? == 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"))?; + } + std::fs::rename(stage.path(), &release)?; + } + let standalone = home.join("packages/standalone"); + let canonical_source = source.canonicalize()?; + let follows_latest = 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) == root + && daemon.running_backend_instance(settings).await?.is_none() + && crate::client::probe(&daemon.socket_path).await.is_err(), + "daemon state changed while preparing its package; retry the command" + ); + 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)?; + } + #[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)?; + Ok(()) +} + +/// Hash the complete tree and optionally copy those same bytes. Relative file +/// links are materialized; escaping links and directory links are rejected. +fn package_tree(root: &Path, destination: Option<&Path>) -> 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 bytes = std::fs::read(&path)?; + hasher.update(b"file"); + hasher.update(blake3::hash(&bytes).as_bytes()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + hasher.update(&(metadata.permissions().mode() & 0o777).to_le_bytes()); + } + if let Some(destination) = destination { + let target = destination.join(relative); + std::fs::write(&target, bytes)?; + std::fs::set_permissions(target, metadata.permissions())?; + } + } + } + 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) -> Result<()> { + let mut names = vec![ + "codex-package.json", + if cfg!(windows) { + "bin/codex.exe" + } else { + "bin/codex" + }, + if cfg!(windows) { + "bin/codex-code-mode-host.exe" + } else { + "bin/codex-code-mode-host" + }, + 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; diff --git a/codex-rs/app-server-daemon/src/prepare_install_tests.rs b/codex-rs/app-server-daemon/src/prepare_install_tests.rs new file mode 100644 index 0000000000..99801a0856 --- /dev/null +++ b/codex-rs/app-server-daemon/src/prepare_install_tests.rs @@ -0,0 +1,223 @@ +//! Covers complete-package staging, conservative selection, and legacy preservation. +#![cfg(unix)] +use super::prepare_from_package; +use super::validate_package; +use crate::settings::DaemonSettings; +use pretty_assertions::assert_eq; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::path::PathBuf; + +fn daemon(home: &std::path::Path) -> crate::Daemon { + let state = home.join("app-server-daemon"); + crate::Daemon { + socket_path: state.join("app-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: crate::managed_install::managed_codex_bin(home), + } +} + +fn package(root: &Path, version: &str) -> PathBuf { + let target = super::platform_target().expect("target"); + for dir in ["bin", "codex-path", "codex-resources/nested"] { + std::fs::create_dir_all(root.join(dir)).expect("package directory"); + } + let bin = root.join("bin/codex"); + std::fs::write(&bin, format!("#!/bin/sh\necho 'codex {version}'\n")).expect("codex executable"); + for file in [ + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/nested/runtime", + ] { + std::fs::write(root.join(file), b"runtime").expect("package file"); + if file != "codex-resources/nested/runtime" { + std::fs::set_permissions(root.join(file), std::fs::Permissions::from_mode(0o755)) + .expect("executable helper"); + } + } + if cfg!(target_os = "linux") { + std::fs::write(root.join("codex-resources/bwrap"), b"runtime").expect("bwrap"); + std::fs::set_permissions( + root.join("codex-resources/bwrap"), + std::fs::Permissions::from_mode(0o755), + ) + .expect("executable bwrap"); + } + std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)) + .expect("executable permission"); + std::fs::write( + root.join("codex-package.json"), + serde_json::json!({ + "version": version, "target": target, "entrypoint": "bin/codex" + }) + .to_string(), + ) + .expect("manifest"); + bin +} + +#[tokio::test] +async fn seeds_full_package() { + let temp = tempfile::TempDir::new().expect("temp"); + let home = temp.path().join("home"); + let daemon = daemon(&home); + let settings = DaemonSettings::default(); + let old = temp.path().join("old"); + let old_bin = package(&old, "0.152.0"); + prepare_from_package(&daemon, &settings, Some(&old), &old_bin) + .await + .expect("seed"); + + let standalone = home.join("packages/app-server-daemon"); + let selected = std::fs::canonicalize(standalone.join("current")).expect("selected"); + assert_eq!( + std::fs::read(selected.join("codex-resources/nested/runtime")).expect("runtime"), + b"runtime" + ); + assert_eq!( + std::fs::read_to_string(standalone.join("auto-update-version")).expect("marker"), + selected.file_name().expect("name").to_string_lossy() + ); + assert!(validate_package(&selected).is_ok()); +} + +#[tokio::test] +async fn incomplete_source_fails_without_selecting_it() { + let temp = tempfile::TempDir::new().expect("temp"); + let source = temp.path().join("package"); + let bin = package(&source, "0.152.0"); + std::fs::remove_file(source.join("bin/codex-code-mode-host")).expect("remove helper"); + let home = temp.path().join("home"); + let error = prepare_from_package( + &daemon(&home), + &DaemonSettings::default(), + Some(&source), + &bin, + ) + .await + .expect_err("incomplete package"); + assert!(error.to_string().contains("bin/codex-code-mode-host")); + assert!(!home.join("packages/app-server-daemon/current").exists()); +} + +#[cfg(target_os = "macos")] +#[tokio::test] +async fn provisioned_macos_bundle_seeds_from_its_running_executable() { + let temp = tempfile::TempDir::new().expect("temp"); + let source = temp.path().join("package"); + let launcher = package(&source, "0.1.0-internal-test.202609091200.1"); + std::fs::write(&launcher, b"#!/bin/sh\necho codex 0.0.0\n").expect("launcher"); + let bundle = source.join("CodexCLI.app/Contents/MacOS/codex"); + std::fs::create_dir_all(bundle.parent().expect("bundle parent")).expect("bundle dir"); + std::fs::write(&bundle, b"provisioned executable").expect("bundle executable"); + let home = temp.path().join("home"); + prepare_from_package( + &daemon(&home), + &DaemonSettings::default(), + Some(&source), + &bundle, + ) + .await + .expect("seed provisioned bundle"); + let selected = std::fs::canonicalize(home.join("packages/app-server-daemon/current")) + .expect("selected release"); + assert_eq!( + std::fs::read(selected.join("CodexCLI.app/Contents/MacOS/codex")) + .expect("bundled executable"), + b"provisioned executable" + ); + assert!( + !home + .join("packages/app-server-daemon/auto-update-version") + .exists() + ); +} + +#[tokio::test] +async fn legacy_selection_is_not_migrated_or_refreshed() { + let temp = tempfile::TempDir::new().unwrap(); + let home = temp.path().join("home"); + let legacy = home.join("packages/standalone"); + let bin = package(&legacy.join("releases/old"), "0.150.0"); + std::os::unix::fs::symlink("releases/old", legacy.join("current")).unwrap(); + let state = home.join("app-server-daemon"); + std::fs::create_dir(&state).unwrap(); + std::fs::write(state.join("app-server.stderr.log"), b"").unwrap(); + let source = temp.path().join("new"); + let new_bin = package(&source, "0.160.0"); + prepare_from_package( + &daemon(&home), + &DaemonSettings::default(), + Some(&source), + &new_bin, + ) + .await + .unwrap(); + assert_eq!( + crate::managed_install::managed_codex_bin(&home) + .canonicalize() + .unwrap(), + bin.canonicalize().unwrap() + ); + assert!(!home.join("packages/app-server-daemon").exists()); +} + +#[tokio::test] +async fn standalone_seed_preserves_explicit_pin_or_latest_channel() { + for follows_latest in [false, true] { + let temp = tempfile::TempDir::new().unwrap(); + let home = temp.path().join("home"); + let standalone = home.join("packages/standalone"); + let source = standalone.join("releases/0.152.0-local-target"); + let bin = package(&source, "0.152.0"); + std::os::unix::fs::symlink(&source, standalone.join("current")).unwrap(); + if follows_latest { + std::fs::write( + standalone.join("auto-update-version"), + "0.152.0-local-target", + ) + .unwrap(); + } + prepare_from_package( + &daemon(&home), + &DaemonSettings::default(), + Some(&source), + &bin, + ) + .await + .unwrap(); + let root = home.join("packages/app-server-daemon"); + let selected = root.join("current").canonicalize().unwrap(); + assert_eq!( + std::fs::read_to_string(root.join("auto-update-version")).ok(), + follows_latest.then(|| selected.file_name().unwrap().to_string_lossy().into_owned()) + ); + } +} + +#[tokio::test] +async fn broken_selection_is_not_a_missing_installation() { + let temp = tempfile::TempDir::new().unwrap(); + let home = temp.path().join("home"); + let source = temp.path().join("source"); + let bin = package(&source, "0.152.0"); + let daemon = daemon(&home); + let current = home.join("packages/app-server-daemon/current"); + std::fs::create_dir_all(current.parent().unwrap()).unwrap(); + std::os::unix::fs::symlink("missing-release", ¤t).unwrap(); + let error = prepare_from_package(&daemon, &DaemonSettings::default(), Some(&source), &bin) + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("repair the existing installation") + ); + assert_eq!( + std::fs::read_link(current).unwrap(), + PathBuf::from("missing-release") + ); +} diff --git a/codex-rs/app-server-daemon/src/prepare_install_windows.rs b/codex-rs/app-server-daemon/src/prepare_install_windows.rs new file mode 100644 index 0000000000..97622bb942 --- /dev/null +++ b/codex-rs/app-server-daemon/src/prepare_install_windows.rs @@ -0,0 +1,87 @@ +//! Selects a Windows managed release by creating or retargeting the installer junction. + +use std::os::windows::ffi::OsStrExt; +use std::os::windows::fs::OpenOptionsExt; +use std::os::windows::io::AsRawHandle; +use std::path::Path; + +use anyhow::Context; +use anyhow::Result; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS; +use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_DELETE; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_READ; +use windows_sys::Win32::Storage::FileSystem::FILE_SHARE_WRITE; +use windows_sys::Win32::System::IO::DeviceIoControl; + +pub(super) fn select_release(root: &Path, release: &Path) -> Result<()> { + let release = release.canonicalize()?; + let current = root.join("current"); + if current.symlink_metadata().is_err() { + let temporary = tempfile::TempDir::new_in(root)?; + let junction = temporary.path().join("current"); + std::fs::create_dir(&junction)?; + retarget_junction(&junction, &release)?; + std::fs::rename(junction, ¤t)?; + return Ok(()); + } + anyhow::ensure!( + current.canonicalize()?.parent() == Some(root.join("releases").canonicalize()?.as_path()), + "refusing to replace a daemon selection outside its releases directory" + ); + retarget_junction(¤t, &release) +} + +fn retarget_junction(current: &Path, release: &Path) -> Result<()> { + // Use the same mount-point reparse operation as the standalone installer. + // Retargeting in place keeps current available to concurrent readers. + const FSCTL_SET_REPARSE_POINT: u32 = 0x0009_00a4; + const IO_REPARSE_TAG_MOUNT_POINT: u32 = 0xa000_0003; + const GENERIC_WRITE: u32 = 0x4000_0000; + let release: Vec = release.as_os_str().encode_wide().collect(); + let prefix: Vec = r"\\?\".encode_utf16().collect(); + let release = release.strip_prefix(prefix.as_slice()).unwrap_or(&release); + let substitute: Vec = r"\??\" + .encode_utf16() + .chain(release.iter().copied()) + .flat_map(u16::to_le_bytes) + .collect(); + u16::try_from(substitute.len() + 20).context("managed release path is too long")?; + let length = substitute.len() as u16; + // REPARSE_DATA_BUFFER: an 8-byte header, then four u16 byte offsets/lengths + // for substitute and print names. The UTF-16 path buffer starts at byte 16; + // both names have a trailing NUL, and the print name is empty. + let mut data = vec![0; substitute.len() + 20]; + data[0..4].copy_from_slice(&IO_REPARSE_TAG_MOUNT_POINT.to_le_bytes()); + data[4..6].copy_from_slice(&(length + 12).to_le_bytes()); + data[10..12].copy_from_slice(&length.to_le_bytes()); + data[12..14].copy_from_slice(&(length + 2).to_le_bytes()); + data[16..16 + substitute.len()].copy_from_slice(&substitute); + let handle = std::fs::OpenOptions::new() + .access_mode(GENERIC_WRITE) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT) + .open(current)?; + let mut returned = 0; + if unsafe { + DeviceIoControl( + handle.as_raw_handle() as isize, + FSCTL_SET_REPARSE_POINT, + data.as_ptr().cast(), + data.len() as u32, + std::ptr::null_mut(), + 0, + &mut returned, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(std::io::Error::last_os_error()) + .context("failed to retarget managed daemon junction"); + } + Ok(()) +} + +#[cfg(test)] +#[path = "prepare_install_windows_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-daemon/src/prepare_install_windows_tests.rs b/codex-rs/app-server-daemon/src/prepare_install_windows_tests.rs new file mode 100644 index 0000000000..9d3b939687 --- /dev/null +++ b/codex-rs/app-server-daemon/src/prepare_install_windows_tests.rs @@ -0,0 +1,19 @@ +//! Verifies Windows selection without requiring an elevated daemon. +use pretty_assertions::assert_eq; + +#[cfg(windows)] +#[test] +fn daemon_junction_can_be_created_and_retargeted_without_cli_links() { + let home = tempfile::TempDir::new().unwrap(); + let root = home.path().join("packages/app-server-daemon"); + for version in ["first", "second"] { + let release = root.join("releases").join(version); + std::fs::create_dir_all(&release).unwrap(); + super::select_release(&root, &release).unwrap(); + assert_eq!( + root.join("current").canonicalize().unwrap(), + release.canonicalize().unwrap() + ); + } + assert!(!home.path().join("packages/standalone").exists()); +} diff --git a/codex-rs/cli/tests/app_server_daemon.rs b/codex-rs/cli/tests/app_server_daemon.rs index bd90b5b59c..4a9b05dda3 100644 --- a/codex-rs/cli/tests/app_server_daemon.rs +++ b/codex-rs/cli/tests/app_server_daemon.rs @@ -323,3 +323,107 @@ fn manual_update_rejects_an_unowned_installation() -> Result<()> { assert!(daemon.pid("app-server-updater.pid").is_err()); Ok(()) } + +fn packaged_daemon_launch(action: &str) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut daemon = TestDaemon::new()?; + let standalone = daemon.home.path().join("packages/standalone"); + let package = if action == "start" { + standalone.join("releases/caller") + } else { + daemon.home.path().join("cli-package") + }; + for directory in ["bin", "codex-path", "codex-resources"] { + std::fs::create_dir_all(package.join(directory))?; + } + std::fs::copy(&daemon.codex, package.join("bin/codex"))?; + daemon.codex = package.join("bin/codex"); + for helper in [ + "bin/codex-code-mode-host", + "codex-path/rg", + "codex-resources/bwrap", + ] { + std::fs::write(package.join(helper), b"runtime fixture")?; + std::fs::set_permissions(package.join(helper), std::fs::Permissions::from_mode(0o755))?; + } + let target = format!( + "{}-{}", + std::env::consts::ARCH, + if cfg!(target_os = "macos") { + "apple-darwin" + } else if cfg!(target_env = "gnu") { + "unknown-linux-gnu" + } else { + "unknown-linux-musl" + } + ); + std::fs::write( + package.join("codex-package.json"), + serde_json::to_vec(&serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), "target": target, "entrypoint": "bin/codex" + }))?, + )?; + if action == "start" { + std::fs::remove_file(standalone.join("current"))?; + std::os::unix::fs::symlink(&package, standalone.join("current"))?; + } + let cli_selection = standalone.join("current").canonicalize()?; + let state = daemon.home.path().join("app-server-daemon"); + std::fs::remove_file(state.join("app-server.stderr.log"))?; + std::fs::write( + state.join("settings.json"), + br#"{"shutdownGraceSeconds":0}"#, + )?; + let cli_before = daemon.codex.canonicalize()?; + let result = daemon + .command() + .args(["app-server", "daemon", action]) + .output()?; + ensure!( + result.status.success(), + "{}", + String::from_utf8_lossy(&result.stderr) + ); + assert!(String::from_utf8_lossy(&result.stderr).contains("Installing daemon from CLI version")); + let output: Value = serde_json::from_slice(&result.stdout)?; + let dedicated = daemon + .home + .path() + .canonicalize()? + .join("packages/app-server-daemon"); + assert_eq!( + output["managedCodexPath"], + dedicated + .join("current/bin/codex") + .to_str() + .context("managed path is not UTF-8")? + ); + assert_eq!( + std::fs::read(dedicated.join("current/codex-path/rg"))?, + b"runtime fixture" + ); + assert_eq!(daemon.codex.canonicalize()?, cli_before); + assert_eq!(standalone.join("current").canonicalize()?, cli_selection); + assert!(state.join("daemon.pid").exists()); + assert!(!state.join("app-server.pid").exists()); + assert!(!state.join("app-server-updater.pid").exists()); + if action == "bootstrap" { + assert_eq!(output["autoUpdateEnabled"], false); + } + Ok(()) +} + +#[test] +fn packaged_daemon_start_seeds_local_package() -> Result<()> { + packaged_daemon_launch("start") +} + +#[test] +fn packaged_daemon_restart_seeds_local_package() -> Result<()> { + packaged_daemon_launch("restart") +} + +#[test] +fn packaged_daemon_bootstrap_seeds_local_package() -> Result<()> { + packaged_daemon_launch("bootstrap") +}