Wire remote app-server auth through the client (#14853)

For app-server websocket auth, support the two server-side mechanisms
from
PR #14847:

- `--ws-auth capability-token --ws-token-file /abs/path`
- `--ws-auth signed-bearer-token --ws-shared-secret-file /abs/path`
  with optional `--ws-issuer`, `--ws-audience`, and
  `--ws-max-clock-skew-seconds`

On the client side, add interactive remote support via:

- `--remote ws://host:port` or `--remote wss://host:port`
- `--remote-auth-token-env <ENV_VAR>`

Codex reads the bearer token from the named environment variable and
sends it
as `Authorization: Bearer <token>` during the websocket handshake.
Remote auth
tokens are only allowed for `wss://` URLs or loopback `ws://` URLs.

Testing:
- tested both auth methods manually to confirm connection success and
rejection for both auth types
This commit is contained in:
Eric Traut
2026-03-25 22:17:03 -06:00
committed by GitHub
Unverified
parent b565f05d79
commit 1ff39b6fa8
7 changed files with 469 additions and 47 deletions
+9 -1
View File
@@ -959,6 +959,7 @@ pub(crate) struct App {
pub(crate) feedback: codex_feedback::CodexFeedback,
feedback_audience: FeedbackAudience,
remote_app_server_url: Option<String>,
remote_app_server_auth_token: Option<String>,
/// Set when the user confirms an update; propagated on exit.
pub(crate) pending_update_action: Option<UpdateAction>,
@@ -3124,6 +3125,7 @@ impl App {
is_first_run: bool,
should_prompt_windows_sandbox_nux_at_startup: bool,
remote_app_server_url: Option<String>,
remote_app_server_auth_token: Option<String>,
) -> Result<AppExitInfo> {
use tokio_stream::StreamExt;
let (app_event_tx, mut app_event_rx) = unbounded_channel();
@@ -3335,6 +3337,7 @@ impl App {
feedback: feedback.clone(),
feedback_audience,
remote_app_server_url,
remote_app_server_auth_token,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
@@ -3583,7 +3586,10 @@ impl App {
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),
Some(websocket_url) => crate::AppServerTarget::Remote {
websocket_url,
auth_token: self.remote_app_server_auth_token.clone(),
},
None => crate::AppServerTarget::Embedded,
},
)
@@ -8121,6 +8127,7 @@ guardian_approval = true
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
remote_app_server_url: None,
remote_app_server_auth_token: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
@@ -8173,6 +8180,7 @@ guardian_approval = true
feedback: codex_feedback::CodexFeedback::new(),
feedback_audience: FeedbackAudience::External,
remote_app_server_url: None,
remote_app_server_auth_token: None,
pending_update_action: None,
pending_shutdown_exit_thread_id: None,
windows_sandbox: WindowsSandboxState::default(),
+72 -7
View File
@@ -284,7 +284,10 @@ async fn start_embedded_app_server(
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum AppServerTarget {
Embedded,
Remote(String),
Remote {
websocket_url: String,
auth_token: Option<String>,
},
}
fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool {
@@ -316,6 +319,16 @@ fn remote_addr_has_explicit_port(addr: &str, parsed: &Url) -> bool {
host_and_port == format!("{expected_host}:{explicit_default_port}")
}
fn websocket_url_supports_auth_token(parsed: &Url) -> bool {
match (parsed.scheme(), parsed.host()) {
("wss", Some(_)) => true,
("ws", Some(url::Host::Domain(domain))) => domain.eq_ignore_ascii_case("localhost"),
("ws", Some(url::Host::Ipv4(addr))) => addr.is_loopback(),
("ws", Some(url::Host::Ipv6(addr))) => addr.is_loopback(),
_ => false,
}
}
pub fn normalize_remote_addr(addr: &str) -> color_eyre::Result<String> {
let parsed = match Url::parse(addr) {
Ok(parsed) => parsed,
@@ -340,9 +353,24 @@ pub fn normalize_remote_addr(addr: &str) -> color_eyre::Result<String> {
);
}
async fn connect_remote_app_server(websocket_url: String) -> color_eyre::Result<AppServerClient> {
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(());
}
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>,
) -> color_eyre::Result<AppServerClient> {
let app_server = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
websocket_url,
auth_token,
client_name: "codex-tui".to_string(),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
@@ -374,9 +402,10 @@ async fn start_app_server(
)
.await
.map(AppServerClient::InProcess),
AppServerTarget::Remote(websocket_url) => {
connect_remote_app_server(websocket_url.clone()).await
}
AppServerTarget::Remote {
websocket_url,
auth_token,
} => connect_remote_app_server(websocket_url.clone(), auth_token.clone()).await,
}
}
@@ -590,11 +619,18 @@ pub async fn run_main(
arg0_paths: Arg0DispatchPaths,
loader_overrides: LoaderOverrides,
remote: Option<String>,
remote_auth_token: Option<String>,
) -> 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(AppServerTarget::Remote)
.map(|websocket_url| AppServerTarget::Remote {
websocket_url,
auth_token: remote_auth_token.clone(),
})
.unwrap_or(AppServerTarget::Embedded);
let (sandbox_mode, approval_policy) = if cli.full_auto {
(
@@ -904,6 +940,7 @@ pub async fn run_main(
cloud_requirements,
feedback,
remote_url,
remote_auth_token,
)
.await
.map_err(|err| std::io::Error::other(err.to_string()))
@@ -921,8 +958,9 @@ async fn run_ratatui_app(
mut cloud_requirements: CloudRequirementsLoader,
feedback: codex_feedback::CodexFeedback,
remote_url: Option<String>,
remote_auth_token: Option<String>,
) -> color_eyre::Result<AppExitInfo> {
let remote_mode = matches!(&app_server_target, AppServerTarget::Remote(_));
let remote_mode = matches!(&app_server_target, AppServerTarget::Remote { .. });
color_eyre::install()?;
tooltips::announcement::prewarm();
@@ -1326,6 +1364,7 @@ async fn run_ratatui_app(
should_show_trust_screen, // Proxy to: is it a first run in this directory?
should_prompt_windows_sandbox_nux_at_startup,
remote_url,
remote_auth_token,
)
.await;
@@ -1724,6 +1763,32 @@ mod tests {
);
}
#[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")
);
}
#[tokio::test]
async fn latest_session_lookup_params_keep_local_filters_for_embedded_sessions()
-> std::io::Result<()> {
+1
View File
@@ -27,6 +27,7 @@ fn main() -> anyhow::Result<()> {
arg0_paths,
codex_core::config_loader::LoaderOverrides::default(),
/*remote*/ None,
/*remote_auth_token*/ None,
)
.await?;
let token_usage = exit_info.token_usage;