feat(app-server): add remote control client management RPCs (#25785)

## Why

Remote-control clients need to list and revoke controller-device grants
without enabling or enrolling the local relay. These are signed-in
account-management operations, so coupling them to websocket, pairing,
enrollment, or persisted relay state would prevent clients from managing
stale grants from the picker.

Related enhancement request: N/A. This adds the Codex app-server surface
for the planned upstream environment-scoped revoke endpoint.

## What Changed

- Added experimental app-server v2 RPCs:
  - `remoteControl/client/list`
  - `remoteControl/client/revoke`
- Added picker-oriented protocol types and standard generated schema
fixtures. The list response intentionally omits backend account id,
enrollment status, and location fields.
- Added `app-server-transport/src/transport/remote_control/clients.rs`
for environment-scoped GET and DELETE requests. It builds escaped URL
path segments, forwards optional pagination query fields, sends ChatGPT
auth plus `chatgpt-account-id`, converts RFC3339 `last_seen_at` values
to Unix seconds, accepts `204 No Content` revoke responses, and retries
once after a `401`.
- Extracted shared ChatGPT auth loading and recovery into
`app-server-transport/src/transport/remote_control/auth.rs` so
websocket, pairing, and client management use the same account-auth
boundary.
- Retained the configured remote-control base URL on
`RemoteControlHandle` and resolve management URLs lazily, preserving
deferred validation while relay startup is disabled.
- Registered list as `global_shared_read("remote-control-clients")` and
revoke as `global("remote-control-clients")`.

## Verification

- Added transport coverage proving list and revoke work while relay
state is disabled, IDs are escaped, picker-only fields are returned,
timestamps are converted, revoke accepts `204`, auth headers are
forwarded, `401` retries exactly once, `403` is not retried, and
malformed list payloads retain decode context.
- Added an app-server integration test proving both JSON-RPC methods
work before relay enablement and successful revoke returns `{}`.
- Regenerated and validated experimental and standard app-server schema
fixtures.
This commit is contained in:
Anton Panasenko
2026-06-02 17:01:02 -07:00
committed by GitHub
parent 1fd2a6d328
commit 98a62a62ce
18 changed files with 1281 additions and 130 deletions
+37 -1
View File
@@ -39,6 +39,8 @@ use ts_rs::TS;
pub(crate) const GENERATED_TS_HEADER: &str = "// GENERATED CODE! DO NOT MODIFY BY HAND!\n\n";
const IGNORED_DEFINITIONS: &[&str] = &["Option<()>"];
const JSON_V1_ALLOWLIST: &[&str] = &["InitializeParams", "InitializeResponse"];
const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] =
&["RemoteControlClient", "RemoteControlClientsListOrder"];
const SPECIAL_DEFINITIONS: &[&str] = &[
"ClientNotification",
"ClientRequest",
@@ -554,6 +556,7 @@ fn experimental_method_types() -> HashSet<String> {
let mut type_names = HashSet::new();
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES, &mut type_names);
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES, &mut type_names);
collect_experimental_type_names(EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES, &mut type_names);
type_names
}
@@ -2132,6 +2135,14 @@ mod tests {
fixture_tree.contains_key(Path::new("v2/MockExperimentalMethodResponse.ts")),
false
);
assert_eq!(
fixture_tree.contains_key(Path::new("v2/RemoteControlClient.ts")),
false
);
assert_eq!(
fixture_tree.contains_key(Path::new("v2/RemoteControlClientsListOrder.ts")),
false
);
let mut undefined_offenders = Vec::new();
let mut optional_nullable_offenders = BTreeSet::new();
@@ -2847,6 +2858,11 @@ permissionProfile?: string | null};
flat_v2_bundle_json.contains("MockExperimentalMethodResponse"),
false
);
assert_eq!(flat_v2_bundle_json.contains("RemoteControlClient"), false);
assert_eq!(
flat_v2_bundle_json.contains("RemoteControlClientsListOrder"),
false
);
assert_eq!(flat_v2_bundle_json.contains("#/definitions/v2/"), false);
assert_eq!(
flat_v2_bundle_json.contains("\"title\": \"CodexAppServerProtocolV2\""),
@@ -2920,22 +2936,42 @@ permissionProfile?: string | null};
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("RemoteControlClient.json")
.exists(),
false
);
assert_eq!(
output_dir
.join("v2")
.join("RemoteControlClientsListOrder.json")
.exists(),
false
);
let _cleanup = fs::remove_dir_all(&output_dir);
Ok(())
}
#[test]
fn generate_json_includes_remote_control_pairing_start_with_experimental_api() -> Result<()> {
fn generate_json_includes_remote_control_methods_with_experimental_api() -> Result<()> {
let output_dir = std::env::temp_dir().join(format!("codex_schema_{}", Uuid::now_v7()));
fs::create_dir(&output_dir)?;
generate_json_with_experimental(&output_dir, /*experimental_api*/ true)?;
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/client/list"));
assert!(client_request_json.contains("remoteControl/client/revoke"));
for schema in [
"RemoteControlPairingStartParams.json",
"RemoteControlPairingStartResponse.json",
"RemoteControlClientsListParams.json",
"RemoteControlClientsListResponse.json",
"RemoteControlClientsRevokeParams.json",
"RemoteControlClientsRevokeResponse.json",
] {
assert!(output_dir.join("v2").join(schema).exists());
}
@@ -849,6 +849,18 @@ client_request_definitions! {
serialization: global("remote-control-pairing"),
response: v2::RemoteControlPairingStartResponse,
},
#[experimental("remoteControl/client/list")]
RemoteControlClientsList => "remoteControl/client/list" {
params: v2::RemoteControlClientsListParams,
serialization: global_shared_read("remote-control-clients"),
response: v2::RemoteControlClientsListResponse,
},
#[experimental("remoteControl/client/revoke")]
RemoteControlClientsRevoke => "remoteControl/client/revoke" {
params: v2::RemoteControlClientsRevokeParams,
serialization: global("remote-control-clients"),
response: v2::RemoteControlClientsRevokeResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
@@ -1994,6 +2006,29 @@ mod tests {
"remote-control-pairing"
))
);
let remote_control_clients_list = ClientRequest::RemoteControlClientsList {
request_id: request_id(),
params: v2::RemoteControlClientsListParams::default(),
};
assert_eq!(
remote_control_clients_list.serialization_scope(),
Some(ClientRequestSerializationScope::GlobalSharedRead(
"remote-control-clients"
))
);
let remote_control_clients_revoke = ClientRequest::RemoteControlClientsRevoke {
request_id: request_id(),
params: v2::RemoteControlClientsRevokeParams {
environment_id: "environment-id".to_string(),
client_id: "client-id".to_string(),
},
};
assert_eq!(
remote_control_clients_revoke.serialization_scope(),
Some(ClientRequestSerializationScope::Global(
"remote-control-clients"
))
);
}
#[test]
@@ -62,6 +62,62 @@ pub struct RemoteControlPairingStartResponse {
pub expires_at: i64,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsListParams {
pub environment_id: String,
#[ts(optional = nullable)]
pub cursor: Option<String>,
#[ts(optional = nullable)]
pub limit: Option<u32>,
#[ts(optional = nullable)]
pub order: Option<RemoteControlClientsListOrder>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
pub enum RemoteControlClientsListOrder {
Asc,
Desc,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsListResponse {
pub data: Vec<RemoteControlClient>,
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClient {
pub client_id: String,
pub display_name: Option<String>,
pub device_type: Option<String>,
pub platform: Option<String>,
pub os_version: Option<String>,
pub device_model: Option<String>,
pub app_version: Option<String>,
pub last_seen_at: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsRevokeParams {
pub environment_id: String,
pub client_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlClientsRevokeResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase", export_to = "v2/")]
@@ -105,3 +161,7 @@ impl From<RemoteControlStatusChangedNotification> for RemoteControlDisableRespon
}
}
}
#[cfg(test)]
#[path = "remote_control_tests.rs"]
mod tests;
@@ -0,0 +1,50 @@
use super::*;
use pretty_assertions::assert_eq;
use serde_json::json;
#[test]
fn remote_control_clients_list_params_serialize_nullable_optional_fields() {
assert_eq!(
serde_json::to_value(RemoteControlClientsListParams {
environment_id: "env-123".to_string(),
cursor: None,
limit: None,
order: None,
})
.expect("params should serialize"),
json!({
"environmentId": "env-123",
"cursor": null,
"limit": null,
"order": null,
})
);
}
#[test]
fn remote_control_clients_list_params_deserialize_camel_case_fields() {
assert_eq!(
serde_json::from_value::<RemoteControlClientsListParams>(json!({
"environmentId": "env-123",
"cursor": "cursor-123",
"limit": 10,
"order": "asc",
}))
.expect("params should deserialize"),
RemoteControlClientsListParams {
environment_id: "env-123".to_string(),
cursor: Some("cursor-123".to_string()),
limit: Some(10),
order: Some(RemoteControlClientsListOrder::Asc),
}
);
}
#[test]
fn remote_control_clients_revoke_response_serializes_as_empty_object() {
assert_eq!(
serde_json::to_value(RemoteControlClientsRevokeResponse {})
.expect("response should serialize"),
json!({})
);
}