mirror of
https://github.com/openai/codex.git
synced 2026-10-20 00:18:49 +00:00
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:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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?;
|
||||
|
||||
+6
@@ -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
|
||||
+6
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user