Seed missing daemon installs from complete local CLI packages (#45558)

## Why

Daemon lifecycle commands previously required a standalone managed installation. A complete CLI package can supply the daemon executable and helpers without requiring a separate installer run.

## What changed

- Let `codex app-server daemon start`, `restart`, and `bootstrap` copy the invoking package into `CODEX_HOME/packages/app-server-daemon` when no daemon installation exists.
- Validate the package's platform, required helpers, executable identity, and copied contents before selecting the staged release.
- Preserve existing dedicated and legacy daemon selections, reject broken selections instead of replacing them, and leave the CLI package and selection unchanged.
- Preserve standalone release pins and latest-channel eligibility, and select dedicated releases using Unix symlinks or Windows junctions.

## Testing

Add package preparation tests for complete copies, incomplete packages, broken selections, legacy preservation, and update-channel handling. Add Windows junction creation and retargeting coverage, plus CLI integration tests that launch the copied package through `start`, `restart`, and `bootstrap`.

GitOrigin-RevId: abd2f4f82eae0885434ea30603c2c1c0ca760bef
This commit is contained in:
Eric Traut
2026-09-15 00:18:38 +00:00
committed by copyberry
parent 446b771049
commit 653e5fbb9d
12 changed files with 815 additions and 50 deletions

View File

@@ -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"

2
codex-rs/Cargo.lock generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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

View File

@@ -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<LifecycleOutput> {
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<BootstrapOutput> {
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<RemoteControlReadyOutput> {
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<RemoteControl
ensure_supported_platform()?;
#[cfg(windows)]
backend::windows::ensure_not_elevated()?;
Daemon::from_environment()?.set_remote_control(mode).await
Box::pin(Daemon::from_environment()?.set_remote_control(mode)).await
}
pub async fn run_pid_update_loop(
@@ -381,6 +381,7 @@ impl Daemon {
}
async fn start(&self) -> Result<LifecycleOutput> {
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<BootstrapOutput> {
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"
))
}

View File

@@ -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
}

View File

@@ -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);

View File

@@ -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<String> {
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::<std::io::Result<Vec<_>>>()?;
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<semver::Version> {
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;

View File

@@ -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", &current).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")
);
}

View File

@@ -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, &current)?;
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(&current, &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<u16> = release.as_os_str().encode_wide().collect();
let prefix: Vec<u16> = r"\\?\".encode_utf16().collect();
let release = release.strip_prefix(prefix.as_slice()).unwrap_or(&release);
let substitute: Vec<u8> = 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;

View File

@@ -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());
}

View File

@@ -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")
}