mirror of
https://github.com/openai/codex.git
synced 2026-09-15 12:08:01 +00:00
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:
@@ -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
2
codex-rs/Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
317
codex-rs/app-server-daemon/src/prepare_install.rs
Normal file
317
codex-rs/app-server-daemon/src/prepare_install.rs
Normal 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;
|
||||
223
codex-rs/app-server-daemon/src/prepare_install_tests.rs
Normal file
223
codex-rs/app-server-daemon/src/prepare_install_tests.rs
Normal 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", ¤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")
|
||||
);
|
||||
}
|
||||
87
codex-rs/app-server-daemon/src/prepare_install_windows.rs
Normal file
87
codex-rs/app-server-daemon/src/prepare_install_windows.rs
Normal 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, ¤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<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;
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user