mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
841f057f2d
commit
0177231ca0
@@ -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(),
|
||||
|
||||
@@ -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(¶ms)?;
|
||||
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 (¶ms.pairing_code, ¶ms.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
|
||||
|
||||
+53
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user