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
This commit is contained in:
Eric Traut
2026-09-03 01:26:35 +00:00
committed by copyberry
parent deb1471166
commit b7f710273e
25 changed files with 659 additions and 92 deletions
+1
View File
@@ -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",
+34 -1
View File
@@ -169,8 +169,34 @@ pub struct RemoteAppServerRequestHandle {
command_tx: mpsc::Sender<RemoteClientCommand>,
}
enum SocketPeerPolicy {
ExplicitEndpoint,
#[cfg(windows)]
NonElevatedCurrentUser,
}
impl RemoteAppServerClient {
pub async fn connect(args: RemoteAppServerConnectArgs) -> IoResult<Self> {
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<Self> {
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<Self> {
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<UnixStream>)> {
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,
+34 -12
View File
@@ -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
@@ -16,6 +16,8 @@ use tokio::process::Command;
impl PidBackend {
pub(super) async fn start_inner(&self, replacement: Option<PidRecord>) -> Result<Option<u32>> {
#[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::<std::io::Result<Vec<_>>>()?;
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);
@@ -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<bool> {
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::<u8>::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::<std::io::Error>(), "{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::<std::io::Error>(), "{error:#}");
}
assert_eq!(
backend.read_pid_file_state().await.unwrap(),
PidFileState::Running(record)
@@ -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::<TOKEN_ELEVATION>() 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 {
@@ -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 {
<std::os::windows::io::OwnedHandle as std::os::windows::io::FromRawHandle>::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")
+28 -5
View File
@@ -194,16 +194,24 @@ enum RestartDecision {
pub async fn run(command: LifecycleCommand) -> Result<LifecycleOutput> {
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<BootstrapOutput> {
ensure_supported_platform()?;
#[cfg(windows)]
backend::windows::ensure_not_elevated()?;
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
@@ -232,6 +240,8 @@ pub async fn start_remote_control_pairing() -> Result<RemoteControlPairingStartR
pub async fn set_remote_control(mode: RemoteControlMode) -> Result<RemoteControlOutput> {
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."
))
}
+5 -1
View File
@@ -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.
+2 -2
View File
@@ -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"));
+1 -1
View File
@@ -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()
+1
View File
@@ -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 }
+17 -7
View File
@@ -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
};
+42 -1
View File
@@ -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::<Vec<_>>()
.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;
+2 -2
View File
@@ -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."
+4 -9
View File
@@ -36,14 +36,9 @@ pub(super) async fn reconnect(
presentation: ReconnectPresentation,
) -> Result<Reconnected> {
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())
+1 -1
View File
@@ -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<AppEvent>,
tokio::sync::mpsc::UnboundedReceiver<Op>,
+2 -2
View File
@@ -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>,
},
+48
View File
@@ -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<AppServerClient> {
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
}
}
}
+85
View File
@@ -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(())
}
+120 -41
View File
@@ -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<AbsolutePathBuf> {
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<Absolute
}
}
#[cfg(not(unix))]
async fn maybe_probe_default_daemon_socket(_codex_home: &Path) -> Option<AbsolutePathBuf> {
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<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
state_db: &mut Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
) -> color_eyre::Result<AppServerClient> {
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<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
) -> color_eyre::Result<AppServerSession> {
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<AbsolutePathBuf>,
can_reuse_implicit_local_daemon: bool,
workload_identity_selected: bool,
exec_server_url: Option<&std::ffi::OsStr>,
) -> std::io::Result<AppServerTarget> {
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<PathBuf>,
initial_config: Config,
manually_selected_oss_provider: Option<String>,
@@ -979,7 +1001,7 @@ async fn run_ratatui_app(
mut cloud_config_bundle: CloudConfigBundleLoader,
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
mut state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
startup_draft: startup_draft::StartupDraft,
) -> color_eyre::Result<AppExitInfo> {
@@ -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!(
+5 -4
View File
@@ -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?;
@@ -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
@@ -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.
@@ -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