feat(remote-control): add pairing status transport (#26449)

## What

Adds transport support for checking remote-control pairing status
against the backend.

- Adds the normalized `server/pair/status` backend URL.
- Adds backend request/response structs for exactly one lookup key:
`pairing_code` or `manual_pairing_code`, returning `{ claimed }`.
- Adds `RemoteControlEnrollment::pairing_status` and
`RemoteControlHandle::pairing_status`.
- Preserves auth refresh/retry behavior and backend error mapping.
- Adds transport coverage for pending, claimed, manual-code payloads,
token refresh, mapped backend errors, malformed responses, and URL
normalization.

## Why

Desktop needs a host-authenticated way to poll whether a QR or manual
pairing code has been claimed.

Related backend change: https://github.com/openai/openai/pull/990244

## Verification

- `cargo test --manifest-path app-server-transport/Cargo.toml
remote_control::tests::pairing_tests`
- `cargo fmt --all --check`
- `git diff --check`
This commit is contained in:
hefuc-oai
2026-06-05 10:07:25 -07:00
committed by GitHub
Unverified
parent 9ddb1de633
commit da490ba9de
6 changed files with 465 additions and 0 deletions
@@ -62,6 +62,23 @@ pub struct RemoteControlPairingStartResponse {
pub expires_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStatusParams {
#[ts(optional = nullable)]
pub pairing_code: Option<String>,
#[ts(optional = nullable)]
pub manual_pairing_code: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStatusResponse {
pub claimed: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3,11 +3,14 @@ use super::pairing_unavailable_error;
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RefreshRemoteServerRequest;
use super::protocol::RemoteControlPairingStatusRequest;
use super::protocol::RemoteControlPairingStatusResponse as BackendRemoteControlPairingStatusResponse;
use super::protocol::RemoteControlTarget;
use super::protocol::StartRemoteControlPairingRequest;
use super::protocol::StartRemoteControlPairingResponse;
use axum::http::HeaderMap;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlPairingStatusResponse;
use codex_login::default_client::build_reqwest_client;
use codex_state::RemoteControlEnrollmentRecord;
use codex_state::StateRuntime;
@@ -136,6 +139,69 @@ impl RemoteControlEnrollment {
})
}
pub(super) async fn pairing_status(
&self,
request: RemoteControlPairingStatusRequest,
) -> io::Result<RemoteControlPairingStatusResponse> {
if self.should_refresh_server_token() {
return Err(pairing_unavailable_error());
}
let remote_control_token = self
.remote_control_token
.as_deref()
.ok_or_else(pairing_unavailable_error)?;
let response = build_reqwest_client()
.post(&self.remote_control_target.pair_status_url)
.timeout(REMOTE_CONTROL_PAIRING_TIMEOUT)
.bearer_auth(remote_control_token)
.json(&request)
.send()
.await
.map_err(|err| {
io::Error::other(format!(
"failed to check remote control pairing status at `{}`: {err}",
self.remote_control_target.pair_status_url
))
})?;
let headers = response.headers().clone();
let status = response.status();
let body = response.bytes().await.map_err(|err| {
io::Error::other(format!(
"failed to read remote control pairing status response from `{}`: {err}",
self.remote_control_target.pair_status_url
))
})?;
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let error_kind = match status.as_u16() {
401 | 403 => ErrorKind::PermissionDenied,
404 | 410 => ErrorKind::InvalidInput,
_ => ErrorKind::Other,
};
return Err(io::Error::new(
error_kind,
format!(
"remote control pairing status failed at `{}`: HTTP {status}, {}, body: {body_preview}",
self.remote_control_target.pair_status_url,
format_headers(&headers)
),
));
}
let response = serde_json::from_slice::<BackendRemoteControlPairingStatusResponse>(&body)
.map_err(|err| {
io::Error::other(format!(
"failed to parse remote control pairing status response from `{}`: HTTP {status}, {}, body: {body_preview}, decode error: {err}",
self.remote_control_target.pair_status_url,
format_headers(&headers)
))
})?;
Ok(RemoteControlPairingStatusResponse {
claimed: response.claimed,
})
}
pub(super) fn should_refresh_server_token(&self) -> bool {
self.remote_control_token.is_none()
|| self.expires_at.is_none_or(|expires_at| {
@@ -18,6 +18,7 @@ use crate::transport::remote_control::websocket::RemoteControlStatusPublisher;
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
pub use self::protocol::ClientId;
use self::protocol::RemoteControlPairingStatusCode;
use self::protocol::ServerEvent;
use self::protocol::StreamId;
use self::protocol::normalize_remote_control_url;
@@ -31,6 +32,8 @@ use codex_app_server_protocol::RemoteControlClientsRevokeResponse;
use codex_app_server_protocol::RemoteControlConnectionStatus;
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::RemoteControlStatusChangedNotification;
use codex_login::AuthManager;
use codex_state::StateRuntime;
@@ -391,6 +394,89 @@ impl RemoteControlHandle {
Ok(self.pairing_persistence_key.borrow().clone())
}
pub async fn pairing_status(
&self,
params: RemoteControlPairingStatusParams,
) -> io::Result<RemoteControlPairingStatusResponse> {
if !*self.enabled_tx.borrow() {
return Err(Self::pairing_disabled_error());
}
let mut auth = load_remote_control_auth(&self.auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
let app_server_client_name = self.pairing_persistence_key.borrow().clone();
let app_server_client_name = app_server_client_name.as_deref();
let mut current_enrollment = self.current_enrollment.lock().await;
let mut enrollment = current_enrollment
.as_ref()
.filter(|enrollment| enrollment.account_id == auth.account_id)
.cloned()
.ok_or_else(pairing_unavailable_error)?;
let installation_id = self.status().installation_id;
if enrollment.should_refresh_server_token() {
refresh_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&self.auth_manager,
&mut auth,
&installation_id,
&mut enrollment,
)
.await?;
}
let status_code = remote_control_pairing_status_code(&params)?;
let pairing_status_request =
|| protocol::RemoteControlPairingStatusRequest::from(status_code.clone());
let pairing_status_response =
match enrollment.pairing_status(pairing_status_request()).await {
Err(err) if err.kind() == io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
refresh_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&self.auth_manager,
&mut auth,
&installation_id,
&mut enrollment,
)
.await?;
enrollment.pairing_status(pairing_status_request()).await
}
pairing_status_response => pairing_status_response,
};
if let Err(err) = &pairing_status_response {
match err.kind() {
io::ErrorKind::NotFound => {
clear_pairing_enrollment(
&mut current_enrollment,
self.state_db.as_deref(),
app_server_client_name,
&enrollment,
)
.await;
return Err(pairing_unavailable_error());
}
io::ErrorKind::PermissionDenied => {
clear_pairing_server_token(&mut current_enrollment, &mut enrollment)?;
return Err(pairing_unavailable_error());
}
_ => {}
}
}
if !*self.enabled_tx.borrow() {
return Err(Self::pairing_disabled_error());
}
let current_auth = load_remote_control_auth(&self.auth_manager)
.await
.map_err(|_| pairing_unavailable_error())?;
if current_auth.account_id != auth.account_id {
return Err(pairing_unavailable_error());
}
pairing_status_response
}
pub async fn list_clients(
&self,
params: RemoteControlClientsListParams,
@@ -407,6 +493,13 @@ impl RemoteControlHandle {
.await
}
fn pairing_disabled_error() -> io::Error {
io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing requires remote control to be enabled",
)
}
fn publish_status(
&self,
connection_status: RemoteControlConnectionStatus,
@@ -464,6 +557,27 @@ async fn enroll_pairing_server(
enroll_remote_control_server(remote_control_target, auth, installation_id, server_name).await
}
fn remote_control_pairing_status_code(
params: &RemoteControlPairingStatusParams,
) -> io::Result<RemoteControlPairingStatusCode> {
match (&params.pairing_code, &params.manual_pairing_code) {
(Some(pairing_code), None) => Ok(RemoteControlPairingStatusCode::PairingCode(
pairing_code.clone(),
)),
(None, Some(manual_pairing_code)) => Ok(RemoteControlPairingStatusCode::ManualPairingCode(
manual_pairing_code.clone(),
)),
(Some(_), Some(_)) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing status accepts either pairingCode or manualPairingCode, not both",
)),
(None, None) => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"remote control pairing status requires pairingCode or manualPairingCode",
)),
}
}
async fn refresh_pairing_enrollment(
current_enrollment: &mut Option<RemoteControlEnrollment>,
state_db: Option<&StateRuntime>,
@@ -13,6 +13,7 @@ pub(super) struct RemoteControlTarget {
pub(super) enroll_url: String,
pub(super) refresh_url: String,
pub(super) pair_url: String,
pub(super) pair_status_url: String,
}
#[derive(Debug, Serialize)]
@@ -52,6 +53,40 @@ pub(super) struct StartRemoteControlPairingResponse {
pub(super) expires_at: String,
}
#[derive(Debug, Serialize)]
pub(super) struct RemoteControlPairingStatusRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) pairing_code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) manual_pairing_code: Option<String>,
}
#[derive(Clone)]
pub(super) enum RemoteControlPairingStatusCode {
PairingCode(String),
ManualPairingCode(String),
}
impl From<RemoteControlPairingStatusCode> for RemoteControlPairingStatusRequest {
fn from(code: RemoteControlPairingStatusCode) -> Self {
match code {
RemoteControlPairingStatusCode::PairingCode(pairing_code) => Self {
pairing_code: Some(pairing_code),
manual_pairing_code: None,
},
RemoteControlPairingStatusCode::ManualPairingCode(manual_pairing_code) => Self {
pairing_code: None,
manual_pairing_code: Some(manual_pairing_code),
},
}
}
}
#[derive(Debug, Deserialize)]
pub(super) struct RemoteControlPairingStatusResponse {
pub(super) claimed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ClientId(pub String);
@@ -194,6 +229,9 @@ pub(super) fn normalize_remote_control_url(
let pair_url = remote_control_url
.join("wham/remote/control/server/pair")
.map_err(map_url_parse_error)?;
let pair_status_url = remote_control_url
.join("wham/remote/control/server/pair/status")
.map_err(map_url_parse_error)?;
let mut websocket_url = remote_control_url
.join("wham/remote/control/server")
.map_err(map_url_parse_error)?;
@@ -215,6 +253,7 @@ pub(super) fn normalize_remote_control_url(
enroll_url: enroll_url.to_string(),
refresh_url: refresh_url.to_string(),
pair_url: pair_url.to_string(),
pair_status_url: pair_status_url.to_string(),
})
}
@@ -269,6 +308,9 @@ mod tests {
.to_string(),
pair_url: "https://chatgpt.com/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"https://chatgpt.com/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
assert_eq!(
@@ -287,6 +329,9 @@ mod tests {
pair_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
}
@@ -305,6 +350,9 @@ mod tests {
.to_string(),
pair_url: "http://localhost:8080/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"http://localhost:8080/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
assert_eq!(
@@ -320,6 +368,9 @@ mod tests {
.to_string(),
pair_url: "https://localhost:8443/backend-api/wham/remote/control/server/pair"
.to_string(),
pair_status_url:
"https://localhost:8443/backend-api/wham/remote/control/server/pair/status"
.to_string(),
}
);
}
@@ -21,6 +21,7 @@ use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::RemoteControlConnectionStatus;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStatusParams;
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
use codex_app_server_protocol::ServerNotification;
use codex_config::types::AuthCredentialsStoreMode;
@@ -1,6 +1,8 @@
use super::super::protocol::RemoteControlPairingStatusRequest;
use super::super::protocol::StartRemoteControlPairingRequest;
use super::*;
use pretty_assertions::assert_eq;
use std::io;
fn remote_control_enrollment(
remote_control_url: &str,
@@ -66,6 +68,36 @@ async fn pairing_response_error(body: serde_json::Value) -> String {
err.to_string()
}
async fn pairing_status_error(status: &'static str, body: &'static str) -> (io::Error, String) {
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 expected_status_url = normalize_remote_control_url(&remote_control_url)
.expect("target should normalize")
.pair_status_url;
let server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
respond_with_status_and_headers(
status_request.stream,
status,
&[("x-request-id", "request-123"), ("cf-ray", "ray-123")],
body,
)
.await;
});
let err = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect_err("pairing status should fail");
server_task.await.expect("server task should finish");
(err, expected_status_url)
}
#[tokio::test]
async fn remote_control_handle_starts_pairing_before_websocket_connects() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -156,6 +188,190 @@ async fn remote_control_handle_starts_pairing_before_websocket_connects() {
);
}
#[tokio::test]
async fn remote_control_pairing_status_returns_pending() {
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 server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
assert_eq!(
status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
status_request.headers.get("authorization"),
Some(&"Bearer remote-control-token".to_string())
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&status_request.body)
.expect("status request body should deserialize"),
json!({ "pairing_code": "pairing-code" })
);
respond_with_json(status_request.stream, json!({ "claimed": false })).await;
});
let response = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect("pairing status should succeed");
server_task.await.expect("server task should finish");
assert!(!response.claimed);
}
#[tokio::test]
async fn remote_control_pairing_status_accepts_manual_pairing_code() {
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 server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
assert_eq!(
status_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_request.body)
.expect("status request body should deserialize"),
json!({ "manual_pairing_code": "ABCD-EFGH" })
);
respond_with_json(status_request.stream, json!({ "claimed": false })).await;
});
let response = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: None,
manual_pairing_code: Some("ABCD-EFGH".to_string()),
})
.await
.expect("pairing status should succeed");
server_task.await.expect("server task should finish");
assert!(!response.claimed);
}
#[tokio::test]
async fn remote_control_pairing_status_returns_claimed() {
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 server_task = tokio::spawn(async move {
let status_request = accept_http_request(&listener).await;
assert_eq!(
status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
respond_with_json(status_request.stream, json!({ "claimed": true })).await;
});
let response = remote_control_enrollment(&remote_control_url, "remote-control-token")
.pairing_status(RemoteControlPairingStatusRequest {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect("pairing status should succeed");
server_task.await.expect("server task should finish");
assert!(response.claimed);
}
#[tokio::test]
async fn remote_control_handle_refreshes_after_pairing_status_auth_failure() {
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 server_task = tokio::spawn(async move {
let stale_status_request = accept_http_request(&listener).await;
assert_eq!(
stale_status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
stale_status_request.headers.get("authorization"),
Some(&format!("Bearer {TEST_REMOTE_CONTROL_SERVER_TOKEN}"))
);
respond_with_status(stale_status_request.stream, "401 Unauthorized", "").await;
let refresh_request = accept_http_request(&listener).await;
assert_eq!(
refresh_request.request_line,
"POST /backend-api/wham/remote/control/server/refresh HTTP/1.1"
);
respond_with_json(
refresh_request.stream,
remote_control_server_token_response(
"srv_e_test",
"env_test",
TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN,
),
)
.await;
let refreshed_status_request = accept_http_request(&listener).await;
assert_eq!(
refreshed_status_request.request_line,
"POST /backend-api/wham/remote/control/server/pair/status HTTP/1.1"
);
assert_eq!(
refreshed_status_request.headers.get("authorization"),
Some(&format!(
"Bearer {TEST_REFRESHED_REMOTE_CONTROL_SERVER_TOKEN}"
))
);
respond_with_json(refreshed_status_request.stream, json!({ "claimed": true })).await;
});
let remote_handle = remote_control_handle_with_current_enrollment(
&remote_control_url,
remote_control_auth_manager(),
);
let response = remote_handle
.pairing_status(RemoteControlPairingStatusParams {
pairing_code: Some("pairing-code".to_string()),
manual_pairing_code: None,
})
.await
.expect("pairing status should refresh after server token auth failure");
server_task.await.expect("server task should finish");
assert!(response.claimed);
}
#[tokio::test]
async fn remote_control_pairing_status_maps_user_actionable_backend_errors() {
for (status, expected_kind) in [
("403 Forbidden", io::ErrorKind::PermissionDenied),
("404 Not Found", io::ErrorKind::InvalidInput),
("410 Gone", io::ErrorKind::InvalidInput),
] {
let (err, _expected_status_url) = pairing_status_error(status, "not available").await;
assert_eq!(err.kind(), expected_kind);
}
}
#[tokio::test]
async fn remote_control_pairing_status_preserves_decode_error_context() {
let (err, expected_status_url) = pairing_status_error("200 OK", "{").await;
let err = err.to_string();
assert!(err.contains(&format!(
"failed to parse remote control pairing status response from `{expected_status_url}`: HTTP 200 OK"
)));
assert!(err.contains("request-id: request-123"));
assert!(err.contains("cf-ray: ray-123"));
assert!(err.contains("body: {"));
assert!(err.contains("decode error:"));
}
#[tokio::test]
async fn remote_control_handle_refreshes_after_pairing_auth_failure() {
let listener = TcpListener::bind("127.0.0.1:0")