From b7f710273e14ef2d79d510a06385c2a67285585a Mon Sep 17 00:00:00 2001 From: Eric Traut Date: Wed, 2 Sep 2026 23:45:43 +0000 Subject: [PATCH] Support the app-server daemon on Windows (#42405) ## Why The managed app-server daemon and automatic TUI attachment were limited to Unix, so Windows users could not share a background server across Codex sessions. ## What changed - Enable daemon lifecycle commands, `codex agents` startup, and AF_UNIX daemon discovery on Windows. - Require Windows daemons to start non-elevated and verify the discovered socket directory and peer before connecting. - Preflight detached process support, preserve path-based environment settings, and keep daemon processes out of the launching project's working directory. - Fall back to an embedded server when an implicitly discovered daemon cannot connect, while preserving errors for explicit remote endpoints and bypassing daemon reuse when `CODEX_EXEC_SERVER_URL` is set. ## Testing Add coverage for Windows launch restrictions and socket validation, implicit connection fallback, executor selection, daemon discovery, and the agents dashboard startup result. GitOrigin-RevId: e7fd63cf8690bcca8b5155266416f31f2f792aa2 --- codex-rs/Cargo.lock | 1 + codex-rs/app-server-client/src/remote.rs | 35 +++- codex-rs/app-server-daemon/README.md | 46 +++-- .../src/backend/pid_start.rs | 57 +++++++ .../src/backend/pid_tests.rs | 42 ++++- .../app-server-daemon/src/backend/windows.rs | 77 +++++++++ .../src/backend/windows_tests.rs | 40 +++++ codex-rs/app-server-daemon/src/lib.rs | 33 +++- codex-rs/app-server/README.md | 6 +- codex-rs/cli/src/main.rs | 4 +- codex-rs/cli/tests/app_server.rs | 2 +- codex-rs/tui/Cargo.toml | 1 + codex-rs/tui/src/app/agents_overview.rs | 24 ++- codex-rs/tui/src/app/agents_overview_tests.rs | 43 ++++- codex-rs/tui/src/app/event_dispatch.rs | 4 +- codex-rs/tui/src/app/reconnect.rs | 13 +- codex-rs/tui/src/app/tests.rs | 2 +- codex-rs/tui/src/app_event.rs | 4 +- codex-rs/tui/src/app_server_connection.rs | 48 ++++++ codex-rs/tui/src/daemon_startup_tests.rs | 85 +++++++++ codex-rs/tui/src/lib.rs | 161 +++++++++++++----- codex-rs/tui/src/session_archive_commands.rs | 9 +- ...ew__tests__agents_daemon_start_failed.snap | 6 + ...verview__tests__agents_daemon_started.snap | 6 + codex-rs/tui/src/startup_orchestration.rs | 2 + 25 files changed, 659 insertions(+), 92 deletions(-) create mode 100644 codex-rs/tui/src/app_server_connection.rs create mode 100644 codex-rs/tui/src/daemon_startup_tests.rs create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_start_failed.snap create mode 100644 codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_started.snap diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c96ce605af..555ce9ce28 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4543,6 +4543,7 @@ dependencies = [ "codex-shell-command", "codex-state", "codex-terminal-detection", + "codex-uds", "codex-utils-absolute-path", "codex-utils-approval-presets", "codex-utils-cargo-bin", diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index ea7311f948..8a31b266b4 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -169,8 +169,34 @@ pub struct RemoteAppServerRequestHandle { command_tx: mpsc::Sender, } +enum SocketPeerPolicy { + ExplicitEndpoint, + #[cfg(windows)] + NonElevatedCurrentUser, +} + impl RemoteAppServerClient { pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult { + Self::connect_with_policy(args, SocketPeerPolicy::ExplicitEndpoint).await + } + + /// Connects to an implicitly discovered Windows daemon, verifying its peer + /// token before the WebSocket handshake or any session requests. + #[cfg(windows)] + pub async fn connect_local_daemon(args: RemoteAppServerConnectArgs) -> IoResult { + if !matches!(args.endpoint, RemoteAppServerEndpoint::UnixSocket { .. }) { + return Err(IoError::new( + ErrorKind::InvalidInput, + "local daemon requires a Unix socket", + )); + } + Self::connect_with_policy(args, SocketPeerPolicy::NonElevatedCurrentUser).await + } + + async fn connect_with_policy( + args: RemoteAppServerConnectArgs, + peer_policy: SocketPeerPolicy, + ) -> IoResult { let channel_capacity = args.channel_capacity.max(1); let initialize_params = args.initialize_params(); match args.endpoint { @@ -184,7 +210,8 @@ impl RemoteAppServerClient { .await } RemoteAppServerEndpoint::UnixSocket { socket_path } => { - let (endpoint, stream) = connect_unix_socket_endpoint(socket_path).await?; + let (endpoint, stream) = + connect_unix_socket_endpoint(socket_path, peer_policy).await?; Self::connect_with_stream(channel_capacity, endpoint, stream, initialize_params) .await } @@ -757,6 +784,7 @@ async fn connect_websocket_endpoint( async fn connect_unix_socket_endpoint( socket_path: AbsolutePathBuf, + peer_policy: SocketPeerPolicy, ) -> IoResult<(String, WebSocketStream)> { let endpoint = format!("unix://{}", socket_path.display()); let request = UDS_WEBSOCKET_HANDSHAKE_URL @@ -780,6 +808,11 @@ async fn connect_unix_socket_endpoint( "failed to connect to remote app server at `{endpoint}`: {err}" )) })?; + match peer_policy { + SocketPeerPolicy::ExplicitEndpoint => {} + #[cfg(windows)] + SocketPeerPolicy::NonElevatedCurrentUser => stream.ensure_non_elevated_peer()?, + } let websocket_config = remote_websocket_config(); let stream = timeout( CONNECT_TIMEOUT, diff --git a/codex-rs/app-server-daemon/README.md b/codex-rs/app-server-daemon/README.md index ac512a8037..5417366519 100644 --- a/codex-rs/app-server-daemon/README.md +++ b/codex-rs/app-server-daemon/README.md @@ -10,9 +10,22 @@ machines that should expose app-server with `remote_control` enabled. ## Platform support -The current daemon implementation is Unix-only. It uses pidfile-backed -daemonization plus Unix process and file-locking primitives, and does not yet -support Windows lifecycle management. +The daemon supports Linux, macOS, and Windows using platform-specific process +and file-locking primitives. Windows startup requires a non-elevated terminal +whose host permits detached child processes. + +Windows automatic attachment requires the canonical socket address to fit the +108-byte AF_UNIX limit (including its terminator). A short junction alias whose +resolved address exceeds that limit falls back to the embedded server. Use a +shorter `CODEX_HOME` to share the daemon; discovery does not trust a mutable alias. + +Shared clients use the environment inherited when the daemon started. Opening a +new terminal or clearing variables there does not clear the running daemon's +environment; per-client environment isolation is not provided. +An invocation that sets `CODEX_EXEC_SERVER_URL` skips implicit daemon attachment +so its executor selection is preserved. If an implicitly discovered daemon cannot +initialize the connection, the TUI starts an embedded server instead. Explicit +`--remote` endpoints remain authoritative and report connection failures. ## Commands @@ -33,36 +46,45 @@ running app-server version when applicable. ## Bootstrap flow -For a new remote machine: +For a new Linux or macOS machine: ```sh curl -fsSL https://chatgpt.com/codex/install.sh | sh $HOME/.codex/packages/standalone/current/codex app-server daemon bootstrap --remote-control ``` +On Windows, use a non-elevated PowerShell terminal whose host allows breakaway: + +```powershell +irm https://chatgpt.com/codex/install.ps1 | iex +$codexHome = if ($env:CODEX_HOME) { $env:CODEX_HOME } else { Join-Path $HOME '.codex' } +& "$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 pidfile-backed detached process, and launches a detached updater loop. ## Installation and update cases -The daemon assumes Codex is installed through `install.sh` and always launches -the standalone managed binary under `CODEX_HOME`. +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. | Situation | What starts | Does this daemon fetch new binaries? | Does a running app-server eventually move to a newer binary on its own? | | --- | --- | --- | --- | -| `install.sh` has run, but only `start` is used | `start` uses `CODEX_HOME/packages/standalone/current/codex` | No | No. The managed path is used when starting or restarting, but no updater is installed. | -| `install.sh` has run, then `bootstrap` is used | The pidfile backend uses `CODEX_HOME/packages/standalone/current/codex` | Yes. Bootstrap launches a detached updater loop that runs `install.sh` hourly. | Yes, while that updater process is alive and app-server is already running. After a successful fetch, the updater restarts app-server with the refreshed binary and only then replaces its own process image. | -| Some other tool updates the managed binary path | The next fresh start or restart uses the updated file at that path | Only if `bootstrap` is active, because the updater still runs `install.sh` on its normal cadence. | Without `bootstrap`, no. With `bootstrap`, the next successful updater pass compares the managed binary contents after `install.sh` runs; if app-server is running and they differ from the updater's current image, it refreshes app-server first and then itself. | +| Installer has run; only `start` is used | Managed binary | No | No; explicit restart is required. | +| Installer has run; `bootstrap` is used | Managed binary and detached updater | Yes; the platform's installer runs hourly. | Yes; after a successful update, a running app-server restarts with the new binary before the updater replaces itself. | +| Another tool updates the managed binary | Next start or restart uses it | Only with `bootstrap`, on its normal cadence. | With `bootstrap`, the next successful installer pass compares binary contents and refreshes a running app-server before the updater. | ### Standalone installs -For installs created by `install.sh`: +For installs created by either platform's standalone installer: - lifecycle commands always use the standalone managed binary path - `bootstrap` is supported - `bootstrap` starts a detached pid-backed updater loop that fetches via - `install.sh` + the platform's installer - after a successful refresh, if app-server is running and the managed binary contents changed, the updater restarts app-server with that binary first and only then replaces its own process image @@ -77,7 +99,7 @@ other tool updates the managed binary path: - without `bootstrap`, a currently running app-server remains on the old executable image until an explicit `restart` - with `bootstrap`, the detached updater loop notices the changed managed - binary on its next successful scheduled pass after running `install.sh`; if + binary on its next successful scheduled installer pass; if app-server is running, it refreshes app-server first and then refreshes itself once that replacement starts successfully diff --git a/codex-rs/app-server-daemon/src/backend/pid_start.rs b/codex-rs/app-server-daemon/src/backend/pid_start.rs index 0f0181b178..5c283331bc 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_start.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_start.rs @@ -16,6 +16,8 @@ use tokio::process::Command; impl PidBackend { pub(super) async fn start_inner(&self, replacement: Option) -> Result> { + #[cfg(windows)] + crate::backend::windows::ensure_not_elevated()?; if let Some(parent) = self.pid_file.parent() { codex_uds::prepare_private_socket_directory(parent) .await @@ -107,6 +109,61 @@ impl PidBackend { { use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB; use windows_sys::Win32::System::Threading::DETACHED_PROCESS; + // Preserve process-scoped paths before changing cwd; CA names match CUSTOM_CA_ENV_KEYS. + for name in [ + "CODEX_HOME", + "CODEX_SQLITE_HOME", + "CODEX_CA_CERTIFICATE", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "NODE_EXTRA_CA_CERTS", + "GIT_SSL_CAINFO", + "CARGO_HTTP_CAINFO", + "PIP_CERT", + "BUNDLE_SSL_CA_CERT", + "npm_config_cafile", + "AWS_CONFIG_FILE", + "AWS_SHARED_CREDENTIALS_FILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE", + ] { + let Some(mut value) = std::env::var_os(name) else { + continue; + }; + if matches!(name, "CODEX_SQLITE_HOME" | "npm_config_cafile") { + value = value.to_str().unwrap_or_default().trim().into(); + } + // These consumers expand `~` independently of the working directory. + let expands_home = if name == "npm_config_cafile" { + value + .to_str() + .is_some_and(|path| path.starts_with("~/") || path.starts_with("~\\")) + } else { + matches!( + name, + "CODEX_SQLITE_HOME" | "AWS_CONFIG_FILE" | "AWS_SHARED_CREDENTIALS_FILE" + ) && std::path::Path::new(&value).starts_with("~") + }; + if value.is_empty() || expands_home { + continue; + } + command.env(name, std::path::absolute(value)?); + } + if let Some(value) = std::env::var_os("SSL_CERT_DIR") { + let paths = std::env::split_paths(&value) + .filter(|path| !path.as_os_str().is_empty()) + .map(std::path::absolute) + .collect::>>()?; + command.env("SSL_CERT_DIR", std::env::join_paths(paths)?); + } + // A Windows process pins its working directory for its lifetime. + // Keep both managed children out of the launching project's directory. + command.current_dir( + self.pid_file + .parent() + .context("daemon pid path has no parent")?, + ); // Never retry inside the parent's Job Object: that would report a // successful launch that dies when the terminal/SSH session closes. command.creation_flags(DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB); diff --git a/codex-rs/app-server-daemon/src/backend/pid_tests.rs b/codex-rs/app-server-daemon/src/backend/pid_tests.rs index ce94d75705..0fbf843e7e 100644 --- a/codex-rs/app-server-daemon/src/backend/pid_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/pid_tests.rs @@ -18,6 +18,27 @@ use super::read_stderr_log_tail; use super::stderr_log_file_for_pid_file; use super::try_lock_file; +#[cfg(windows)] +fn is_elevated_test_process() -> anyhow::Result { + let output = std::process::Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-Command", + "[Security.Principal.WindowsPrincipal]::new([Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)", + ]) + .output()?; + anyhow::ensure!( + output.status.success(), + "administrator membership query failed" + ); + match String::from_utf8(output.stdout)?.trim() { + "True" => Ok(true), + "False" => Ok(false), + other => anyhow::bail!("unexpected administrator membership: {other}"), + } +} + #[tokio::test] async fn locked_empty_pid_file_is_treated_as_active_reservation() { let temp_dir = TempDir::new().expect("temp dir"); @@ -116,6 +137,16 @@ async fn start_retries_stale_empty_pid_file_under_its_own_lock() { ); let err = backend.start().await.expect_err("start"); + #[cfg(windows)] + if is_elevated_test_process().expect("query administrator membership") { + assert!(err.to_string().contains("non-elevated terminal")); + assert_eq!( + tokio::fs::read(&backend.pid_file).await.unwrap(), + Vec::::new() + ); + assert!(!backend.lock_file.exists()); + return; + } assert!( err.to_string() .starts_with("failed to spawn detached app-server process using ") @@ -287,6 +318,7 @@ async fn stale_creation_time_never_stops_reused_pid() { #[cfg(windows)] #[tokio::test] async fn failed_updater_handoff_preserves_predecessor_record() { + let elevated = is_elevated_test_process().expect("query administrator membership"); let temp = TempDir::new().expect("temp"); let state_dir = temp.path().join("state"); codex_uds::prepare_private_socket_directory(&state_dir) @@ -313,9 +345,13 @@ async fn failed_updater_handoff_preserves_predecessor_record() { .await .unwrap(); let error = backend.replace_current_updater().await.unwrap_err(); - // Windows may reject breakaway before reporting the missing executable. - // Both must reach process I/O rather than rejecting stale ownership. - assert!(error.root_cause().is::(), "{error:#}"); + if elevated { + assert!(error.to_string().contains("non-elevated terminal")); + } else { + // Windows may reject breakaway before reporting the missing executable. + // Both must reach process I/O rather than rejecting stale ownership. + assert!(error.root_cause().is::(), "{error:#}"); + } assert_eq!( backend.read_pid_file_state().await.unwrap(), PidFileState::Running(record) diff --git a/codex-rs/app-server-daemon/src/backend/windows.rs b/codex-rs/app-server-daemon/src/backend/windows.rs index f93e4bb20d..1d2b91eaf5 100644 --- a/codex-rs/app-server-daemon/src/backend/windows.rs +++ b/codex-rs/app-server-daemon/src/backend/windows.rs @@ -1,10 +1,15 @@ //! Windows process identity and file locks. Keep a process handle across shutdown //! so PID reuse can never redirect forced termination to a different process. +//! Managed servers must not elevate ordinary clients sharing the account's socket. use std::io; use std::os::windows::io::AsRawHandle; use std::os::windows::io::FromRawHandle; use std::os::windows::io::OwnedHandle; +use std::os::windows::process::CommandExt; +use std::path::Path; +use std::process::Command; +use std::process::Stdio; use anyhow::Context; use anyhow::Result; @@ -13,6 +18,10 @@ use windows_sys::Win32::Foundation::ERROR_LOCK_VIOLATION; use windows_sys::Win32::Foundation::FILETIME; use windows_sys::Win32::Foundation::WAIT_OBJECT_0; use windows_sys::Win32::Foundation::WAIT_TIMEOUT; +use windows_sys::Win32::Security::GetTokenInformation; +use windows_sys::Win32::Security::TOKEN_ELEVATION; +use windows_sys::Win32::Security::TOKEN_QUERY; +use windows_sys::Win32::Security::TokenElevation; use windows_sys::Win32::Storage::FileSystem::LOCKFILE_EXCLUSIVE_LOCK; use windows_sys::Win32::Storage::FileSystem::LOCKFILE_FAIL_IMMEDIATELY; use windows_sys::Win32::Storage::FileSystem::LockFileEx; @@ -24,10 +33,14 @@ use windows_sys::Win32::System::JobObjects::JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; use windows_sys::Win32::System::JobObjects::JOBOBJECT_EXTENDED_LIMIT_INFORMATION; use windows_sys::Win32::System::JobObjects::JobObjectExtendedLimitInformation; use windows_sys::Win32::System::JobObjects::SetInformationJobObject; +use windows_sys::Win32::System::Threading::CREATE_BREAKAWAY_FROM_JOB; +use windows_sys::Win32::System::Threading::CREATE_SUSPENDED; +use windows_sys::Win32::System::Threading::DETACHED_PROCESS; use windows_sys::Win32::System::Threading::GetCurrentProcess; use windows_sys::Win32::System::Threading::GetProcessId; use windows_sys::Win32::System::Threading::GetProcessTimes; use windows_sys::Win32::System::Threading::OpenProcess; +use windows_sys::Win32::System::Threading::OpenProcessToken; use windows_sys::Win32::System::Threading::PROCESS_ACCESS_RIGHTS; use windows_sys::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; use windows_sys::Win32::System::Threading::PROCESS_SYNCHRONIZE; @@ -35,6 +48,70 @@ use windows_sys::Win32::System::Threading::PROCESS_TERMINATE; use windows_sys::Win32::System::Threading::TerminateProcess; use windows_sys::Win32::System::Threading::WaitForSingleObject; +pub(crate) fn ensure_not_elevated() -> Result<()> { + let mut token = 0; + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()).context("failed to query daemon launcher token"); + } + let token = unsafe { OwnedHandle::from_raw_handle(token as _) }; + let mut elevation: TOKEN_ELEVATION = unsafe { std::mem::zeroed() }; + let mut returned = 0; + if unsafe { + GetTokenInformation( + token.as_raw_handle() as _, + TokenElevation, + (&mut elevation as *mut TOKEN_ELEVATION).cast(), + std::mem::size_of::() as u32, + &mut returned, + ) + } == 0 + { + return Err(io::Error::last_os_error()) + .context("failed to query daemon launcher elevation"); + } + anyhow::ensure!( + elevation.TokenIsElevated == 0, + "start the Windows daemon from a non-elevated terminal; shared clients must not inherit administrator privileges" + ); + Ok(()) +} + +// Probe the actual child association: escaping an inner job can leave an outer +// job attached. Suspend the image so no application code runs before cleanup. +pub(crate) fn ensure_detached_launch(executable: &Path) -> Result<()> { + let mut child = Command::new(executable) + .creation_flags(CREATE_SUSPENDED | DETACHED_PROCESS | CREATE_BREAKAWAY_FROM_JOB) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .context("cannot launch detached daemon; existing daemon was not stopped")?; + let mut in_job = 0; + let result = if unsafe { + IsProcessInJob( + child.as_raw_handle() as _, + /*jobhandle*/ 0, + &mut in_job, + ) + } == 0 + { + Err(io::Error::last_os_error()).context("failed to verify daemon launch capability") + } else if in_job != 0 { + Err(anyhow::anyhow!( + "host Job Object prevents daemon detachment; start from a host that allows breakaway" + )) + } else { + Ok(()) + }; + child + .kill() + .context("failed to terminate suspended launch probe")?; + child + .wait() + .context("failed to reap suspended launch probe")?; + result +} + pub(super) struct Process(OwnedHandle); impl Process { diff --git a/codex-rs/app-server-daemon/src/backend/windows_tests.rs b/codex-rs/app-server-daemon/src/backend/windows_tests.rs index 840ea887d1..7ce59b332b 100644 --- a/codex-rs/app-server-daemon/src/backend/windows_tests.rs +++ b/codex-rs/app-server-daemon/src/backend/windows_tests.rs @@ -5,6 +5,46 @@ use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; use windows_sys::Win32::System::Threading::CREATE_NO_WINDOW; use windows_sys::Win32::System::Threading::TerminateProcess; +#[test] +fn detached_launch_preflight_rejects_restrictive_job() { + const CHILD: &str = "CODEX_TEST_RESTRICTIVE_LAUNCH_JOB"; + let executable = std::env::current_exe().expect("test executable"); + if std::env::var_os(CHILD).is_some() { + let job = unsafe { super::CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + assert_ne!(job, 0); + let job = unsafe { + ::from_raw_handle(job as _) + }; + assert_ne!( + unsafe { + super::AssignProcessToJobObject( + job.as_raw_handle() as _, + super::GetCurrentProcess(), + ) + }, + 0 + ); + // A new job does not permit breakaway. Reject before any lifecycle mutation. + assert!(super::ensure_detached_launch(&executable).is_err()); + return; + } + let output = std::process::Command::new(executable) + .args([ + "--exact", + "backend::windows::tests::detached_launch_preflight_rejects_restrictive_job", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .expect("isolated job test"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!(String::from_utf8_lossy(&output.stdout).contains("1 passed")); +} + #[tokio::test] async fn identity_queries_do_not_require_termination_access() { let mut child = tokio::process::Command::new("powershell.exe") diff --git a/codex-rs/app-server-daemon/src/lib.rs b/codex-rs/app-server-daemon/src/lib.rs index 93d5e8b067..a9cd34f062 100644 --- a/codex-rs/app-server-daemon/src/lib.rs +++ b/codex-rs/app-server-daemon/src/lib.rs @@ -194,16 +194,24 @@ enum RestartDecision { pub async fn run(command: LifecycleCommand) -> Result { ensure_supported_platform()?; + #[cfg(windows)] + if matches!(command, LifecycleCommand::Start | LifecycleCommand::Restart) { + backend::windows::ensure_not_elevated()?; + } Daemon::from_environment()?.run(command).await } pub async fn bootstrap(options: BootstrapOptions) -> Result { ensure_supported_platform()?; + #[cfg(windows)] + backend::windows::ensure_not_elevated()?; Daemon::from_environment()?.bootstrap(options).await } pub async fn ensure_remote_control_ready() -> Result { ensure_supported_platform()?; + #[cfg(windows)] + backend::windows::ensure_not_elevated()?; Daemon::from_environment()? .ensure_remote_control_ready() .await @@ -232,6 +240,8 @@ pub async fn start_remote_control_pairing() -> Result Result { ensure_supported_platform()?; + #[cfg(windows)] + backend::windows::ensure_not_elevated()?; Daemon::from_environment()?.set_remote_control(mode).await } @@ -239,18 +249,20 @@ pub async fn run_pid_update_loop( http_client_factory: codex_http_client::HttpClientFactory, ) -> Result<()> { ensure_supported_platform()?; + #[cfg(windows)] + backend::windows::ensure_not_elevated()?; update_loop::run(http_client_factory).await } -#[cfg(unix)] +#[cfg(any(unix, windows))] fn ensure_supported_platform() -> Result<()> { Ok(()) } -#[cfg(not(unix))] +#[cfg(not(any(unix, windows)))] fn ensure_supported_platform() -> Result<()> { Err(anyhow!( - "codex app-server daemon lifecycle is only supported on Unix platforms" + "codex app-server daemon lifecycle is only supported on Unix and Windows platforms" )) } @@ -386,6 +398,8 @@ impl Daemon { RestartDecision::NotReady => return Ok(RestartIfRunningOutcome::NotReady), RestartDecision::AlreadyCurrent => RestartIfRunningOutcome::AlreadyCurrent, RestartDecision::Restart => { + #[cfg(windows)] + backend::windows::ensure_detached_launch(managed_codex_bin)?; backend.stop().await?; let _ = self .start_managed_backend_with_bin(&settings, managed_codex_bin) @@ -577,11 +591,13 @@ impl Daemon { )); } + if backend.is_some() { + self.ensure_managed_codex_bin()?; + } settings.remote_control_enabled = remote_control_enabled; settings.save(&self.settings_file).await?; let app_server_version = if let Some(backend) = backend { - self.ensure_managed_codex_bin()?; backend.stop().await?; let _ = self.start_managed_backend(&settings).await?; Some(self.wait_until_ready().await?.app_server_version) @@ -679,15 +695,22 @@ impl Daemon { fn ensure_managed_codex_bin(&self) -> Result<()> { if self.managed_codex_bin.is_file() { + #[cfg(windows)] + backend::windows::ensure_detached_launch(&self.managed_codex_bin)?; return Ok(()); } 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 curl -fsSL https://chatgpt.com/codex/install.sh | sh\n\n\ + Install it with:\n {install_command}\n\n\ Then rerun the command you just tried." )) } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 92044d0be0..a07418049b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -25,7 +25,7 @@ Supported transports: - stdio (`--stdio` or `--listen stdio://`, default): newline-delimited JSON (JSONL) - websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**) -- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake +- unix socket (`--listen unix://` or `--listen unix://PATH`): websocket connections over `$CODEX_HOME/app-server-control/app-server-control.sock` or a custom socket path, using the standard HTTP Upgrade handshake (also supported on Windows) - off (`--listen off`): do not expose a local transport When running with `--listen ws://IP:PORT`, the same listener also serves basic HTTP health probes: @@ -48,6 +48,10 @@ directories must already have that owner and DACL; startup rejects broader permi than attempting to repair previously exposed state. Custom sockets should use a new dedicated subdirectory. The listener pins the validated directory until socket cleanup completes. +`codex app-server daemon` manages this local server on Unix and Windows using the standalone +installation. The TUI discovers an available local daemon; `codex agents` starts it when no explicit +remote endpoint is supplied. See [daemon lifecycle](../app-server-daemon/README.md) for commands and platform requirements. + Tracing/log output: - `RUST_LOG` controls log filtering/verbosity. diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5009e23820..928e08b217 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1133,7 +1133,7 @@ async fn cli_main( /*remote*/ None, root_remote_auth_token_env.clone(), )?; - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] anyhow::bail!("`codex agents` requires `--remote` on this platform"); } interactive.agents_overview = true; @@ -2600,7 +2600,7 @@ async fn run_interactive_tui( } } - #[cfg(unix)] + #[cfg(any(unix, windows))] if interactive.agents_overview && remote.is_none() { if !std::io::stdin().is_terminal() { return Ok(AppExitInfo::fatal("stdin is not a terminal")); diff --git a/codex-rs/cli/tests/app_server.rs b/codex-rs/cli/tests/app_server.rs index 4d3c0d445d..7a55c4ca52 100644 --- a/codex-rs/cli/tests/app_server.rs +++ b/codex-rs/cli/tests/app_server.rs @@ -47,7 +47,7 @@ fn agents_accept_interactive_configuration_overrides() -> Result<()> { ] { let mut cmd = codex_command(codex_home.path())?; cmd.env("TERM", "xterm-256color").args(args); - #[cfg(not(unix))] + #[cfg(not(any(unix, windows)))] cmd.args(["--remote", "ws://127.0.0.1:4512"]); cmd.assert() diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 7de0ba58a0..8a8533b781 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -57,6 +57,7 @@ codex-shell-command = { workspace = true } codex-state = { workspace = true } codex-terminal-detection = { workspace = true } codex-utils-approval-presets = { workspace = true } +codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-cli = { workspace = true } codex-utils-elapsed = { workspace = true } diff --git a/codex-rs/tui/src/app/agents_overview.rs b/codex-rs/tui/src/app/agents_overview.rs index c07369511d..af0644806a 100644 --- a/codex-rs/tui/src/app/agents_overview.rs +++ b/codex-rs/tui/src/app/agents_overview.rs @@ -56,14 +56,14 @@ impl App { subtitle: Some( if workload_identity_selected { "The agents dashboard is unavailable while workload identity is active." - } else if cfg!(unix) { + } else if cfg!(any(unix, windows)) { "This session isn’t connected to a shared background server." } else { "Connect to a remote background server to use the agents dashboard." } .to_string(), ), - footer_note: (cfg!(unix) && !workload_identity_selected).then(|| { + footer_note: (cfg!(any(unix, windows)) && !workload_identity_selected).then(|| { Line::from( "Starting a background server will not interrupt or move this session." .dim(), @@ -71,7 +71,7 @@ impl App { }), footer_hint: Some(standard_popup_hint_line_for_keymap(&self.keymap.list)), items: [ - #[cfg(unix)] + #[cfg(any(unix, windows))] (!workload_identity_selected).then(|| SelectionItem { name: "Start background server".to_string(), description: Some( @@ -737,7 +737,7 @@ impl App { } } - #[cfg(unix)] + #[cfg(any(unix, windows))] pub(super) fn start_agents_daemon(&self) { let app_event_tx = self.app_event_tx.clone(); tokio::spawn(async move { @@ -746,9 +746,19 @@ impl App { std::env::current_exe().map_err(|error| error.to_string())?; let executable = if current_executable .file_stem() - .is_some_and(|name| name == "codex-tui") - { - current_executable.with_file_name("codex") + .and_then(std::ffi::OsStr::to_str) + .is_some_and(|name| { + if cfg!(windows) { + name.eq_ignore_ascii_case("codex-tui") + } else { + name == "codex-tui" + } + }) { + current_executable.with_file_name(if cfg!(windows) { + "codex.exe" + } else { + "codex" + }) } else { current_executable }; diff --git a/codex-rs/tui/src/app/agents_overview_tests.rs b/codex-rs/tui/src/app/agents_overview_tests.rs index 262d623e54..800bfd6084 100644 --- a/codex-rs/tui/src/app/agents_overview_tests.rs +++ b/codex-rs/tui/src/app/agents_overview_tests.rs @@ -606,7 +606,7 @@ async fn shared_overview_shows_only_root_sessions() { assert!(app.last_rendered_history_tail.is_some()); } -#[cfg(unix)] +#[cfg(any(unix, windows))] #[tokio::test] async fn embedded_sessions_offer_to_start_a_background_server_without_migrating() { let mut app = make_test_app().await; @@ -625,6 +625,47 @@ async fn embedded_sessions_offer_to_start_a_background_server_without_migrating( app_server.shutdown().await.expect("shutdown app server"); } +#[cfg(any(unix, windows))] +#[tokio::test] +async fn daemon_start_result_snapshots() -> Result<()> { + let (mut app, mut app_event_rx, _op_rx) = + crate::app::tests::make_test_app_with_channels().await; + let mut app_server = + crate::start_embedded_app_server_for_picker(app.chat_widget.config_ref()).await?; + let mut tui = crate::tui::test_support::make_test_tui()?; + + for (name, result) in [ + ("agents_daemon_started", Ok(())), + ( + "agents_daemon_start_failed", + Err("The host does not allow detached processes".to_string()), + ), + ] { + app.handle_event( + &mut tui, + &mut app_server, + AppEvent::AgentsDaemonStarted { result }, + ) + .await?; + let cell = match app_event_rx.try_recv()? { + AppEvent::InsertHistoryCell(cell) => cell, + other => panic!("expected daemon result history, got {other:?}"), + }; + let rendered = cell + .display_lines(/*width*/ 80) + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + insta::with_settings!({snapshot_path => "../snapshots"}, { + insta::assert_snapshot!(name, rendered); + }); + } + + app_server.shutdown().await?; + Ok(()) +} + #[tokio::test] async fn filtered_dashboard_actions_use_configured_shortcuts() { let mut app = make_test_app().await; diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 336fe3fbe7..17fb55e6a0 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -2637,11 +2637,11 @@ impl App { self.stop_agents_overview_thread(app_server, thread_id) .await; } - #[cfg(unix)] + #[cfg(any(unix, windows))] AppEvent::StartAgentsDaemon => { self.start_agents_daemon(); } - #[cfg(unix)] + #[cfg(any(unix, windows))] AppEvent::AgentsDaemonStarted { result } => match result { Ok(()) => self.chat_widget.add_info_message( "Background server started. Run `codex agents` in another terminal; this session remains unchanged." diff --git a/codex-rs/tui/src/app/reconnect.rs b/codex-rs/tui/src/app/reconnect.rs index 7bb9cd043c..fd8232cff5 100644 --- a/codex-rs/tui/src/app/reconnect.rs +++ b/codex-rs/tui/src/app/reconnect.rs @@ -36,14 +36,9 @@ pub(super) async fn reconnect( presentation: ReconnectPresentation, ) -> Result { let mode = target.thread_params_mode(); - let endpoint = match target { - AppServerTarget::Remote { endpoint } | AppServerTarget::LocalDaemon { endpoint } => { - endpoint - } - AppServerTarget::Embedded => { - color_eyre::eyre::bail!("in-process sessions have no connection to restore") - } - }; + if matches!(target, AppServerTarget::Embedded) { + color_eyre::eyre::bail!("in-process sessions have no connection to restore"); + } if let ThreadToolTransport::Mcp(server) = &task_tools { server.suspend(); } @@ -58,7 +53,7 @@ pub(super) async fn reconnect( for delay in [0, 1, 2, 4, 8] { let attempt = async { tokio::time::sleep(Duration::from_secs(delay)).await; - let client = crate::connect_remote_app_server(endpoint.clone()).await?; + let client = crate::app_server_connection::connect(&target).await?; let mut session = AppServerSession::new(client, mode) .with_startup_config(&config) .with_remote_cwd_override(remote_cwd.clone()) diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d9a2c21387..7a94472643 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -5510,7 +5510,7 @@ async fn make_test_app() -> App { } } -async fn make_test_app_with_channels() -> ( +pub(super) async fn make_test_app_with_channels() -> ( App, tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index cea9c29326..38c415981b 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -270,10 +270,10 @@ pub(crate) enum AppEvent { thread_id: ThreadId, }, /// Start the shared app-server daemon without moving the current embedded session. - #[cfg(unix)] + #[cfg(any(unix, windows))] StartAgentsDaemon, /// Report whether starting the shared app-server daemon succeeded. - #[cfg(unix)] + #[cfg(any(unix, windows))] AgentsDaemonStarted { result: Result<(), String>, }, diff --git a/codex-rs/tui/src/app_server_connection.rs b/codex-rs/tui/src/app_server_connection.rs new file mode 100644 index 0000000000..8df6681e7e --- /dev/null +++ b/codex-rs/tui/src/app_server_connection.rs @@ -0,0 +1,48 @@ +//! Applies the selected server's socket policy on initial connections and reconnects. + +use crate::AppServerTarget; +#[cfg(windows)] +use crate::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; +use crate::connect_remote_app_server; +use codex_app_server_client::AppServerClient; +#[cfg(windows)] +use codex_app_server_client::RemoteAppServerClient; +#[cfg(windows)] +use codex_app_server_client::RemoteAppServerConnectArgs; +#[cfg(windows)] +use codex_app_server_client::RemoteAppServerEndpoint; +#[cfg(windows)] +use codex_utils_absolute_path::AbsolutePathBuf; + +pub(crate) async fn connect(target: &AppServerTarget) -> color_eyre::Result { + match target { + AppServerTarget::Embedded => { + color_eyre::eyre::bail!("embedded sessions have no remote connection") + } + #[cfg(windows)] + AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, + } => { + // Revalidate at the real connection, not just the earlier discovery probe. + let (socket_path, _directory) = + codex_uds::validate_private_socket_path(socket_path.as_path())?; + let app_server = + RemoteAppServerClient::connect_local_daemon(RemoteAppServerConnectArgs { + endpoint: RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path_checked(socket_path)?, + }, + client_name: "codex-tui".to_string(), + client_version: env!("CARGO_PKG_VERSION").to_string(), + experimental_api: true, + mcp_server_openai_form_elicitation: false, + opt_out_notification_methods: Vec::new(), + channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, + }) + .await?; + Ok(AppServerClient::Remote(app_server)) + } + AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => { + connect_remote_app_server(endpoint.clone()).await + } + } +} diff --git a/codex-rs/tui/src/daemon_startup_tests.rs b/codex-rs/tui/src/daemon_startup_tests.rs new file mode 100644 index 0000000000..01bd8bc3cf --- /dev/null +++ b/codex-rs/tui/src/daemon_startup_tests.rs @@ -0,0 +1,85 @@ +//! Implicit daemon discovery is opportunistic; explicit endpoints remain authoritative. + +use super::*; +use crate::legacy_core::config::ConfigBuilder; +use pretty_assertions::assert_eq; +use tempfile::TempDir; + +#[cfg(windows)] +#[tokio::test] +async fn daemon_connection_rejects_unprotected_socket_before_handshake() -> color_eyre::Result<()> { + let home = TempDir::new()?; + let parent = home.path().join("control"); + std::fs::create_dir(&parent)?; + let socket_path = AbsolutePathBuf::from_absolute_path_checked(parent.join("server.sock"))?; + let mut listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; + let target = AppServerTarget::LocalDaemon { + endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, + }; + tokio::select! { + result = app_server_connection::connect(&target) => assert!(result.is_err()), + _ = listener.accept() => panic!("unprotected listener must not receive a connection"), + } + Ok(()) +} + +#[tokio::test] +async fn daemon_startup_falls_back_only_for_implicit_endpoints() -> color_eyre::Result<()> { + for scenario in ["missing socket", "failed handshake", "explicit endpoint"] { + let home = TempDir::new()?; + let config = ConfigBuilder::default() + .codex_home(home.path().to_path_buf()) + .build() + .await?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let endpoint = if scenario == "missing socket" { + RemoteAppServerEndpoint::UnixSocket { + socket_path: AbsolutePathBuf::from_absolute_path_checked( + home.path().join("gone.sock"), + )?, + } + } else { + RemoteAppServerEndpoint::WebSocket { + websocket_url: format!("ws://{}", listener.local_addr()?), + auth_token: None, + } + }; + let reject_handshake = tokio::spawn(async move { + let _ = listener.accept().await; + }); + let mut target = if scenario == "explicit endpoint" { + AppServerTarget::Remote { endpoint } + } else { + AppServerTarget::LocalDaemon { endpoint } + }; + let original_target = target.clone(); + let mut state_db = None; + let result = start_app_server( + &mut target, + Arg0DispatchPaths::default(), + config, + Vec::new(), + LoaderOverrides::default(), + /*strict_config*/ false, + CloudConfigBundleLoader::default(), + codex_feedback::CodexFeedback::new(), + /*log_db*/ None, + &mut state_db, + Arc::new(EnvironmentManager::default_for_tests()), + ) + .await; + reject_handshake.abort(); + if scenario == "explicit endpoint" { + assert!(result.is_err()); + assert_eq!(target, original_target); + assert!(state_db.is_none()); + } else { + let server = AppServerSession::new(result?, target.thread_params_mode()); + assert!(server.uses_embedded_app_server()); + assert_eq!(target, AppServerTarget::Embedded); + assert!(state_db.is_some()); + server.shutdown().await?; + } + } + Ok(()) +} diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 34cee7f2ee..e604e745d5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -104,6 +104,7 @@ mod app_event; mod app_event_sender; mod app_info; mod app_server_approval_conversions; +mod app_server_connection; mod app_server_session; mod approval_events; mod ascii_animation; @@ -249,7 +250,6 @@ pub use public_widgets::composer_input::ComposerInput; const TUI_LOG_FILE_NAME: &str = "codex-tui.log"; const INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 500); -#[cfg(unix)] const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50); @@ -444,16 +444,28 @@ async fn connect_remote_app_server( Ok(AppServerClient::Remote(app_server)) } -#[cfg(unix)] async fn maybe_probe_default_daemon_socket(codex_home: &Path) -> Option { let socket_path = codex_app_server_client::app_server_control_socket_path(codex_home).ok()?; + #[cfg(windows)] + let (validated_path, _directory) = + codex_uds::validate_private_socket_path(socket_path.as_path()).ok()?; + #[cfg(windows)] + let validated_path = AbsolutePathBuf::from_absolute_path_checked(validated_path).ok()?; + #[cfg(windows)] + let probe_path = validated_path.as_path(); + #[cfg(not(windows))] + let probe_path = socket_path.as_path(); match tokio::time::timeout( AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT, - tokio::net::UnixStream::connect(socket_path.as_path()), + codex_uds::UnixStream::connect(probe_path), ) .await { - Ok(Ok(_stream)) => Some(socket_path), + Ok(Ok(_stream)) => { + #[cfg(windows)] + _stream.ensure_non_elevated_peer().ok()?; + Some(socket_path) + } Ok(Err(err)) => { tracing::debug!(%err, socket_path = %socket_path.display(), "skipping default app-server daemon socket"); None @@ -469,14 +481,9 @@ async fn maybe_probe_default_daemon_socket(codex_home: &Path) -> Option Option { - None -} - #[allow(clippy::too_many_arguments)] async fn start_app_server( - target: &AppServerTarget, + target: &mut AppServerTarget, arg0_paths: Arg0DispatchPaths, config: Config, cli_kv_overrides: Vec<(String, toml::Value)>, @@ -485,28 +492,39 @@ async fn start_app_server( cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, - state_db: Option, + state_db: &mut Option, environment_manager: Arc, ) -> color_eyre::Result { - match target { - AppServerTarget::Embedded => start_embedded_app_server( - arg0_paths, - config, - cli_kv_overrides, - loader_overrides, - strict_config, - cloud_config_bundle, - feedback, - log_db, - state_db, - environment_manager, - ) - .await - .map(AppServerClient::InProcess), - AppServerTarget::LocalDaemon { endpoint } | AppServerTarget::Remote { endpoint } => { - connect_remote_app_server(endpoint.clone()).await + let connection = if matches!(target, AppServerTarget::Embedded) { + None + } else { + Some(app_server_connection::connect(target).await) + }; + if let Some(connection) = connection { + match connection { + Ok(app_server) => return Ok(app_server), + Err(err) if matches!(target, AppServerTarget::LocalDaemon { .. }) => { + tracing::debug!(%err, "local daemon connection failed; starting embedded app server"); + *target = AppServerTarget::Embedded; + *state_db = init_state_db_for_app_server_target(&config, target).await?; + } + Err(err) => return Err(err), } } + start_embedded_app_server( + arg0_paths, + config, + cli_kv_overrides, + loader_overrides, + strict_config, + cloud_config_bundle, + feedback, + log_db, + state_db.clone(), + environment_manager, + ) + .await + .map(AppServerClient::InProcess) } pub(crate) async fn start_app_server_for_picker( @@ -515,8 +533,10 @@ pub(crate) async fn start_app_server_for_picker( state_db: Option, environment_manager: Arc, ) -> color_eyre::Result { + let mut target = target.clone(); + let mut state_db = state_db; let app_server = start_app_server( - target, + &mut target, Arg0DispatchPaths::default(), config.clone(), Vec::new(), @@ -525,7 +545,7 @@ pub(crate) async fn start_app_server_for_picker( CloudConfigBundleLoader::default(), codex_feedback::CodexFeedback::new(), /*log_db*/ None, - state_db, + &mut state_db, environment_manager, ) .await?; @@ -864,6 +884,7 @@ fn app_server_target_for_launch( default_daemon_socket: Option, can_reuse_implicit_local_daemon: bool, workload_identity_selected: bool, + exec_server_url: Option<&std::ffi::OsStr>, ) -> std::io::Result { if workload_identity_selected { if explicit_remote_endpoint.is_some() { @@ -876,7 +897,8 @@ fn app_server_target_for_launch( } Ok(match explicit_remote_endpoint { Some(endpoint) => AppServerTarget::Remote { endpoint }, - None if can_reuse_implicit_local_daemon => { + // A shared daemon cannot adopt this invocation's executor selection. + None if can_reuse_implicit_local_daemon && exec_server_url.is_none() => { default_daemon_socket.map_or(AppServerTarget::Embedded, |socket_path| { AppServerTarget::LocalDaemon { endpoint: RemoteAppServerEndpoint::UnixSocket { socket_path }, @@ -970,7 +992,7 @@ async fn run_ratatui_app( arg0_paths: Arg0DispatchPaths, loader_overrides: LoaderOverrides, strict_config: bool, - app_server_target: AppServerTarget, + mut app_server_target: AppServerTarget, remote_cwd_override: Option, initial_config: Config, manually_selected_oss_provider: Option, @@ -979,7 +1001,7 @@ async fn run_ratatui_app( mut cloud_config_bundle: CloudConfigBundleLoader, feedback: codex_feedback::CodexFeedback, log_db: Option, - state_db: Option, + mut state_db: Option, environment_manager: Arc, startup_draft: startup_draft::StartupDraft, ) -> color_eyre::Result { @@ -1032,7 +1054,7 @@ async fn run_ratatui_app( .run_until( &mut tui, start_app_server( - &app_server_target, + &mut app_server_target, arg0_paths.clone(), initial_config.clone(), cli_kv_overrides.clone(), @@ -1041,7 +1063,7 @@ async fn run_ratatui_app( cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), - state_db.clone(), + &mut state_db, environment_manager.clone(), ), ) @@ -1639,7 +1661,7 @@ async fn run_ratatui_app( .run_until( &mut tui, start_app_server( - &app_server_target, + &mut app_server_target, arg0_paths, config.clone(), cli_kv_overrides.clone(), @@ -1648,7 +1670,7 @@ async fn run_ratatui_app( cloud_config_bundle.clone(), feedback.clone(), log_db.clone(), - state_db.clone(), + &mut state_db, environment_manager.clone(), ), ) @@ -1978,6 +2000,10 @@ fn should_show_bedrock_setup_wizard( .is_login_method_allowed(ForcedLoginMethod::Api) } +#[cfg(test)] +#[path = "daemon_startup_tests.rs"] +mod daemon_startup_tests; + #[cfg(test)] mod tests { use super::*; @@ -2624,18 +2650,48 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } - #[cfg(unix)] #[tokio::test] async fn default_daemon_auto_connect_probes_socket_only() -> color_eyre::Result<()> { let codex_home = TempDir::new()?; let socket_path = codex_app_server_client::app_server_control_socket_path(codex_home.path())?; - std::fs::create_dir_all(socket_path.as_path().parent().expect("socket parent"))?; - let _listener = tokio::net::UnixListener::bind(socket_path.as_path())?; + #[cfg(windows)] + { + let parent = socket_path.as_path().parent().expect("socket parent"); + std::fs::create_dir_all(parent)?; + let listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; + assert!( + maybe_probe_default_daemon_socket(codex_home.path()) + .await + .is_none() + ); + drop(listener); + std::fs::remove_dir_all(parent)?; + } + codex_uds::prepare_private_socket_directory( + socket_path.as_path().parent().expect("socket parent"), + ) + .await?; + let _listener = codex_uds::UnixListener::bind(socket_path.as_path()).await?; + let expected = Some(socket_path); + #[cfg(windows)] + let expected = { + // Existing Windows CI may run elevated; an elevated listener must + // never be selected implicitly, even when its directory is private. + let output = std::process::Command::new("powershell.exe") + .args(["-NoProfile", "-NonInteractive", "-Command", "([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)"]) + .output()?; + assert!(output.status.success()); + match String::from_utf8(output.stdout)?.trim() { + "True" => None, + "False" => expected, + other => panic!("unexpected elevation result: {other}"), + } + }; assert_eq!( maybe_probe_default_daemon_socket(codex_home.path()).await, - Some(socket_path) + expected ); Ok(()) } @@ -2649,6 +2705,7 @@ requires_openai_auth = {requires_openai_auth} Some(socket_path.clone()), /*can_reuse_implicit_local_daemon*/ true, /*workload_identity_selected*/ false, + /*exec_server_url*/ None, )?; assert_eq!( @@ -2662,6 +2719,24 @@ requires_openai_auth = {requires_openai_auth} Ok(()) } + #[test] + fn app_server_target_for_launch_preserves_executor_selection() -> color_eyre::Result<()> { + let socket_path = AbsolutePathBuf::relative_to_current_dir("codex.sock")?; + for executor in ["none", "ws://127.0.0.1:4501"] { + assert_eq!( + app_server_target_for_launch( + /*explicit_remote_endpoint*/ None, + Some(socket_path.clone()), + /*can_reuse_implicit_local_daemon*/ true, + /*workload_identity_selected*/ false, + Some(std::ffi::OsStr::new(executor)), + )?, + AppServerTarget::Embedded, + ); + } + Ok(()) + } + #[test] fn app_server_target_for_launch_prefers_explicit_remote_endpoint() -> color_eyre::Result<()> { let explicit_endpoint = RemoteAppServerEndpoint::UnixSocket { @@ -2672,6 +2747,7 @@ requires_openai_auth = {requires_openai_auth} Some(AbsolutePathBuf::relative_to_current_dir("default.sock")?), /*can_reuse_implicit_local_daemon*/ false, /*workload_identity_selected*/ false, + Some(std::ffi::OsStr::new("none")), )?; assert_eq!( @@ -2694,6 +2770,7 @@ requires_openai_auth = {requires_openai_auth} Some(socket_path), /*can_reuse_implicit_local_daemon*/ false, /*workload_identity_selected*/ false, + /*exec_server_url*/ None, )?; assert_eq!(target, AppServerTarget::Embedded); @@ -2709,6 +2786,7 @@ requires_openai_auth = {requires_openai_auth} Some(default_socket), /*can_reuse_implicit_local_daemon*/ true, /*workload_identity_selected*/ true, + /*exec_server_url*/ None, )?, AppServerTarget::Embedded ); @@ -2721,6 +2799,7 @@ requires_openai_auth = {requires_openai_auth} /*default_daemon_socket*/ None, /*can_reuse_implicit_local_daemon*/ false, /*workload_identity_selected*/ true, + /*exec_server_url*/ None, ) .expect_err("remote hosts must own workload identity"); assert_eq!( diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index ba460f4b8a..bdc62ee83e 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -350,11 +350,12 @@ pub(super) async fn start_app_server_for_session_command( } else { None }; - let app_server_target = super::app_server_target_for_launch( + let mut app_server_target = super::app_server_target_for_launch( explicit_remote_endpoint, default_daemon, reuse_implicit_local_daemon, workload_identity_selected, + std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(), )?; let remote_cwd_override = cli .cwd @@ -446,11 +447,11 @@ pub(super) async fn start_app_server_for_session_command( .build(Some(local_runtime_paths), config.http_client_factory()) .wrap_err("failed to initialize environment manager")?, ); - let state_db = super::init_state_db_for_app_server_target(&config, &app_server_target) + let mut state_db = super::init_state_db_for_app_server_target(&config, &app_server_target) .await .wrap_err("failed to initialize state database")?; let app_server = super::start_app_server( - &app_server_target, + &mut app_server_target, arg0_paths, config, cli_kv_overrides, @@ -459,7 +460,7 @@ pub(super) async fn start_app_server_for_session_command( cloud_config_bundle, codex_feedback::CodexFeedback::new(), /*log_db*/ None, - state_db, + &mut state_db, environment_manager, ) .await?; diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_start_failed.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_start_failed.snap new file mode 100644 index 0000000000..03fef87ad4 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_start_failed.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 401 +expression: rendered +--- +■ Failed to start the background server: The host does not allow detached processes diff --git a/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_started.snap b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_started.snap new file mode 100644 index 0000000000..db60e61ebd --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__app__agents_overview__tests__agents_daemon_started.snap @@ -0,0 +1,6 @@ +--- +source: tui/src/app/agents_overview_tests.rs +assertion_line: 401 +expression: rendered +--- +• Background server started. Run `codex agents` in another terminal; this session remains unchanged. diff --git a/codex-rs/tui/src/startup_orchestration.rs b/codex-rs/tui/src/startup_orchestration.rs index 0b5cbae183..a7c37b45f3 100644 --- a/codex-rs/tui/src/startup_orchestration.rs +++ b/codex-rs/tui/src/startup_orchestration.rs @@ -73,6 +73,7 @@ pub(super) async fn run_main_inner( /*default_daemon_socket*/ None, /*can_reuse_implicit_local_daemon*/ false, workload_identity_selected, + std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(), )?; let validation_environment_manager = if should_load_configured_environments(&loader_overrides, &validation_target) { @@ -187,6 +188,7 @@ pub(super) async fn run_main_inner( default_daemon, reuse_implicit_local_daemon, workload_identity_selected, + std::env::var_os(codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR).as_deref(), )?; let remote_cwd_override = cli .cwd