From 4ceaf2afa7d1cc0adea16cb47b7cf8bd771af0ca Mon Sep 17 00:00:00 2001 From: Eugene Brevdo Date: Wed, 6 May 2026 15:32:31 -0700 Subject: [PATCH] Add remote control instance naming Allow app-server remote control enrollment to use a per-instance name while keeping the hostname in the displayed server name. Use the raw instance name as the local enrollment discriminator so concurrent app-server instances do not collide with the default enrollment. Co-authored-by: Codex --- .../src/transport/remote_control/enroll.rs | 8 ++ .../src/transport/remote_control/mod.rs | 2 + .../src/transport/remote_control/tests.rs | 115 ++++++++++++++++++ .../src/transport/remote_control/websocket.rs | 27 +++- codex-rs/app-server/src/lib.rs | 5 +- codex-rs/app-server/src/main.rs | 5 + codex-rs/cli/src/main.rs | 29 ++++- 7 files changed, 185 insertions(+), 6 deletions(-) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs index fb7f727b8307..45eae6f05c7f 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/enroll.rs @@ -193,9 +193,16 @@ pub(crate) fn format_headers(headers: &HeaderMap) -> String { pub(super) async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, + remote_control_instance_name: Option<&str>, ) -> io::Result { let enroll_url = &remote_control_target.enroll_url; let server_name = gethostname().to_string_lossy().trim().to_string(); + let server_name = match remote_control_instance_name { + Some(remote_control_instance_name) => { + format!("{server_name} - {remote_control_instance_name}") + } + None => server_name, + }; let request = EnrollRemoteServerRequest { name: server_name.clone(), os: std::env::consts::OS, @@ -459,6 +466,7 @@ mod tests { auth_provider: codex_model_provider::unauthenticated_auth_provider(), account_id: "account_id".to_string(), }, + /*remote_control_instance_name*/ None, ) .await .expect_err("invalid response should fail to parse"); diff --git a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs index 87405efa4f81..312020be4977 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/mod.rs @@ -67,6 +67,7 @@ pub async fn start_remote_control( auth_manager: Arc, transport_event_tx: mpsc::Sender, shutdown_token: CancellationToken, + remote_control_instance_name: Option, app_server_client_name_rx: Option>, initial_enabled: bool, ) -> io::Result<(JoinHandle<()>, RemoteControlHandle)> { @@ -105,6 +106,7 @@ pub async fn start_remote_control( }, shutdown_token, enabled_rx, + remote_control_instance_name, ) .run(app_server_client_name_rx) .await; diff --git a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs index 5fd3caa401b8..339ab6f4dd09 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/tests.rs @@ -178,6 +178,7 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages() remote_control_auth_manager(), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -454,6 +455,7 @@ async fn remote_control_transport_reconnects_after_disconnect() { remote_control_auth_manager(), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -533,6 +535,7 @@ async fn remote_control_start_allows_remote_control_invalid_url_when_disabled() remote_control_auth_manager(), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ false, ) @@ -569,6 +572,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { auth_manager, transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -601,6 +605,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( remote_control_auth_manager(), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -650,6 +655,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() { remote_control_auth_manager(), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -734,6 +740,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() { remote_control_auth_manager(), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -909,6 +916,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() { remote_control_auth_manager(), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -1098,6 +1106,109 @@ async fn remote_control_http_mode_enrolls_before_connecting() { let _ = remote_task.await; } +#[tokio::test] +async fn remote_control_http_mode_uses_instance_name_for_enrollment() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let remote_control_url = remote_control_url_for_listener(&listener); + let codex_home = TempDir::new().expect("temp dir should create"); + let state_db = remote_control_state_runtime(&codex_home).await; + let remote_control_target = + normalize_remote_control_url(&remote_control_url).expect("target should parse"); + let default_enrollment = RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_default".to_string(), + server_id: "srv_e_default".to_string(), + server_name: "default-server".to_string(), + }; + update_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + Some(&default_enrollment), + ) + .await + .expect("default persisted enrollment should save"); + + let instance_name = "next-build"; + let hostname = gethostname().to_string_lossy().trim().to_string(); + let expected_server_name = format!("{hostname} - {instance_name}"); + let (transport_event_tx, _transport_event_rx) = + mpsc::channel::(CHANNEL_CAPACITY); + let shutdown_token = CancellationToken::new(); + let (remote_task, _remote_handle) = start_remote_control( + remote_control_url, + Some(state_db.clone()), + remote_control_auth_manager_with_home(&codex_home), + transport_event_tx, + shutdown_token.clone(), + Some(instance_name.to_string()), + /*app_server_client_name_rx*/ None, + /*initial_enabled*/ true, + ) + .await + .expect("remote control should start"); + + let enroll_request = accept_http_request(&listener).await; + assert_eq!( + serde_json::from_str::(&enroll_request.body) + .expect("enroll body should deserialize"), + json!({ + "name": expected_server_name, + "os": std::env::consts::OS, + "arch": std::env::consts::ARCH, + "app_server_version": env!("CARGO_PKG_VERSION"), + }) + ); + respond_with_json( + enroll_request.stream, + json!({ "server_id": "srv_e_instance", "environment_id": "env_instance" }), + ) + .await; + + let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await; + assert_eq!( + handshake_request.headers.get("x-codex-server-id"), + Some(&"srv_e_instance".to_string()) + ); + assert_eq!( + handshake_request.headers.get("x-codex-name"), + Some(&base64::engine::general_purpose::STANDARD.encode(&expected_server_name)) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + Some(instance_name), + ) + .await + .expect("instance persisted enrollment should load"), + Some(RemoteControlEnrollment { + account_id: "account_id".to_string(), + environment_id: "env_instance".to_string(), + server_id: "srv_e_instance".to_string(), + server_name: expected_server_name, + }) + ); + assert_eq!( + load_persisted_remote_control_enrollment( + Some(state_db.as_ref()), + &remote_control_target, + "account_id", + /*app_server_client_name*/ None, + ) + .await + .expect("default persisted enrollment should load"), + Some(default_enrollment) + ); + + shutdown_token.cancel(); + let _ = remote_task.await; +} + #[tokio::test] async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling() { let listener = TcpListener::bind("127.0.0.1:0") @@ -1133,6 +1244,7 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling remote_control_auth_manager_with_home(&codex_home), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -1201,6 +1313,7 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() { remote_control_auth_manager_with_home(&codex_home), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, Some(app_server_client_name_rx), /*initial_enabled*/ true, ) @@ -1260,6 +1373,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { auth_manager, transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) @@ -1343,6 +1457,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404() remote_control_auth_manager_with_home(&codex_home), transport_event_tx, shutdown_token.clone(), + /*remote_control_instance_name*/ None, /*app_server_client_name_rx*/ None, /*initial_enabled*/ true, ) diff --git a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs index f7b49b72ec3d..ac2f1ec1a8ea 100644 --- a/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server-transport/src/transport/remote_control/websocket.rs @@ -217,6 +217,7 @@ pub(crate) struct RemoteControlWebsocket { remote_control_target: Option, state_db: Option>, auth_manager: Arc, + remote_control_instance_name: Option, status_publisher: RemoteControlStatusPublisher, shutdown_token: CancellationToken, reconnect_attempt: u64, @@ -292,6 +293,7 @@ impl RemoteControlStatusPublisher { pub(super) struct RemoteControlConnectOptions<'a> { subscribe_cursor: Option<&'a str>, app_server_client_name: Option<&'a str>, + remote_control_instance_name: Option<&'a str>, } impl RemoteControlWebsocket { @@ -303,6 +305,7 @@ impl RemoteControlWebsocket { channels: RemoteControlChannels, shutdown_token: CancellationToken, enabled_rx: watch::Receiver, + remote_control_instance_name: Option, ) -> Self { let shutdown_token = shutdown_token.child_token(); let (server_event_tx, server_event_rx) = mpsc::channel(super::CHANNEL_CAPACITY); @@ -319,6 +322,7 @@ impl RemoteControlWebsocket { remote_control_target, state_db, auth_manager, + remote_control_instance_name, status_publisher: channels.status_publisher, shutdown_token, reconnect_attempt: 0, @@ -444,6 +448,7 @@ impl RemoteControlWebsocket { let connect_options = RemoteControlConnectOptions { subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, + remote_control_instance_name: self.remote_control_instance_name.as_deref(), }; let connect_result = tokio::select! { _ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown, @@ -1029,6 +1034,9 @@ pub(super) async fn connect_remote_control_websocket( return Err(err); } }; + let enrollment_app_server_client_name = connect_options + .remote_control_instance_name + .or(connect_options.app_server_client_name); let enrollment_account_id = enrollment.as_ref().map(|enrollment| &enrollment.account_id); if enrollment_account_id.is_some_and(|account_id| account_id != &auth.account_id) { info!( @@ -1052,7 +1060,7 @@ pub(super) async fn connect_remote_control_websocket( Some(state_db), remote_control_target, &auth.account_id, - connect_options.app_server_client_name, + enrollment_app_server_client_name, ) .await?; if let Some(loaded_enrollment) = loaded_enrollment.as_ref() { @@ -1066,7 +1074,12 @@ pub(super) async fn connect_remote_control_websocket( "creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}", remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id ); - let new_enrollment = match enroll_remote_control_server(remote_control_target, &auth).await + let new_enrollment = match enroll_remote_control_server( + remote_control_target, + &auth, + connect_options.remote_control_instance_name, + ) + .await { Ok(new_enrollment) => new_enrollment, Err(err) @@ -1083,7 +1096,7 @@ pub(super) async fn connect_remote_control_websocket( Some(state_db), remote_control_target, &auth.account_id, - connect_options.app_server_client_name, + enrollment_app_server_client_name, Some(&new_enrollment), ) .await @@ -1129,7 +1142,7 @@ pub(super) async fn connect_remote_control_websocket( Some(state_db), remote_control_target, &auth.account_id, - connect_options.app_server_client_name, + enrollment_app_server_client_name, /*enrollment*/ None, ) .await @@ -1361,6 +1374,7 @@ mod tests { RemoteControlConnectOptions { subscribe_cursor: None, app_server_client_name: None, + remote_control_instance_name: None, }, &status_publisher, ) @@ -1437,6 +1451,7 @@ mod tests { RemoteControlConnectOptions { subscribe_cursor: None, app_server_client_name: None, + remote_control_instance_name: None, }, &status_publisher, ) @@ -1517,6 +1532,7 @@ mod tests { RemoteControlConnectOptions { subscribe_cursor: None, app_server_client_name: None, + remote_control_instance_name: None, }, &status_publisher, ) @@ -1569,6 +1585,7 @@ mod tests { RemoteControlConnectOptions { subscribe_cursor: None, app_server_client_name: None, + remote_control_instance_name: None, }, &status_publisher, ) @@ -1616,6 +1633,7 @@ mod tests { RemoteControlConnectOptions { subscribe_cursor: None, app_server_client_name: None, + remote_control_instance_name: None, }, &status_publisher, ) @@ -1666,6 +1684,7 @@ mod tests { }, shutdown_token, enabled_rx, + /*remote_control_instance_name*/ None, ) .run(/*app_server_client_name_rx*/ None) .await diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index c7efae69c615..8efe210f8396 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -373,15 +373,17 @@ pub enum PluginStartupTasks { Skip, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct AppServerRuntimeOptions { pub plugin_startup_tasks: PluginStartupTasks, + pub remote_control_instance_name: Option, } impl Default for AppServerRuntimeOptions { fn default() -> Self { Self { plugin_startup_tasks: PluginStartupTasks::Start, + remote_control_instance_name: None, } } } @@ -681,6 +683,7 @@ pub async fn run_main_with_transport_options( auth_manager.clone(), transport_event_tx.clone(), transport_shutdown_token.clone(), + runtime_options.remote_control_instance_name, app_server_client_name_rx, remote_control_enabled, ) diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index 1cb4bd9a8e03..4c7dac6c43c2 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -39,6 +39,10 @@ struct AppServerArgs { #[command(flatten)] auth: AppServerWebsocketAuthArgs, + /// Distinguish this remote-control app-server instance from others on the same machine. + #[arg(long = "remote-control-instance-name", value_name = "NAME")] + remote_control_instance_name: Option, + /// Hidden debug-only test hook used by integration tests that spawn the /// production app-server binary. #[cfg(debug_assertions)] @@ -60,6 +64,7 @@ fn main() -> anyhow::Result<()> { let session_source = args.session_source; let auth = args.auth.try_into_settings()?; let mut runtime_options = AppServerRuntimeOptions::default(); + runtime_options.remote_control_instance_name = args.remote_control_instance_name; #[cfg(debug_assertions)] if args.disable_plugin_startup_tasks_for_tests { runtime_options.plugin_startup_tasks = PluginStartupTasks::Skip; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index f3545629c1b9..8dae7721d3a7 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -451,6 +451,10 @@ struct AppServerCommand { #[arg(long = "analytics-default-enabled")] analytics_default_enabled: bool, + /// Distinguish this remote-control app-server instance from others on the same machine. + #[arg(long = "remote-control-instance-name", value_name = "NAME")] + remote_control_instance_name: Option, + #[command(flatten)] auth: codex_app_server::AppServerWebsocketAuthArgs, } @@ -862,6 +866,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { subcommand, listen, analytics_default_enabled, + remote_control_instance_name, auth, } = app_server_cli; reject_remote_mode_for_app_server_subcommand( @@ -873,7 +878,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { None => { let transport = listen; let auth = auth.try_into_settings()?; - codex_app_server::run_main_with_transport( + let runtime_options = codex_app_server::AppServerRuntimeOptions { + remote_control_instance_name, + ..Default::default() + }; + codex_app_server::run_main_with_transport_options( arg0_paths.clone(), root_config_overrides, codex_config::LoaderOverrides::default(), @@ -881,6 +890,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { transport, codex_protocol::protocol::SessionSource::VSCode, auth, + runtime_options, ) .await?; } @@ -2296,6 +2306,23 @@ mod tests { assert!(app_server.analytics_default_enabled); } + #[test] + fn app_server_remote_control_instance_name_parses() { + let app_server = app_server_from_args( + [ + "codex", + "app-server", + "--remote-control-instance-name", + "next-build", + ] + .as_ref(), + ); + assert_eq!( + app_server.remote_control_instance_name.as_deref(), + Some("next-build") + ); + } + #[test] fn remote_flag_parses_for_interactive_root() { let cli = MultitoolCli::try_parse_from(["codex", "--remote", "ws://127.0.0.1:4500"])