diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index 1ea9f6fd87b..ad9eca65e75 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -868,8 +868,11 @@ mod tests { use tokio::net::TcpListener; use tokio::time::Duration; use tokio::time::timeout; - use tokio_tungstenite::accept_async; + use tokio_tungstenite::accept_hdr_async; use tokio_tungstenite::tungstenite::Message; + use tokio_tungstenite::tungstenite::handshake::server::Request as WebSocketRequest; + use tokio_tungstenite::tungstenite::handshake::server::Response as WebSocketResponse; + use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; async fn build_test_config() -> Config { match ConfigBuilder::default().build().await { @@ -908,6 +911,19 @@ mod tests { } async fn start_test_remote_server(handler: F) -> String + where + F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + + Send + + 'static, + Fut: std::future::Future + Send + 'static, + { + start_test_remote_server_with_auth(None, handler).await + } + + async fn start_test_remote_server_with_auth( + expected_auth_token: Option, + handler: F, + ) -> String where F: FnOnce(tokio_tungstenite::WebSocketStream) -> Fut + Send @@ -920,9 +936,23 @@ mod tests { let addr = listener.local_addr().expect("listener address"); tokio::spawn(async move { let (stream, _) = listener.accept().await.expect("accept should succeed"); - let websocket = accept_async(stream) - .await - .expect("websocket upgrade should succeed"); + let websocket = accept_hdr_async( + stream, + move |request: &WebSocketRequest, response: WebSocketResponse| { + let provided_auth_token = request + .headers() + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let expected_auth_token = expected_auth_token + .as_ref() + .map(|token| format!("Bearer {token}")); + assert_eq!(provided_auth_token, expected_auth_token); + Ok(response) + }, + ) + .await + .expect("websocket upgrade should succeed"); handler(websocket).await; }); format!("ws://{addr}") @@ -1037,6 +1067,7 @@ mod tests { fn test_remote_connect_args(websocket_url: String) -> RemoteAppServerConnectArgs { RemoteAppServerConnectArgs { websocket_url, + auth_token: None, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, @@ -1253,6 +1284,7 @@ mod tests { }), ) .await; + websocket.close(None).await.expect("close should succeed"); }) .await; let client = RemoteAppServerClient::connect(test_remote_connect_args(websocket_url)) @@ -1273,6 +1305,59 @@ mod tests { client.shutdown().await.expect("shutdown should complete"); } + #[tokio::test] + async fn remote_connect_includes_auth_header_when_configured() { + let auth_token = "remote-bearer-token".to_string(); + let websocket_url = start_test_remote_server_with_auth( + Some(auth_token.clone()), + |mut websocket| async move { + expect_remote_initialize(&mut websocket).await; + websocket.close(None).await.expect("close should succeed"); + }, + ) + .await; + let client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + auth_token: Some(auth_token), + ..test_remote_connect_args(websocket_url) + }) + .await + .expect("remote client should connect"); + + client.shutdown().await.expect("shutdown should complete"); + } + + #[tokio::test] + async fn remote_connect_rejects_non_loopback_ws_when_auth_configured() { + let result = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { + websocket_url: "ws://example.com:4500".to_string(), + auth_token: Some("remote-bearer-token".to_string()), + ..test_remote_connect_args("ws://127.0.0.1:1".to_string()) + }) + .await; + let err = match result { + Ok(_) => panic!("non-loopback ws should be rejected before connect"), + Err(err) => err, + }; + assert_eq!(err.kind(), ErrorKind::InvalidInput); + assert!( + err.to_string() + .contains("remote auth tokens require `wss://` or loopback `ws://` URLs") + ); + } + + #[test] + fn remote_auth_token_transport_policy_allows_wss_and_loopback_ws() { + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("wss://example.com:443").expect("wss URL should parse") + )); + assert!(crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://127.0.0.1:4500").expect("loopback ws URL should parse") + )); + assert!(!crate::remote::websocket_url_supports_auth_token( + &url::Url::parse("ws://example.com:4500").expect("non-loopback ws URL should parse") + )); + } + #[tokio::test] async fn remote_duplicate_request_id_keeps_original_waiter() { let (first_request_seen_tx, first_request_seen_rx) = tokio::sync::oneshot::channel(); @@ -1425,6 +1510,7 @@ mod tests { .await; let mut client = RemoteAppServerClient::connect(RemoteAppServerConnectArgs { websocket_url, + auth_token: None, client_name: "codex-app-server-client-test".to_string(), client_version: "0.0.0-test".to_string(), experimental_api: true, diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index 9cf37e262f9..a82b924e456 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -48,6 +48,9 @@ use tokio_tungstenite::MaybeTlsStream; use tokio_tungstenite::WebSocketStream; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; use tracing::warn; use url::Url; @@ -57,6 +60,7 @@ const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Clone)] pub struct RemoteAppServerConnectArgs { pub websocket_url: String, + pub auth_token: Option, pub client_name: String, pub client_version: String, pub experimental_api: bool, @@ -86,6 +90,16 @@ impl RemoteAppServerConnectArgs { } } +pub(crate) fn websocket_url_supports_auth_token(url: &Url) -> bool { + match (url.scheme(), url.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, + } +} + enum RemoteClientCommand { Request { request: Box, @@ -132,7 +146,31 @@ impl RemoteAppServerClient { format!("invalid websocket URL `{websocket_url}`: {err}"), ) })?; - let stream = timeout(CONNECT_TIMEOUT, connect_async(url.as_str())) + if args.auth_token.is_some() && !websocket_url_supports_auth_token(&url) { + return Err(IoError::new( + ErrorKind::InvalidInput, + format!( + "remote auth tokens require `wss://` or loopback `ws://` URLs; got `{websocket_url}`" + ), + )); + } + let mut request = url.as_str().into_client_request().map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid websocket URL `{websocket_url}`: {err}"), + ) + })?; + if let Some(auth_token) = args.auth_token.as_deref() { + let header_value = + HeaderValue::from_str(&format!("Bearer {auth_token}")).map_err(|err| { + IoError::new( + ErrorKind::InvalidInput, + format!("invalid remote authorization header value: {err}"), + ) + })?; + request.headers_mut().insert(AUTHORIZATION, header_value); + } + let stream = timeout(CONNECT_TIMEOUT, connect_async(request)) .await .map_err(|_| { IoError::new( diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 5e0d405eb30..12a531d35dc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -530,6 +530,11 @@ struct InteractiveRemoteOptions { /// Accepted forms: `ws://host:port` or `wss://host:port`. #[arg(long = "remote", value_name = "ADDR")] remote: Option, + + /// Name of the environment variable containing the bearer token to send to + /// a remote app server websocket. + #[arg(long = "remote-auth-token-env", value_name = "ENV_VAR")] + remote_auth_token_env: Option, } impl FeatureToggles { @@ -607,6 +612,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let toggle_overrides = feature_toggles.to_overrides()?; root_config_overrides.raw_overrides.extend(toggle_overrides); let root_remote = remote.remote; + let root_remote_auth_token_env = remote.remote_auth_token_env; match subcommand { None => { @@ -614,12 +620,21 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { &mut interactive.config_overrides, root_config_overrides.clone(), ); - let exit_info = - run_interactive_tui(interactive, root_remote.clone(), arg0_paths.clone()).await?; + let exit_info = run_interactive_tui( + interactive, + root_remote.clone(), + root_remote_auth_token_env.clone(), + arg0_paths.clone(), + ) + .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Exec(mut exec_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "exec")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "exec", + )?; prepend_config_flags( &mut exec_cli.config_overrides, root_config_overrides.clone(), @@ -627,7 +642,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::Review(review_args)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "review")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "review", + )?; let mut exec_cli = ExecCli::try_parse_from(["codex", "exec"])?; exec_cli.command = Some(ExecCommand::Review(review_args)); prepend_config_flags( @@ -637,11 +656,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { codex_exec::run_main(exec_cli, arg0_paths.clone()).await?; } Some(Subcommand::McpServer) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "mcp-server")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp-server", + )?; codex_mcp_server::run_main(arg0_paths.clone(), root_config_overrides).await?; } Some(Subcommand::Mcp(mut mcp_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "mcp")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "mcp", + )?; // Propagate any root-level config overrides (e.g. `-c key=value`). prepend_config_flags(&mut mcp_cli.config_overrides, root_config_overrides.clone()); mcp_cli.run().await?; @@ -653,9 +680,13 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { analytics_default_enabled, auth, } = app_server_cli; + reject_remote_mode_for_app_server_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + subcommand.as_ref(), + )?; match subcommand { None => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "app-server")?; let transport = listen; let auth = auth.try_into_settings()?; codex_app_server::run_main_with_transport( @@ -670,10 +701,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } Some(AppServerSubcommand::GenerateTs(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-ts", - )?; let options = codex_app_server_protocol::GenerateTsOptions { experimental_api: gen_cli.experimental, ..Default::default() @@ -685,10 +712,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { )?; } Some(AppServerSubcommand::GenerateJsonSchema(gen_cli)) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - "app-server generate-json-schema", - )?; codex_app_server_protocol::generate_json_with_experimental( &gen_cli.out_dir, gen_cli.experimental, @@ -701,7 +724,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } #[cfg(target_os = "macos")] Some(Subcommand::App(app_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "app")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "app", + )?; app_cmd::run_app(app_cli).await?; } Some(Subcommand::Resume(ResumeCommand { @@ -724,6 +751,9 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), arg0_paths.clone(), ) .await?; @@ -747,13 +777,20 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { let exit_info = run_interactive_tui( interactive, remote.remote.or(root_remote.clone()), + remote + .remote_auth_token_env + .or(root_remote_auth_token_env.clone()), arg0_paths.clone(), ) .await?; handle_app_exit(exit_info)?; } Some(Subcommand::Login(mut login_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "login")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "login", + )?; prepend_config_flags( &mut login_cli.config_overrides, root_config_overrides.clone(), @@ -785,7 +822,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } Some(Subcommand::Logout(mut logout_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "logout")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "logout", + )?; prepend_config_flags( &mut logout_cli.config_overrides, root_config_overrides.clone(), @@ -793,11 +834,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_logout(logout_cli.config_overrides).await; } Some(Subcommand::Completion(completion_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "completion")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "completion", + )?; print_completion(completion_cli); } Some(Subcommand::Cloud(mut cloud_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "cloud")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "cloud", + )?; prepend_config_flags( &mut cloud_cli.config_overrides, root_config_overrides.clone(), @@ -807,7 +856,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } Some(Subcommand::Sandbox(sandbox_args)) => match sandbox_args.cmd { SandboxCommand::Macos(mut seatbelt_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox macos")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox macos", + )?; prepend_config_flags( &mut seatbelt_cli.config_overrides, root_config_overrides.clone(), @@ -819,7 +872,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } SandboxCommand::Linux(mut landlock_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox linux")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox linux", + )?; prepend_config_flags( &mut landlock_cli.config_overrides, root_config_overrides.clone(), @@ -831,7 +888,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { .await?; } SandboxCommand::Windows(mut windows_cli) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "sandbox windows")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "sandbox windows", + )?; prepend_config_flags( &mut windows_cli.config_overrides, root_config_overrides.clone(), @@ -845,22 +906,38 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { }, Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand { DebugSubcommand::AppServer(cmd) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "debug app-server")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug app-server", + )?; run_debug_app_server_command(cmd).await?; } DebugSubcommand::ClearMemories => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "debug clear-memories")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "debug clear-memories", + )?; run_debug_clear_memories_command(&root_config_overrides, &interactive).await?; } }, Some(Subcommand::Execpolicy(ExecpolicyCommand { sub })) => match sub { ExecpolicySubcommand::Check(cmd) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "execpolicy check")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "execpolicy check", + )?; run_execpolicycheck(cmd)? } }, Some(Subcommand::Apply(mut apply_cli)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "apply")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "apply", + )?; prepend_config_flags( &mut apply_cli.config_overrides, root_config_overrides.clone(), @@ -868,19 +945,31 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { run_apply_command(apply_cli, /*cwd*/ None).await?; } Some(Subcommand::ResponsesApiProxy(args)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "responses-api-proxy")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "responses-api-proxy", + )?; tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } Some(Subcommand::StdioToUds(cmd)) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "stdio-to-uds")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "stdio-to-uds", + )?; let socket_path = cmd.socket_path; tokio::task::spawn_blocking(move || codex_stdio_to_uds::run(socket_path.as_path())) .await??; } Some(Subcommand::Features(FeaturesCli { sub })) => match sub { FeaturesSubcommand::List => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features list")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features list", + )?; // Respect root-level `-c` overrides plus top-level flags like `--profile`. let mut cli_kv_overrides = root_config_overrides .parse_overrides() @@ -923,11 +1012,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { } } FeaturesSubcommand::Enable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features enable")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features enable", + )?; enable_feature_in_config(&interactive, &feature).await?; } FeaturesSubcommand::Disable(FeatureSetArgs { feature }) => { - reject_remote_mode_for_subcommand(root_remote.as_deref(), "features disable")?; + reject_remote_mode_for_subcommand( + root_remote.as_deref(), + root_remote_auth_token_env.as_deref(), + "features disable", + )?; disable_feature_in_config(&interactive, &feature).await?; } }, @@ -1046,18 +1143,64 @@ fn prepend_config_flags( .splice(0..0, cli_config_overrides.raw_overrides); } -fn reject_remote_mode_for_subcommand(remote: Option<&str>, subcommand: &str) -> anyhow::Result<()> { +fn reject_remote_mode_for_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: &str, +) -> anyhow::Result<()> { if let Some(remote) = remote { anyhow::bail!( "`--remote {remote}` is only supported for interactive TUI commands, not `codex {subcommand}`" ); } + if remote_auth_token_env.is_some() { + anyhow::bail!( + "`--remote-auth-token-env` is only supported for interactive TUI commands, not `codex {subcommand}`" + ); + } Ok(()) } +fn reject_remote_mode_for_app_server_subcommand( + remote: Option<&str>, + remote_auth_token_env: Option<&str>, + subcommand: Option<&AppServerSubcommand>, +) -> anyhow::Result<()> { + let subcommand_name = match subcommand { + None => "app-server", + Some(AppServerSubcommand::GenerateTs(_)) => "app-server generate-ts", + Some(AppServerSubcommand::GenerateJsonSchema(_)) => "app-server generate-json-schema", + Some(AppServerSubcommand::GenerateInternalJsonSchema(_)) => { + "app-server generate-internal-json-schema" + } + }; + reject_remote_mode_for_subcommand(remote, remote_auth_token_env, subcommand_name) +} + +fn read_remote_auth_token_from_env_var_with( + env_var_name: &str, + get_var: F, +) -> anyhow::Result +where + F: FnOnce(&str) -> Result, +{ + let auth_token = get_var(env_var_name) + .map_err(|_| anyhow::anyhow!("environment variable `{env_var_name}` is not set"))?; + let auth_token = auth_token.trim().to_string(); + if auth_token.is_empty() { + anyhow::bail!("environment variable `{env_var_name}` is empty"); + } + Ok(auth_token) +} + +fn read_remote_auth_token_from_env_var(env_var_name: &str) -> anyhow::Result { + read_remote_auth_token_from_env_var_with(env_var_name, |name| std::env::var(name)) +} + async fn run_interactive_tui( mut interactive: TuiCli, remote: Option, + remote_auth_token_env: Option, arg0_paths: Arg0DispatchPaths, ) -> std::io::Result { if let Some(prompt) = interactive.prompt.take() { @@ -1089,17 +1232,28 @@ async fn run_interactive_tui( .map(codex_tui_app_server::normalize_remote_addr) .transpose() .map_err(std::io::Error::other)?; + if remote_auth_token_env.is_some() && normalized_remote.is_none() { + return Ok(AppExitInfo::fatal( + "`--remote-auth-token-env` requires `--remote`.", + )); + } if normalized_remote.is_some() && !use_app_server_tui { return Ok(AppExitInfo::fatal( "`--remote` requires the `tui_app_server` feature flag to be enabled.", )); } if use_app_server_tui { + let remote_auth_token = remote_auth_token_env + .as_deref() + .map(read_remote_auth_token_from_env_var) + .transpose() + .map_err(std::io::Error::other)?; codex_tui_app_server::run_main( into_app_server_tui_cli(interactive), arg0_paths, codex_core::config_loader::LoaderOverrides::default(), normalized_remote, + remote_auth_token, ) .await .map(into_legacy_app_exit_info) @@ -1661,6 +1815,22 @@ mod tests { assert_eq!(cli.remote.remote.as_deref(), Some("ws://127.0.0.1:4500")); } + #[test] + fn remote_auth_token_env_flag_parses_for_interactive_root() { + let cli = MultitoolCli::try_parse_from([ + "codex", + "--remote-auth-token-env", + "CODEX_REMOTE_AUTH_TOKEN", + "--remote", + "ws://127.0.0.1:4500", + ]) + .expect("parse"); + assert_eq!( + cli.remote.remote_auth_token_env.as_deref(), + Some("CODEX_REMOTE_AUTH_TOKEN") + ); + } + #[test] fn remote_flag_parses_for_resume_subcommand() { let cli = @@ -1676,7 +1846,7 @@ mod tests { #[test] fn reject_remote_mode_for_non_interactive_subcommands() { - let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), "exec") + let err = reject_remote_mode_for_subcommand(Some("127.0.0.1:4500"), None, "exec") .expect_err("non-interactive subcommands should reject --remote"); assert!( err.to_string() @@ -1684,6 +1854,59 @@ mod tests { ); } + #[test] + fn reject_remote_auth_token_env_for_non_interactive_subcommands() { + let err = reject_remote_mode_for_subcommand(None, Some("CODEX_REMOTE_AUTH_TOKEN"), "exec") + .expect_err("non-interactive subcommands should reject --remote-auth-token-env"); + assert!( + err.to_string() + .contains("only supported for interactive TUI commands") + ); + } + + #[test] + fn reject_remote_auth_token_env_for_app_server_generate_internal_json_schema() { + let subcommand = + AppServerSubcommand::GenerateInternalJsonSchema(GenerateInternalJsonSchemaCommand { + out_dir: PathBuf::from("/tmp/out"), + }); + let err = reject_remote_mode_for_app_server_subcommand( + None, + Some("CODEX_REMOTE_AUTH_TOKEN"), + Some(&subcommand), + ) + .expect_err("non-interactive app-server subcommands should reject --remote-auth-token-env"); + assert!(err.to_string().contains("generate-internal-json-schema")); + } + + #[test] + fn read_remote_auth_token_from_env_var_reports_missing_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Err(std::env::VarError::NotPresent) + }) + .expect_err("missing env vars should be rejected"); + assert!(err.to_string().contains("is not set")); + } + + #[test] + fn read_remote_auth_token_from_env_var_trims_values() { + let auth_token = + read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" bearer-token ".to_string()) + }) + .expect("env var should parse"); + assert_eq!(auth_token, "bearer-token"); + } + + #[test] + fn read_remote_auth_token_from_env_var_rejects_empty_values() { + let err = read_remote_auth_token_from_env_var_with("CODEX_REMOTE_AUTH_TOKEN", |_| { + Ok(" \n\t ".to_string()) + }) + .expect_err("empty env vars should be rejected"); + assert!(err.to_string().contains("is empty")); + } + #[test] fn app_server_listen_websocket_url_parses() { let app_server = app_server_from_args( diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs index a784d3bc233..c563366214e 100644 --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs @@ -92,6 +92,7 @@ fn main() -> anyhow::Result<()> { arg0_paths, codex_core::config_loader::LoaderOverrides::default(), /*remote*/ None, + /*remote_auth_token*/ None, ) .await?, ) diff --git a/codex-rs/tui_app_server/src/app.rs b/codex-rs/tui_app_server/src/app.rs index 452a1cfbd3a..60ca5c7d637 100644 --- a/codex-rs/tui_app_server/src/app.rs +++ b/codex-rs/tui_app_server/src/app.rs @@ -958,6 +958,7 @@ pub(crate) struct App { pub(crate) feedback: codex_feedback::CodexFeedback, feedback_audience: FeedbackAudience, remote_app_server_url: Option, + remote_app_server_auth_token: Option, /// Set when the user confirms an update; propagated on exit. pub(crate) pending_update_action: Option, @@ -3119,6 +3120,7 @@ impl App { is_first_run: bool, should_prompt_windows_sandbox_nux_at_startup: bool, remote_app_server_url: Option, + remote_app_server_auth_token: Option, ) -> Result { use tokio_stream::StreamExt; let (app_event_tx, mut app_event_rx) = unbounded_channel(); @@ -3330,6 +3332,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(), @@ -3578,7 +3581,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, }, ) @@ -8086,6 +8092,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(), @@ -8138,6 +8145,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(), diff --git a/codex-rs/tui_app_server/src/lib.rs b/codex-rs/tui_app_server/src/lib.rs index 4a36eb58c6f..c5e2479bf4b 100644 --- a/codex-rs/tui_app_server/src/lib.rs +++ b/codex-rs/tui_app_server/src/lib.rs @@ -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, + }, } 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 { let parsed = match Url::parse(addr) { Ok(parsed) => parsed, @@ -340,9 +353,24 @@ pub fn normalize_remote_addr(addr: &str) -> color_eyre::Result { ); } -async fn connect_remote_app_server(websocket_url: String) -> color_eyre::Result { +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, +) -> color_eyre::Result { 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, + remote_auth_token: Option, ) -> std::io::Result { 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, + remote_auth_token: Option, ) -> color_eyre::Result { - 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<()> { diff --git a/codex-rs/tui_app_server/src/main.rs b/codex-rs/tui_app_server/src/main.rs index feda31b0b73..ae611114373 100644 --- a/codex-rs/tui_app_server/src/main.rs +++ b/codex-rs/tui_app_server/src/main.rs @@ -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;