From 6a331a66eb1d526f9b647b07fadf0c2e2833a3d2 Mon Sep 17 00:00:00 2001 From: Owen Lin Date: Fri, 15 May 2026 14:33:24 -0700 Subject: [PATCH] feat(app-server): update remote control APIs for better UX (#22877) ## Why To help improve `codex remote-control` CLI UX which I plan to do in a followup, this PR adds `server-name` to the various remote control APIs: - `remoteControl/enable` - `remoteControl/disable` - `remoteControl/status/changed` Also, add a `remoteControl/status/read` API. This will be helpful in the Codex App. --- .../schema/json/ServerNotification.json | 4 + .../codex_app_server_protocol.schemas.json | 4 + .../codex_app_server_protocol.v2.schemas.json | 4 + ...emoteControlStatusChangedNotification.json | 4 + .../RemoteControlStatusChangedNotification.ts | 2 +- .../src/protocol/common.rs | 6 + .../src/protocol/v2/remote_control.rs | 17 ++ .../src/transport/remote_control/enroll.rs | 8 +- .../src/transport/remote_control/mod.rs | 31 ++-- .../src/transport/remote_control/tests.rs | 11 ++ .../src/transport/remote_control/websocket.rs | 38 +++-- codex-rs/app-server/README.md | 3 +- codex-rs/app-server/src/lib.rs | 10 +- codex-rs/app-server/src/message_processor.rs | 4 + .../remote_control_processor.rs | 11 ++ .../app-server/tests/common/mcp_process.rs | 6 + .../tests/suite/v2/remote_control.rs | 146 ++++++++++++++++++ 17 files changed, 276 insertions(+), 33 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 6af19f89e2..a5fce14a4f 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -2748,12 +2748,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 935ec98e88..d1b318889b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -13446,12 +13446,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/v2/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ccf68b28d8..bb926075ee 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -9995,12 +9995,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json index 85be3316d7..2f305c7d78 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlStatusChangedNotification.json @@ -22,12 +22,16 @@ "installationId": { "type": "string" }, + "serverName": { + "type": "string" + }, "status": { "$ref": "#/definitions/RemoteControlConnectionStatus" } }, "required": [ "installationId", + "serverName", "status" ], "title": "RemoteControlStatusChangedNotification", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts index 8c63ab9029..403b0e6457 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlStatusChangedNotification.ts @@ -6,4 +6,4 @@ import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionSta /** * Current remote-control connection status and remote identity exposed to clients. */ -export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, installationId: string, environmentId: string | null, }; +export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, serverName: string, installationId: string, environmentId: string | null, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index f7e04b3dc5..9681f70c34 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -812,6 +812,12 @@ client_request_definitions! { serialization: global("remote-control"), response: v2::RemoteControlDisableResponse, }, + #[experimental("remoteControl/status/read")] + RemoteControlStatusRead => "remoteControl/status/read" { + params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>, + serialization: global_shared_read("remote-control"), + response: v2::RemoteControlStatusReadResponse, + }, #[experimental("collaborationMode/list")] /// Lists collaboration mode presets. CollaborationModeList => "collaborationMode/list" { diff --git a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs index e89a9d19b8..c8ad617e7d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/remote_control.rs @@ -9,6 +9,7 @@ use ts_rs::TS; #[ts(export_to = "v2/")] pub struct RemoteControlStatusChangedNotification { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -18,6 +19,7 @@ pub struct RemoteControlStatusChangedNotification { #[ts(export_to = "v2/")] pub struct RemoteControlEnableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -27,6 +29,17 @@ pub struct RemoteControlEnableResponse { #[ts(export_to = "v2/")] pub struct RemoteControlDisableResponse { pub status: RemoteControlConnectionStatus, + pub server_name: String, + pub installation_id: String, + pub environment_id: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct RemoteControlStatusReadResponse { + pub status: RemoteControlConnectionStatus, + pub server_name: String, pub installation_id: String, pub environment_id: Option, } @@ -45,11 +58,13 @@ impl From for RemoteControlEnableRespons fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } @@ -60,11 +75,13 @@ impl From for RemoteControlDisableRespon fn from(notification: RemoteControlStatusChangedNotification) -> Self { let RemoteControlStatusChangedNotification { status, + server_name, installation_id, environment_id, } = notification; Self { status, + server_name, installation_id, environment_id, } 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 60dfc3d845..9ec6e4d004 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 @@ -6,7 +6,6 @@ use codex_api::SharedAuthProvider; use codex_login::default_client::build_reqwest_client; use codex_state::RemoteControlEnrollmentRecord; use codex_state::StateRuntime; -use gethostname::gethostname; use std::io; use std::io::ErrorKind; use tracing::info; @@ -195,11 +194,11 @@ pub(super) async fn enroll_remote_control_server( remote_control_target: &RemoteControlTarget, auth: &RemoteControlConnectionAuth, installation_id: &str, + server_name: &str, ) -> io::Result { let enroll_url = &remote_control_target.enroll_url; - let server_name = gethostname().to_string_lossy().trim().to_string(); let request = EnrollRemoteServerRequest { - name: server_name.clone(), + name: server_name.to_string(), os: std::env::consts::OS, arch: std::env::consts::ARCH, app_server_version: env!("CARGO_PKG_VERSION"), @@ -255,7 +254,7 @@ pub(super) async fn enroll_remote_control_server( account_id: auth.account_id.clone(), environment_id: enrollment.environment_id, server_id: enrollment.server_id, - server_name, + server_name: server_name.to_string(), }) } @@ -464,6 +463,7 @@ mod tests { account_id: "account_id".to_string(), }, "11111111-1111-4111-8111-111111111111", + "test-server", ) .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 8722f4e9ee..ae6d79462c 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 @@ -19,6 +19,7 @@ use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_login::AuthManager; use codex_state::StateRuntime; +use gethostname::gethostname; use std::error::Error; use std::fmt; use std::io; @@ -112,15 +113,8 @@ impl RemoteControlHandle { connection_status: RemoteControlConnectionStatus, ) -> RemoteControlStatusChangedNotification { self.status_tx.send_if_modified(|status| { - let next_status = RemoteControlStatusChangedNotification { - status: connection_status, - installation_id: status.installation_id.clone(), - environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { - None - } else { - status.environment_id.clone() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } @@ -132,6 +126,22 @@ impl RemoteControlHandle { } } +fn remote_control_status_with_connection_status( + status: &RemoteControlStatusChangedNotification, + connection_status: RemoteControlConnectionStatus, +) -> RemoteControlStatusChangedNotification { + RemoteControlStatusChangedNotification { + status: connection_status, + server_name: status.server_name.clone(), + installation_id: status.installation_id.clone(), + environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { + None + } else { + status.environment_id.clone() + }, + } +} + pub async fn start_remote_control( config: RemoteControlStartConfig, state_db: Option>, @@ -154,12 +164,14 @@ pub async fn start_remote_control( }; let (enabled_tx, enabled_rx) = watch::channel(initial_enabled); + let server_name = gethostname().to_string_lossy().trim().to_string(); let initial_status = RemoteControlStatusChangedNotification { status: if initial_enabled { RemoteControlConnectionStatus::Connecting } else { RemoteControlConnectionStatus::Disabled }, + server_name: server_name.clone(), installation_id: config.installation_id.clone(), environment_id: None, }; @@ -171,6 +183,7 @@ pub async fn start_remote_control( remote_control_url: config.remote_control_url, installation_id: config.installation_id, remote_control_target, + server_name, }, state_db, auth_manager, 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 3dcfb81ce5..fb1512fedb 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 @@ -121,6 +121,10 @@ fn remote_control_url_for_listener(listener: &TcpListener) -> String { format!("http://{addr}/backend-api/") } +fn test_server_name() -> String { + gethostname().to_string_lossy().trim().to_string() +} + async fn expect_remote_control_status( status_rx: &mut watch::Receiver, expected_status: Option, @@ -134,6 +138,7 @@ async fn expect_remote_control_status( if let Some(expected_status) = expected_status { assert_eq!(status.status, expected_status); } + assert_eq!(status.server_name, test_server_name()); assert_eq!(status.installation_id, TEST_INSTALLATION_ID); assert_eq!(status.environment_id.as_deref(), expected_environment_id); } @@ -630,6 +635,7 @@ async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled( status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -698,6 +704,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), }, @@ -708,6 +715,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.disable(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -716,6 +724,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, @@ -732,6 +741,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { remote_handle.enable().expect("enable should succeed"), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -740,6 +750,7 @@ async fn remote_control_handle_enable_disable_stops_and_restarts_connections() { &mut status_rx, RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: test_server_name(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }, 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 472639bc68..f117aec3b4 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 @@ -15,6 +15,7 @@ use super::protocol::ClientId; use super::protocol::RemoteControlTarget; use super::protocol::ServerEnvelope; use super::protocol::StreamId; +use super::remote_control_status_with_connection_status; use super::segment::ClientSegmentObservation; use super::segment::ClientSegmentReassembler; use super::segment::REMOTE_CONTROL_SEGMENT_MAX_BYTES; @@ -216,6 +217,7 @@ impl WebsocketState { pub(crate) struct RemoteControlWebsocket { remote_control_url: String, installation_id: String, + server_name: String, remote_control_target: Option, state_db: Option>, auth_manager: Arc, @@ -235,6 +237,7 @@ pub(crate) struct RemoteControlWebsocketConfig { pub(crate) remote_control_url: String, pub(crate) installation_id: String, pub(crate) remote_control_target: Option, + pub(crate) server_name: String, } enum ConnectOutcome { @@ -260,15 +263,8 @@ impl RemoteControlStatusPublisher { fn publish_status(&self, connection_status: RemoteControlConnectionStatus) { self.tx.send_if_modified(|status| { - let next_status = RemoteControlStatusChangedNotification { - status: connection_status, - installation_id: status.installation_id.clone(), - environment_id: if connection_status == RemoteControlConnectionStatus::Disabled { - None - } else { - status.environment_id.clone() - }, - }; + let next_status = + remote_control_status_with_connection_status(status, connection_status); if *status == next_status { return false; } @@ -285,6 +281,7 @@ impl RemoteControlStatusPublisher { } let next_status = RemoteControlStatusChangedNotification { status: status.status, + server_name: status.server_name.clone(), installation_id: status.installation_id.clone(), environment_id, }; @@ -301,6 +298,7 @@ impl RemoteControlStatusPublisher { #[derive(Clone, Copy)] pub(super) struct RemoteControlConnectOptions<'a> { installation_id: &'a str, + server_name: &'a str, subscribe_cursor: Option<&'a str>, app_server_client_name: Option<&'a str>, } @@ -327,6 +325,7 @@ impl RemoteControlWebsocket { Self { remote_control_url: config.remote_control_url, installation_id: config.installation_id, + server_name: config.server_name, remote_control_target: config.remote_control_target, state_db, auth_manager, @@ -454,6 +453,7 @@ impl RemoteControlWebsocket { let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone(); let connect_options = RemoteControlConnectOptions { installation_id: &self.installation_id, + server_name: &self.server_name, subscribe_cursor: subscribe_cursor.as_deref(), app_server_client_name, }; @@ -1076,7 +1076,10 @@ pub(super) async fn connect_remote_control_websocket( if let Some(loaded_enrollment) = loaded_enrollment.as_ref() { status_publisher.publish_environment_id(Some(loaded_enrollment.environment_id.clone())); } - *enrollment = loaded_enrollment; + *enrollment = loaded_enrollment.map(|mut enrollment| { + enrollment.server_name = connect_options.server_name.to_string(); + enrollment + }); } if enrollment.is_none() { @@ -1088,6 +1091,7 @@ pub(super) async fn connect_remote_control_websocket( remote_control_target, &auth, connect_options.installation_id, + connect_options.server_name, ) .await { @@ -1279,6 +1283,7 @@ mod tests { ) { let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, }); @@ -1386,6 +1391,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1403,6 +1409,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } @@ -1464,6 +1471,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1477,6 +1485,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_test".to_string()), } @@ -1546,6 +1555,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1599,6 +1609,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1647,6 +1658,7 @@ mod tests { &mut enrollment, RemoteControlConnectOptions { installation_id: TEST_INSTALLATION_ID, + server_name: "test-server", subscribe_cursor: None, app_server_client_name: None, }, @@ -1665,6 +1677,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1694,6 +1707,7 @@ mod tests { remote_control_url, installation_id: TEST_INSTALLATION_ID.to_string(), remote_control_target: Some(remote_control_target), + server_name: "test-server".to_string(), }, /*state_db*/ None, remote_control_auth_manager(), @@ -1738,6 +1752,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connecting, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1759,6 +1774,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: Some("env_first".to_string()), } @@ -1773,6 +1789,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Connected, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } @@ -1788,6 +1805,7 @@ mod tests { status_rx.borrow().clone(), RemoteControlStatusChangedNotification { status: RemoteControlConnectionStatus::Disabled, + server_name: "test-server".to_string(), installation_id: TEST_INSTALLATION_ID.to_string(), environment_id: None, } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4d21eb2846..8e3a23f0f7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -204,7 +204,8 @@ Example with notification opt-out: - `app/list` — list available apps. - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. The caller is responsible for persisting the desired setting outside app-server. - `remoteControl/disable` — experimental; disable remote control for the current app-server process and return the current remote-control status snapshot. This does not revoke already enrolled controller devices. -- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. +- `remoteControl/status/read` — experimental; read the current remote-control status snapshot. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. +- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `serverName` is the local machine name used by this app-server process; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot. - `skills/config/write` — write user-level skill config by name or absolute path. - `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**). - `plugin/uninstall` — uninstall a local plugin by `pluginId` in `@` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**). diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 673dec6341..4482d326f6 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -41,7 +41,6 @@ use codex_analytics::AppServerRpcTransport; use codex_app_server_protocol::ConfigLayerSource; use codex_app_server_protocol::ConfigWarningNotification; use codex_app_server_protocol::JSONRPCMessage; -use codex_app_server_protocol::RemoteControlStatusChangedNotification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::TextPosition as AppTextPosition; use codex_app_server_protocol::TextRange as AppTextRange; @@ -1003,14 +1002,9 @@ pub async fn run_main_with_transport_options( continue; } remote_control_status = status.clone(); + let notification = ServerNotification::RemoteControlStatusChanged(status); initialize_notification_sender - .send_server_notification(ServerNotification::RemoteControlStatusChanged( - RemoteControlStatusChangedNotification { - status: status.status, - installation_id: status.installation_id, - environment_id: status.environment_id, - }, - )) + .send_server_notification(notification) .await; } created = thread_created_rx.recv(), if listen_for_threads => { diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 0f291abfcc..79c077dec7 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -897,6 +897,10 @@ impl MessageProcessor { .remote_control_processor .disable() .map(|response| Some(response.into())), + ClientRequest::RemoteControlStatusRead { .. } => self + .remote_control_processor + .status_read() + .map(|response| Some(response.into())), ClientRequest::ConfigRequirementsRead { params: _, .. } => self .config_processor .config_requirements_read() diff --git a/codex-rs/app-server/src/request_processors/remote_control_processor.rs b/codex-rs/app-server/src/request_processors/remote_control_processor.rs index 41a124e94b..2fceeb5030 100644 --- a/codex-rs/app-server/src/request_processors/remote_control_processor.rs +++ b/codex-rs/app-server/src/request_processors/remote_control_processor.rs @@ -5,6 +5,7 @@ use crate::transport::RemoteControlUnavailable; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusReadResponse; #[derive(Clone)] pub(crate) struct RemoteControlRequestProcessor { @@ -31,6 +32,16 @@ impl RemoteControlRequestProcessor { Ok(RemoteControlDisableResponse::from(handle.disable())) } + pub(crate) fn status_read(&self) -> Result { + let status = self.handle()?.status(); + Ok(RemoteControlStatusReadResponse { + status: status.status, + server_name: status.server_name, + installation_id: status.installation_id, + environment_id: status.environment_id, + }) + } + fn handle(&self) -> Result<&RemoteControlHandle, JSONRPCErrorError> { self.remote_control_handle .as_ref() diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 19f3b5b85e..981c22f625 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -582,6 +582,12 @@ impl McpProcess { .await } + /// Send a `remoteControl/status/read` JSON-RPC request. + pub async fn send_remote_control_status_read_request(&mut self) -> anyhow::Result { + self.send_request("remoteControl/status/read", /*params*/ None) + .await + } + /// Send an `app/list` JSON-RPC request. pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result { let params = Some(serde_json::to_value(params)?); diff --git a/codex-rs/app-server/tests/suite/v2/remote_control.rs b/codex-rs/app-server/tests/suite/v2/remote_control.rs index ae6bc9013f..d71f05bf58 100644 --- a/codex-rs/app-server/tests/suite/v2/remote_control.rs +++ b/codex-rs/app-server/tests/suite/v2/remote_control.rs @@ -1,14 +1,27 @@ use std::time::Duration; +use anyhow::Context; use anyhow::Result; +use app_test_support::ChatGptAuthFixture; use app_test_support::McpProcess; use app_test_support::to_response; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RemoteControlConnectionStatus; use codex_app_server_protocol::RemoteControlDisableResponse; use codex_app_server_protocol::RemoteControlEnableResponse; +use codex_app_server_protocol::RemoteControlStatusReadResponse; use codex_app_server_protocol::RequestId; +use codex_config::types::AuthCredentialsStoreMode; +use pretty_assertions::assert_eq; use tempfile::TempDir; +use tokio::io::AsyncBufReadExt; +use tokio::io::BufReader; +use tokio::net::TcpListener; +use tokio::net::TcpStream; +use tokio::sync::oneshot; +use tokio::task::JoinHandle; use tokio::time::timeout; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); @@ -28,6 +41,28 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { let received: RemoteControlDisableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +#[tokio::test] +async fn remote_control_status_read_returns_disabled_status() -> Result<()> { + let codex_home = TempDir::new()?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Disabled); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) @@ -36,6 +71,7 @@ async fn remote_control_disable_returns_disabled_status() -> Result<()> { #[tokio::test] async fn remote_control_enable_returns_connecting_status() -> Result<()> { let codex_home = TempDir::new()?; + let _backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -48,7 +84,117 @@ async fn remote_control_enable_returns_connecting_status() -> Result<()> { let received: RemoteControlEnableResponse = to_response(response)?; assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); assert_eq!(received.environment_id, None); assert!(!received.installation_id.is_empty()); Ok(()) } + +#[tokio::test] +async fn remote_control_status_read_returns_connecting_status_after_enable() -> Result<()> { + let codex_home = TempDir::new()?; + let mut backend = BlockingRemoteControlBackend::start(codex_home.path()).await?; + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp.send_remote_control_enable_request().await?; + let _: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let enroll_request = timeout(DEFAULT_TIMEOUT, backend.wait_for_enroll_request()).await??; + assert_eq!( + enroll_request, + "POST /backend-api/wham/remote/control/server/enroll HTTP/1.1" + ); + + let request_id = mcp.send_remote_control_status_read_request().await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: RemoteControlStatusReadResponse = to_response(response)?; + + assert_eq!(received.status, RemoteControlConnectionStatus::Connecting); + assert!(!received.server_name.is_empty()); + assert_eq!(received.environment_id, None); + assert!(!received.installation_id.is_empty()); + Ok(()) +} + +struct BlockingRemoteControlBackend { + enroll_request_rx: Option>>, + server_task: JoinHandle<()>, +} + +impl BlockingRemoteControlBackend { + async fn start(codex_home: &std::path::Path) -> Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let remote_control_url = format!("http://{}/backend-api/", listener.local_addr()?); + write_mock_responses_config_toml_with_chatgpt_base_url( + codex_home, + &remote_control_url, + &remote_control_url, + )?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account_id") + .chatgpt_account_id("account_id"), + AuthCredentialsStoreMode::File, + )?; + + let (enroll_request_tx, enroll_request_rx) = oneshot::channel(); + let server_task = tokio::spawn(async move { + match read_enroll_request(listener).await { + Ok((request_line, _reader)) => { + let _ = enroll_request_tx.send(Ok(request_line)); + std::future::pending::<()>().await; + } + Err(err) => { + let _ = enroll_request_tx.send(Err(err)); + } + } + }); + + Ok(Self { + enroll_request_rx: Some(enroll_request_rx), + server_task, + }) + } + + async fn wait_for_enroll_request(&mut self) -> Result { + let rx = self + .enroll_request_rx + .take() + .context("enroll request should only be awaited once")?; + rx.await? + } +} + +impl Drop for BlockingRemoteControlBackend { + fn drop(&mut self) { + self.server_task.abort(); + } +} + +async fn read_enroll_request(listener: TcpListener) -> Result<(String, BufReader)> { + let (stream, _) = listener.accept().await?; + let mut reader = BufReader::new(stream); + + let mut request_line = String::new(); + reader.read_line(&mut request_line).await?; + + loop { + let mut line = String::new(); + reader.read_line(&mut line).await?; + if line == "\r\n" { + break; + } + } + + Ok((request_line.trim_end().to_string(), reader)) +}