mirror of
https://github.com/openai/codex.git
synced 2026-05-29 15:30:22 +00:00
## Why Desktop and mobile Codex clients need a machine-readable way to bootstrap and manage `codex app-server` on remote machines reached over SSH. The same flow is also useful for bringing up app-server with `remote_control` enabled on a fresh developer machine and keeping that managed install current without requiring a human session. ## What changed - add the new experimental `codex-app-server-daemon` crate and wire it into `codex app-server daemon` lifecycle commands: `start`, `restart`, `stop`, `version`, and `bootstrap` - add explicit `enable-remote-control` and `disable-remote-control` commands that persist the launch setting and restart a running managed daemon so the change takes effect immediately - emit JSON success responses for daemon commands so remote callers can consume them directly - support a Unix-only pidfile-backed detached backend for lifecycle management - assume the standalone `install.sh` layout for daemon-managed binaries and always launch `CODEX_HOME/packages/standalone/current/codex` - add bootstrap support for the standalone managed install plus a detached hourly updater loop - harden lifecycle management around concurrent operations, pidfile ownership, stale state cleanup, updater ownership, managed-binary preflight, Unix-only rejection, forced shutdown after the graceful window, and updater process-group tracking/cleanup - document the experimental Unix-only support boundary plus the standalone bootstrap/update flow in `codex-rs/app-server-daemon/README.md` ## Verification - `cargo test -p codex-app-server-daemon -p codex-cli` - live pid validation on `cb4`: `bootstrap --remote-control`, `restart`, `version`, `stop` ## Follow-up - Add updater self-refresh so the long-lived `pid-update-loop` can replace its own executable image after installing a newer managed Codex binary.
159 lines
4.5 KiB
Rust
159 lines
4.5 KiB
Rust
use std::time::Duration;
|
|
|
|
use pretty_assertions::assert_eq;
|
|
use tempfile::TempDir;
|
|
|
|
use super::PidBackend;
|
|
use super::PidCommandKind;
|
|
use super::PidFileState;
|
|
use super::PidRecord;
|
|
use super::try_lock_file;
|
|
|
|
#[tokio::test]
|
|
async fn locked_empty_pid_file_is_treated_as_active_reservation() {
|
|
let temp_dir = TempDir::new().expect("temp dir");
|
|
let pid_file = temp_dir.path().join("app-server.pid");
|
|
tokio::fs::write(&pid_file, "")
|
|
.await
|
|
.expect("write pid file");
|
|
let backend = PidBackend::new(
|
|
temp_dir.path().join("codex"),
|
|
pid_file.clone(),
|
|
/*remote_control_enabled*/ false,
|
|
);
|
|
let reservation = tokio::fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.write(true)
|
|
.open(&backend.lock_file)
|
|
.await
|
|
.expect("open pid lock file");
|
|
assert!(try_lock_file(&reservation).expect("lock reservation"));
|
|
|
|
assert_eq!(
|
|
backend.read_pid_file_state().await.expect("read pid"),
|
|
PidFileState::Starting
|
|
);
|
|
assert!(pid_file.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn unlocked_empty_pid_file_is_treated_as_stale_reservation() {
|
|
let temp_dir = TempDir::new().expect("temp dir");
|
|
let pid_file = temp_dir.path().join("app-server.pid");
|
|
tokio::fs::write(&pid_file, "")
|
|
.await
|
|
.expect("write pid file");
|
|
let backend = PidBackend::new(
|
|
temp_dir.path().join("codex"),
|
|
pid_file.clone(),
|
|
/*remote_control_enabled*/ false,
|
|
);
|
|
|
|
assert_eq!(
|
|
backend.read_pid_file_state().await.expect("read pid"),
|
|
PidFileState::Missing
|
|
);
|
|
assert!(!pid_file.exists());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stop_waits_for_live_reservation_to_resolve() {
|
|
let temp_dir = TempDir::new().expect("temp dir");
|
|
let pid_file = temp_dir.path().join("app-server.pid");
|
|
tokio::fs::write(&pid_file, "")
|
|
.await
|
|
.expect("write pid file");
|
|
let backend = PidBackend::new(
|
|
temp_dir.path().join("codex"),
|
|
pid_file.clone(),
|
|
/*remote_control_enabled*/ false,
|
|
);
|
|
let reservation = tokio::fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.write(true)
|
|
.open(&backend.lock_file)
|
|
.await
|
|
.expect("open pid lock file");
|
|
assert!(try_lock_file(&reservation).expect("lock reservation"));
|
|
let cleanup = tokio::spawn(async move {
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
drop(reservation);
|
|
tokio::fs::remove_file(pid_file)
|
|
.await
|
|
.expect("remove pid file");
|
|
});
|
|
|
|
backend.stop().await.expect("stop");
|
|
cleanup.await.expect("cleanup task");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn start_retries_stale_empty_pid_file_under_its_own_lock() {
|
|
let temp_dir = TempDir::new().expect("temp dir");
|
|
let pid_file = temp_dir.path().join("app-server.pid");
|
|
tokio::fs::write(&pid_file, "")
|
|
.await
|
|
.expect("write pid file");
|
|
let backend = PidBackend::new(
|
|
temp_dir.path().join("missing-codex"),
|
|
pid_file,
|
|
/*remote_control_enabled*/ false,
|
|
);
|
|
|
|
let err = backend.start().await.expect_err("start");
|
|
assert!(
|
|
err.to_string()
|
|
.starts_with("failed to spawn detached app-server process using ")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn stale_record_cleanup_preserves_replacement_record() {
|
|
let temp_dir = TempDir::new().expect("temp dir");
|
|
let pid_file = temp_dir.path().join("app-server.pid");
|
|
let backend = PidBackend::new(
|
|
temp_dir.path().join("codex"),
|
|
pid_file.clone(),
|
|
/*remote_control_enabled*/ false,
|
|
);
|
|
let stale = PidRecord {
|
|
pid: 1,
|
|
process_start_time: "old".to_string(),
|
|
};
|
|
let replacement = PidRecord {
|
|
pid: 2,
|
|
process_start_time: "new".to_string(),
|
|
};
|
|
tokio::fs::write(
|
|
&pid_file,
|
|
serde_json::to_vec(&replacement).expect("serialize replacement"),
|
|
)
|
|
.await
|
|
.expect("write replacement pid file");
|
|
|
|
assert_eq!(
|
|
backend
|
|
.refresh_after_stale_record(&stale)
|
|
.await
|
|
.expect("cleanup"),
|
|
PidFileState::Running(replacement)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn update_loop_uses_hidden_app_server_subcommand() {
|
|
let backend = PidBackend {
|
|
codex_bin: "codex".into(),
|
|
pid_file: "updater.pid".into(),
|
|
lock_file: "updater.pid.lock".into(),
|
|
command_kind: PidCommandKind::UpdateLoop,
|
|
};
|
|
|
|
assert_eq!(
|
|
backend.command_args(),
|
|
vec!["app-server", "daemon", "pid-update-loop"]
|
|
);
|
|
}
|