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.
This commit is contained in:
Owen Lin
2026-05-15 14:33:24 -07:00
committed by GitHub
Unverified
parent 98129fb9c5
commit 6a331a66eb
17 changed files with 276 additions and 33 deletions
@@ -2748,12 +2748,16 @@
"installationId": {
"type": "string"
},
"serverName": {
"type": "string"
},
"status": {
"$ref": "#/definitions/RemoteControlConnectionStatus"
}
},
"required": [
"installationId",
"serverName",
"status"
],
"type": "object"
@@ -13446,12 +13446,16 @@
"installationId": {
"type": "string"
},
"serverName": {
"type": "string"
},
"status": {
"$ref": "#/definitions/v2/RemoteControlConnectionStatus"
}
},
"required": [
"installationId",
"serverName",
"status"
],
"title": "RemoteControlStatusChangedNotification",
@@ -9995,12 +9995,16 @@
"installationId": {
"type": "string"
},
"serverName": {
"type": "string"
},
"status": {
"$ref": "#/definitions/RemoteControlConnectionStatus"
}
},
"required": [
"installationId",
"serverName",
"status"
],
"title": "RemoteControlStatusChangedNotification",
@@ -22,12 +22,16 @@
"installationId": {
"type": "string"
},
"serverName": {
"type": "string"
},
"status": {
"$ref": "#/definitions/RemoteControlConnectionStatus"
}
},
"required": [
"installationId",
"serverName",
"status"
],
"title": "RemoteControlStatusChangedNotification",
@@ -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, };
@@ -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" {
@@ -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<String>,
}
@@ -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<String>,
}
@@ -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<String>,
}
#[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<String>,
}
@@ -45,11 +58,13 @@ impl From<RemoteControlStatusChangedNotification> 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<RemoteControlStatusChangedNotification> 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,
}
@@ -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<RemoteControlEnrollment> {
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");
@@ -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<Arc<StateRuntime>>,
@@ -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,
@@ -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<RemoteControlStatusChangedNotification>,
expected_status: Option<RemoteControlConnectionStatus>,
@@ -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,
},
@@ -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<RemoteControlTarget>,
state_db: Option<Arc<StateRuntime>>,
auth_manager: Arc<AuthManager>,
@@ -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<RemoteControlTarget>,
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,
}
+2 -1
View File
@@ -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 `<plugin>@<marketplace>` 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**).
+2 -8
View File
@@ -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 => {
@@ -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()
@@ -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<RemoteControlStatusReadResponse, JSONRPCErrorError> {
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()
@@ -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<i64> {
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<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -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<oneshot::Receiver<Result<String>>>,
server_task: JoinHandle<()>,
}
impl BlockingRemoteControlBackend {
async fn start(codex_home: &std::path::Path) -> Result<Self> {
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<String> {
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<TcpStream>)> {
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))
}