feat(app-server): add remote control pairing status RPC (#26450)

## What

Exposes the pairing status transport as experimental app-server v2 RPC
`remoteControl/pairing/status`.

- Adds request/response protocol types for exactly one lookup key:
`pairingCode` or `manualPairingCode`, returning `{ claimed }`.
- Registers the RPC with `global_shared_read("remote-control-pairing")`.
- Wires the method through `MessageProcessor` and
`RemoteControlRequestProcessor`.
- Validates missing/conflicting pairing-code params as invalid requests.
- Documents the RPC in `app-server/README.md`.
- Adds processor, protocol export, and JSON-RPC integration coverage for
both code paths.

## Why

This is the app-server surface the desktop app can poll while the
QR/manual pairing modal is active.

Depends on https://github.com/openai/codex/pull/26449
Related backend change: https://github.com/openai/openai/pull/990244

## Verification

- `cargo test --manifest-path app-server-protocol/Cargo.toml
remote_control`
- `cargo test --manifest-path app-server/Cargo.toml remote_control`
- `cargo fmt --all --check`
- `git diff --check`
This commit is contained in:
hefuc-oai
2026-06-05 10:33:56 -07:00
committed by GitHub
Unverified
parent 841f057f2d
commit 0177231ca0
8 changed files with 193 additions and 0 deletions
@@ -2963,11 +2963,14 @@ permissionProfile?: string | null};
let client_request_json = fs::read_to_string(output_dir.join("ClientRequest.json"))?;
assert!(client_request_json.contains("remoteControl/pairing/start"));
assert!(client_request_json.contains("remoteControl/pairing/status"));
assert!(client_request_json.contains("remoteControl/client/list"));
assert!(client_request_json.contains("remoteControl/client/revoke"));
for schema in [
"RemoteControlPairingStartParams.json",
"RemoteControlPairingStartResponse.json",
"RemoteControlPairingStatusParams.json",
"RemoteControlPairingStatusResponse.json",
"RemoteControlClientsListParams.json",
"RemoteControlClientsListResponse.json",
"RemoteControlClientsRevokeParams.json",
@@ -849,6 +849,12 @@ client_request_definitions! {
serialization: global("remote-control-pairing"),
response: v2::RemoteControlPairingStartResponse,
},
#[experimental("remoteControl/pairing/status")]
RemoteControlPairingStatus => "remoteControl/pairing/status" {
params: v2::RemoteControlPairingStatusParams,
serialization: global_shared_read("remote-control-pairing"),
response: v2::RemoteControlPairingStatusResponse,
},
#[experimental("remoteControl/client/list")]
RemoteControlClientsList => "remoteControl/client/list" {
params: v2::RemoteControlClientsListParams,
@@ -2014,6 +2020,19 @@ mod tests {
"remote-control-pairing"
))
);
let remote_control_pairing_status = ClientRequest::RemoteControlPairingStatus {
request_id: request_id(),
params: v2::RemoteControlPairingStatusParams {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
},
};
assert_eq!(
remote_control_pairing_status.serialization_scope(),
Some(ClientRequestSerializationScope::GlobalSharedRead(
"remote-control-pairing"
))
);
let remote_control_clients_list = ClientRequest::RemoteControlClientsList {
request_id: request_id(),
params: v2::RemoteControlClientsListParams::default(),
+1
View File
@@ -211,6 +211,7 @@ Example with notification opt-out:
- `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/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/pairing/start` — experimental; start a short-lived remote-control pairing artifact for the current app-server process. Pass `manualCode: true` to also request a manual pairing code. Returns `pairingCode`, `manualPairingCode`, `environmentId`, and Unix-seconds `expiresAt`; app-server intentionally does not expose the backend `serverId`.
- `remoteControl/pairing/status` — experimental; poll whether a remote-control `pairingCode` or `manualPairingCode` has been claimed. Pass exactly one of the two fields. Returns `claimed`.
- `remoteControl/client/list` — experimental; list controller devices granted access to an environment. Pass `environmentId` and optional `cursor`, `limit`, and `order`; returns picker-oriented client metadata plus `nextCursor`. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `remoteControl/client/revoke` — experimental; revoke one controller device's grant for an environment. Pass `environmentId` and `clientId`; returns an empty object. This signed-in account-management operation works while the local relay is disabled or unenrolled.
- `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.
@@ -921,6 +921,11 @@ impl MessageProcessor {
.pairing_start(params, app_server_client_name.as_deref())
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlPairingStatus { params, .. } => self
.remote_control_processor
.pairing_status(params)
.await
.map(|response| Some(response.into())),
ClientRequest::RemoteControlClientsList { params, .. } => self
.remote_control_processor
.clients_list(params)
@@ -11,6 +11,8 @@ use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlPairingStatusParams;
use codex_app_server_protocol::RemoteControlPairingStatusResponse;
use codex_app_server_protocol::RemoteControlStatusReadResponse;
use std::io;
@@ -60,6 +62,17 @@ impl RemoteControlRequestProcessor {
.map_err(map_pairing_start_error)
}
pub(crate) async fn pairing_status(
&self,
params: RemoteControlPairingStatusParams,
) -> Result<RemoteControlPairingStatusResponse, JSONRPCErrorError> {
validate_pairing_status_params(&params)?;
self.handle()?
.pairing_status(params)
.await
.map_err(map_pairing_start_error)
}
pub(crate) async fn clients_list(
&self,
params: RemoteControlClientsListParams,
@@ -99,6 +112,20 @@ fn map_pairing_start_error(err: io::Error) -> JSONRPCErrorError {
}
}
fn validate_pairing_status_params(
params: &RemoteControlPairingStatusParams,
) -> Result<(), JSONRPCErrorError> {
match (&params.pairing_code, &params.manual_pairing_code) {
(Some(_), None) | (None, Some(_)) => Ok(()),
(Some(_), Some(_)) => Err(invalid_request(
"remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both",
)),
(None, None) => Err(invalid_request(
"remoteControl/pairing/status requires pairingCode or manualPairingCode",
)),
}
}
fn map_client_management_error(err: io::Error) -> JSONRPCErrorError {
match err.kind() {
io::ErrorKind::InvalidInput
@@ -23,6 +23,59 @@ async fn pairing_start_returns_internal_error_when_remote_control_is_unavailable
);
}
#[tokio::test]
async fn pairing_status_returns_internal_error_when_remote_control_is_unavailable() {
let err = RemoteControlRequestProcessor::new(/*remote_control_handle*/ None)
.pairing_status(RemoteControlPairingStatusParams {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect_err("missing remote control should fail pairing status");
assert_eq!(
err,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
data: None,
message: "remote control is unavailable for this app-server".to_string(),
}
);
}
#[test]
fn pairing_status_rejects_missing_pairing_codes() {
assert_eq!(
validate_pairing_status_params(&RemoteControlPairingStatusParams {
pairing_code: None,
manual_pairing_code: None,
}),
Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
data: None,
message: "remoteControl/pairing/status requires pairingCode or manualPairingCode"
.to_string(),
})
);
}
#[test]
fn pairing_status_rejects_conflicting_pairing_codes() {
assert_eq!(
validate_pairing_status_params(&RemoteControlPairingStatusParams {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: Some("ABCD-EFGH".to_string()),
}),
Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
data: None,
message:
"remoteControl/pairing/status accepts either pairingCode or manualPairingCode, not both"
.to_string(),
})
);
}
#[test]
fn pairing_start_maps_invalid_input_to_invalid_request() {
assert_eq!(
@@ -70,6 +70,7 @@ use codex_app_server_protocol::ProcessWriteStdinParams;
use codex_app_server_protocol::RemoteControlClientsListParams;
use codex_app_server_protocol::RemoteControlClientsRevokeParams;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStatusParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
@@ -656,6 +657,16 @@ impl TestAppServer {
.await
}
/// Send a `remoteControl/pairing/status` JSON-RPC request.
pub async fn send_remote_control_pairing_status_request(
&mut self,
params: RemoteControlPairingStatusParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("remoteControl/pairing/status", params)
.await
}
/// Send a `remoteControl/client/list` JSON-RPC request.
pub async fn send_remote_control_clients_list_request(
&mut self,
@@ -19,12 +19,15 @@ use codex_app_server_protocol::RemoteControlDisableResponse;
use codex_app_server_protocol::RemoteControlEnableResponse;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlPairingStatusParams;
use codex_app_server_protocol::RemoteControlPairingStatusResponse;
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::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::net::TcpListener;
@@ -190,6 +193,44 @@ async fn remote_control_pairing_start_returns_pairing_artifacts() -> Result<()>
expires_at: 33_336_362_096,
}
);
let request_id = mcp
.send_remote_control_pairing_status_request(RemoteControlPairingStatusParams {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(response.result.get("serverId"), None);
let received: RemoteControlPairingStatusResponse = to_response(response)?;
assert_eq!(
received,
RemoteControlPairingStatusResponse { claimed: true }
);
let request_id = mcp
.send_remote_control_pairing_status_request(RemoteControlPairingStatusParams {
pairing_code: None,
manual_pairing_code: Some("ABCD-EFGH".to_string()),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(response.result.get("serverId"), None);
let received: RemoteControlPairingStatusResponse = to_response(response)?;
assert_eq!(
received,
RemoteControlPairingStatusResponse { claimed: true }
);
Ok(())
}
@@ -429,6 +470,25 @@ impl PairingRemoteControlBackend {
}),
)
.await?;
for expected_body in [
serde_json::json!({ "pairing_code": "pairing-code" }),
serde_json::json!({ "manual_pairing_code": "ABCD-EFGH" }),
] {
let status_http_request = read_http_request(&listener).await?;
assert_eq!(
status_http_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&status_http_request.body)?,
expected_body
);
respond_with_json(
status_http_request.reader.into_inner(),
serde_json::json!({ "claimed": true }),
)
.await?;
}
std::future::pending::<()>().await;
Ok::<(), anyhow::Error>(())
}
@@ -476,6 +536,7 @@ impl Drop for ClientManagementRemoteControlBackend {
struct HttpRequest {
request_line: String,
body: String,
reader: BufReader<TcpStream>,
}
@@ -508,16 +569,29 @@ async fn read_http_request(listener: &TcpListener) -> Result<HttpRequest> {
let mut request_line = String::new();
reader.read_line(&mut request_line).await?;
let mut content_length = 0;
loop {
let mut line = String::new();
reader.read_line(&mut line).await?;
if line == "\r\n" {
break;
}
if let Some(value) = line
.trim_end()
.strip_prefix("content-length:")
.or_else(|| line.trim_end().strip_prefix("Content-Length:"))
{
content_length = value.trim().parse::<usize>()?;
}
}
let mut body = vec![0; content_length];
if content_length > 0 {
reader.read_exact(&mut body).await?;
}
Ok(HttpRequest {
request_line: request_line.trim_end().to_string(),
body: String::from_utf8(body)?,
reader,
})
}