Add support for UDS in codex --remote (#22414)

## Why

Added support for UDS connections in `codex --remote`.

TUI also now connects to local app-server using UDS by default if it is
running and set to listen to UDS connection.

## What Changed

- Introduced `RemoteAppServerEndpoint` with `WebSocket` and `UnixSocket`
variants.
- Reused the existing JSON-RPC-over-WebSocket protocol over either a TCP
WebSocket stream or a UDS stream.
- Updated `codex --remote` to accept `ws://host:port`,
`wss://host:port`, `unix://`, and `unix://PATH`.
- Kept `--remote-auth-token-env` restricted to `wss://` and loopback
`ws://` remotes.
- Added a fast TUI startup probe for the default daemon socket, falling
back to the embedded app server when the daemon is absent or
unresponsive.

## Verification

- Manually verified that the updated remote flow works.
- Added coverage for UDS remote round trips, WebSocket auth headers,
auth-token transport policy, remote address parsing, and missing-daemon
fallback.
- Ran focused remote test coverage locally.
This commit is contained in:
Eric Traut authored and GitHub committed 2026-05-12 21:17:20 -07:00
1 parent 7bf95b39aa
commit ad572709ab
12 files changed
+563 -292

No files matched your search

+4 -6
View File
@@ -80,6 +80,7 @@ use crate::workspace_command::AppServerWorkspaceCommandRunner;
use crate::workspace_command::WorkspaceCommandRunner;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_client::RemoteAppServerEndpoint;
use codex_app_server_client::TypedRequestError;
use codex_app_server_protocol::AddCreditsNudgeCreditType;
use codex_app_server_protocol::AskForApproval;
@@ -498,8 +499,7 @@ pub(crate) struct App {
pub(crate) feedback: codex_feedback::CodexFeedback,
feedback_audience: FeedbackAudience,
environment_manager: Arc<EnvironmentManager>,
remote_app_server_url: Option<String>,
remote_app_server_auth_token: Option<String>,
remote_app_server_endpoint: Option<RemoteAppServerEndpoint>,
/// Set when the user confirms an update; propagated on exit.
pub(crate) pending_update_action: Option<UpdateAction>,
@@ -637,8 +637,7 @@ impl App {
is_first_run: bool,
entered_trust_nux: bool,
should_prompt_windows_sandbox_nux_at_startup: bool,
remote_app_server_url: Option<String>,
remote_app_server_auth_token: Option<String>,
remote_app_server_endpoint: Option<RemoteAppServerEndpoint>,
state_db: Option<StateDbHandle>,
environment_manager: Arc<EnvironmentManager>,
startup_hooks_browser: Option<HooksListEntry>,
@@ -919,8 +918,7 @@ See the Codex keymap documentation for supported actions and examples."
feedback: feedback.clone(),
feedback_audience,
environment_manager,
remote_app_server_url,
remote_app_server_auth_token,
remote_app_server_endpoint,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
+2 -5
View File
@@ -57,11 +57,8 @@ impl App {
AppEvent::OpenResumePicker => {
let picker_app_server = match crate::start_app_server_for_picker(
&self.config,
&match self.remote_app_server_url.clone() {
Some(websocket_url) => crate::AppServerTarget::Remote {
websocket_url,
auth_token: self.remote_app_server_auth_token.clone(),
},
&match self.remote_app_server_endpoint.clone() {
Some(endpoint) => crate::AppServerTarget::Remote { endpoint },
None => crate::AppServerTarget::Embedded,
},
self.state_db.clone(),
+1 -1
View File
@@ -633,7 +633,7 @@ impl App {
}
let current_cwd = self.config.cwd.to_path_buf();
let resume_cwd = if self.remote_app_server_url.is_some() {
let resume_cwd = if self.remote_app_server_endpoint.is_some() {
current_cwd.clone()
} else {
match crate::session_resume::resolve_cwd_for_resume_or_fork(
+1 -2
View File
@@ -44,8 +44,7 @@ pub(super) async fn make_test_app() -> App {
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
remote_app_server_url: None,
remote_app_server_auth_token: None,
remote_app_server_endpoint: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
+2 -4
View File
@@ -3992,8 +3992,7 @@ async fn make_test_app() -> App {
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
remote_app_server_url: None,
remote_app_server_auth_token: None,
remote_app_server_endpoint: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
@@ -4055,8 +4054,7 @@ async fn make_test_app_with_channels() -> (
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
remote_app_server_url: None,
remote_app_server_auth_token: None,
remote_app_server_endpoint: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
+188 -125
View File
@@ -25,6 +25,7 @@ use codex_app_server_client::InProcessAppServerClient;
use codex_app_server_client::InProcessClientStartArgs;
use codex_app_server_client::RemoteAppServerClient;
use codex_app_server_client::RemoteAppServerConnectArgs;
pub use codex_app_server_client::RemoteAppServerEndpoint;
use codex_app_server_protocol::Account as AppServerAccount;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::AuthMode as AppServerAuthMode;
@@ -272,6 +273,10 @@ pub use public_widgets::composer_input::ComposerAction;
pub use public_widgets::composer_input::ComposerInput;
// (tests access modules directly within the crate)
#[cfg(unix)]
const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration =
std::time::Duration::from_millis(50);
#[allow(clippy::too_many_arguments)]
async fn start_embedded_app_server(
arg0_paths: Arg0DispatchPaths,
@@ -302,10 +307,7 @@ async fn start_embedded_app_server(
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum AppServerTarget {
Embedded,
Remote {
websocket_url: String,
auth_token: Option<String>,
},
Remote { endpoint: RemoteAppServerEndpoint },
}
fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool {
@@ -347,12 +349,24 @@ fn websocket_url_supports_auth_token(parsed: &Url) -> bool {
}
}
pub fn normalize_remote_addr(addr: &str) -> color_eyre::Result<String> {
pub fn resolve_remote_addr(addr: &str) -> color_eyre::Result<RemoteAppServerEndpoint> {
if let Some(socket_path) = addr.strip_prefix("unix://") {
let socket_path = if socket_path.is_empty() {
let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?;
codex_app_server_client::app_server_control_socket_path(&codex_home)
.map_err(color_eyre::Report::new)?
} else {
AbsolutePathBuf::relative_to_current_dir(socket_path)
.map_err(color_eyre::Report::new)?
};
return Ok(RemoteAppServerEndpoint::UnixSocket { socket_path });
}
let parsed = match Url::parse(addr) {
Ok(parsed) => parsed,
Err(_) => {
color_eyre::eyre::bail!(
"invalid remote address `{addr}`; expected `ws://host:port` or `wss://host:port`"
"invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`"
);
}
};
@@ -363,32 +377,31 @@ pub fn normalize_remote_addr(addr: &str) -> color_eyre::Result<String> {
&& parsed.query().is_none()
&& parsed.fragment().is_none()
{
return Ok(parsed.to_string());
return Ok(RemoteAppServerEndpoint::WebSocket {
websocket_url: parsed.to_string(),
auth_token: None,
});
}
color_eyre::eyre::bail!(
"invalid remote address `{addr}`; expected `ws://host:port` or `wss://host:port`"
"invalid remote address `{addr}`; expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`"
);
}
fn validate_remote_auth_token_transport(websocket_url: &str) -> color_eyre::Result<()> {
let parsed = Url::parse(websocket_url).map_err(color_eyre::Report::new)?;
if websocket_url_supports_auth_token(&parsed) {
return Ok(());
pub fn remote_addr_supports_auth_token(endpoint: &RemoteAppServerEndpoint) -> bool {
match endpoint {
RemoteAppServerEndpoint::WebSocket { websocket_url, .. } => {
Url::parse(websocket_url).is_ok_and(|parsed| websocket_url_supports_auth_token(&parsed))
}
RemoteAppServerEndpoint::UnixSocket { .. } => false,
}
color_eyre::eyre::bail!(
"remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`"
)
}
async fn connect_remote_app_server(
websocket_url: String,
auth_token: Option<String>,
endpoint: RemoteAppServerEndpoint,
) -> color_eyre::Result<AppServerClient> {
let app_server = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
websocket_url,
auth_token,
endpoint,
client_name: "codex-tui".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
@@ -400,6 +413,40 @@ 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()?;
if !socket_path.as_path().try_exists().unwrap_or(false) {
return None;
}
match tokio::time::timeout(
AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT,
tokio::net::UnixStream::connect(socket_path.as_path()),
)
.await
{
Ok(Ok(_stream)) => Some(socket_path),
Ok(Err(err)) => {
tracing::debug!(%err, socket_path = %socket_path.display(), "skipping default app-server daemon socket");
None
}
Err(_) => {
tracing::debug!(
socket_path = %socket_path.display(),
timeout_ms = AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT.as_millis(),
"timed out probing default app-server daemon socket"
);
None
}
}
}
#[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,
@@ -427,10 +474,7 @@ async fn start_app_server(
)
.await
.map(AppServerClient::InProcess),
AppServerTarget::Remote {
websocket_url,
auth_token,
} => connect_remote_app_server(websocket_url.clone(), auth_token.clone()).await,
AppServerTarget::Remote { endpoint } => connect_remote_app_server(endpoint.clone()).await,
}
}
@@ -714,24 +758,8 @@ pub async fn run_main(
mut cli: Cli,
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
remote: Option<String>,
remote_auth_token: Option<String>,
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
let remote_url = remote;
if let (Some(websocket_url), Some(_)) = (remote_url.as_deref(), remote_auth_token.as_ref()) {
validate_remote_auth_token_transport(websocket_url).map_err(std::io::Error::other)?;
}
let app_server_target = remote_url
.clone()
.map(|websocket_url| AppServerTarget::Remote {
websocket_url,
auth_token: remote_auth_token.clone(),
})
.unwrap_or(AppServerTarget::Embedded);
let remote_cwd_override = cli
.cwd
.clone()
.filter(|_| matches!(app_server_target, AppServerTarget::Remote { .. }));
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
(
Some(SandboxMode::DangerFullAccess),
@@ -776,6 +804,22 @@ pub async fn run_main(
}
};
let remote_endpoint = match explicit_remote_endpoint {
Some(endpoint) => Some(endpoint),
None => maybe_probe_default_daemon_socket(&codex_home)
.await
.map(|socket_path| RemoteAppServerEndpoint::UnixSocket { socket_path }),
};
let app_server_target = remote_endpoint
.clone()
.map_or(AppServerTarget::Embedded, |endpoint| {
AppServerTarget::Remote { endpoint }
});
let remote_cwd_override = cli
.cwd
.clone()
.filter(|_| matches!(app_server_target, AppServerTarget::Remote { .. }));
let local_runtime_paths = ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
@@ -1098,8 +1142,7 @@ pub async fn run_main(
feedback,
log_db,
state_db,
remote_url,
remote_auth_token,
remote_endpoint,
environment_manager,
)
.await
@@ -1120,8 +1163,7 @@ async fn run_ratatui_app(
feedback: codex_feedback::CodexFeedback,
log_db: Option<log_db::LogDbLayer>,
state_db: Option<StateDbHandle>,
remote_url: Option<String>,
remote_auth_token: Option<String>,
remote_endpoint: Option<RemoteAppServerEndpoint>,
environment_manager: Arc<EnvironmentManager>,
) -> color_eyre::Result<AppExitInfo> {
let remote_mode = matches!(&app_server_target, AppServerTarget::Remote { .. });
@@ -1169,30 +1211,29 @@ async fn run_ratatui_app(
// Initialize high-fidelity session event logging if enabled.
session_log::maybe_init(&initial_config);
let mut app_server = Some(
match start_app_server(
&app_server_target,
arg0_paths.clone(),
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
log_db.clone(),
state_db.clone(),
environment_manager.clone(),
)
.await
{
Ok(app_server) => AppServerSession::new(app_server)
.with_remote_cwd_override(remote_cwd_override.clone()),
Err(err) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
},
);
let app_server_session = match start_app_server(
&app_server_target,
arg0_paths.clone(),
initial_config.clone(),
cli_kv_overrides.clone(),
loader_overrides.clone(),
cloud_requirements.clone(),
feedback.clone(),
log_db.clone(),
state_db.clone(),
environment_manager.clone(),
)
.await
{
Ok(app_server) => AppServerSession::new(app_server),
Err(err) => {
terminal_restore_guard.restore_silently();
session_log::log_session_end();
return Err(err);
}
}
.with_remote_cwd_override(remote_cwd_override.clone());
let mut app_server = Some(app_server_session);
let should_show_trust_screen_flag = !remote_mode && should_show_trust_screen(&initial_config);
let mut trust_decision_was_made = false;
@@ -1553,8 +1594,7 @@ async fn run_ratatui_app(
should_show_trust_screen, // Proxy to: is it a first run in this directory?
should_show_trust_screen_flag, // Preserve the startup-time trust NUX signal before onboarding
should_prompt_windows_sandbox_nux_at_startup,
remote_url,
remote_auth_token,
remote_endpoint,
state_db,
environment_manager,
startup_hooks_browser,
@@ -1788,81 +1828,103 @@ mod tests {
}
#[test]
fn normalize_remote_addr_accepts_websocket_url() {
fn resolve_remote_addr_accepts_websocket_url() {
assert_eq!(
normalize_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"),
"ws://127.0.0.1:4500/"
resolve_remote_addr("ws://127.0.0.1:4500").expect("ws URL should normalize"),
RemoteAppServerEndpoint::WebSocket {
websocket_url: "ws://127.0.0.1:4500/".to_string(),
auth_token: None,
}
);
}
#[test]
fn normalize_remote_addr_accepts_secure_websocket_url() {
fn resolve_remote_addr_accepts_secure_websocket_url() {
assert_eq!(
normalize_remote_addr("wss://example.com:443").expect("wss URL should normalize"),
"wss://example.com/"
resolve_remote_addr("wss://example.com:443").expect("wss URL should normalize"),
RemoteAppServerEndpoint::WebSocket {
websocket_url: "wss://example.com/".to_string(),
auth_token: None,
}
);
}
#[test]
fn normalize_remote_addr_rejects_websocket_url_without_explicit_port() {
fn resolve_remote_addr_accepts_default_socket() -> color_eyre::Result<()> {
let codex_home = find_codex_home().wrap_err("failed to resolve CODEX_HOME")?;
assert_eq!(
resolve_remote_addr("unix://")?,
RemoteAppServerEndpoint::UnixSocket {
socket_path: codex_app_server_client::app_server_control_socket_path(&codex_home)?,
}
);
Ok(())
}
#[test]
fn resolve_remote_addr_accepts_relative_socket_path() -> color_eyre::Result<()> {
assert_eq!(
resolve_remote_addr("unix://codex.sock")?,
RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
}
);
Ok(())
}
#[test]
fn resolve_remote_addr_accepts_absolute_socket_path() -> color_eyre::Result<()> {
let temp_dir = TempDir::new()?;
let socket_path = temp_dir.path().join("codex.sock");
assert_eq!(
resolve_remote_addr(&format!("unix://{}", socket_path.display()))?,
RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::from_absolute_path(&socket_path)?,
}
);
Ok(())
}
#[test]
fn resolve_remote_addr_rejects_invalid_remote_addresses() {
for addr in [
"ws://127.0.0.1",
"wss://example.com",
"ws://user:pass@127.0.0.1",
"127.0.0.1:4500",
"https://127.0.0.1:4500",
] {
let err = normalize_remote_addr(addr)
.expect_err("websocket URLs without an explicit port should be rejected");
assert!(
err.to_string()
.contains("expected `ws://host:port` or `wss://host:port`")
);
let err = resolve_remote_addr(addr).expect_err("invalid remote addresses should fail");
assert!(err.to_string().contains(
"expected `ws://host:port`, `wss://host:port`, `unix://`, or `unix://PATH`"
));
}
}
#[test]
fn normalize_remote_addr_rejects_invalid_input() {
let err = normalize_remote_addr("https://127.0.0.1:4500")
.expect_err("https URLs should be rejected");
#[tokio::test]
async fn default_daemon_auto_connect_skips_missing_socket() -> color_eyre::Result<()> {
let codex_home = TempDir::new()?;
assert!(
err.to_string()
.contains("expected `ws://host:port` or `wss://host:port`")
maybe_probe_default_daemon_socket(codex_home.path())
.await
.is_none()
);
Ok(())
}
#[test]
fn normalize_remote_addr_rejects_host_port_shortcut() {
let err =
normalize_remote_addr("127.0.0.1:4500").expect_err("host:port should be rejected");
assert!(
err.to_string()
.contains("expected `ws://host:port` or `wss://host:port`")
);
}
#[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())?;
#[test]
fn remote_auth_token_transport_accepts_loopback_ws() {
validate_remote_auth_token_transport("ws://127.0.0.1:4500/")
.expect("loopback ws should be allowed for auth tokens");
validate_remote_auth_token_transport("ws://localhost:4500/")
.expect("localhost ws should be allowed for auth tokens");
validate_remote_auth_token_transport("ws://[::1]:4500/")
.expect("ipv6 loopback ws should be allowed for auth tokens");
}
#[test]
fn remote_auth_token_transport_accepts_secure_wss() {
validate_remote_auth_token_transport("wss://example.com:443/")
.expect("wss should be allowed for auth tokens");
}
#[test]
fn remote_auth_token_transport_rejects_non_loopback_ws() {
let err = validate_remote_auth_token_transport("ws://example.com:4500/")
.expect_err("non-loopback ws should be rejected for auth tokens");
assert!(
err.to_string()
.contains("remote auth tokens require `wss://` or loopback `ws://` URLs")
assert_eq!(
maybe_probe_default_daemon_socket(codex_home.path()).await,
Some(socket_path)
);
Ok(())
}
#[tokio::test]
@@ -2109,8 +2171,9 @@ mod tests {
Path::new("/definitely/not/local/to/this/test")
};
let target = AppServerTarget::Remote {
websocket_url: "ws://127.0.0.1:1234/".to_string(),
auth_token: None,
endpoint: RemoteAppServerEndpoint::UnixSocket {
socket_path: AbsolutePathBuf::relative_to_current_dir("codex.sock")?,
},
};
let environment_manager = EnvironmentManager::default_for_tests();
+1 -2
View File
@@ -57,8 +57,7 @@ fn main() -> anyhow::Result<()> {
inner,
arg0_paths,
LoaderOverrides::default(),
/*remote*/ None,
/*remote_auth_token*/ None,
/*explicit_remote_endpoint*/ None,
)
.await?;
match exit_info.exit_reason {