feat: update remote control harness

Co-authored-by: Codex noreply@openai.com
This commit is contained in:
viyatb-oai
2026-04-16 10:49:35 -07:00
co-authored by Codex noreply@openai.com
parent d22974712f
commit e80542dd98
46 changed files with 1185 additions and 721 deletions
+3
View File
@@ -1452,6 +1452,7 @@ dependencies = [
"codex-feedback",
"codex-file-search",
"codex-git-utils",
"codex-keyring-store",
"codex-login",
"codex-mcp",
"codex-models-manager",
@@ -1472,6 +1473,7 @@ dependencies = [
"codex-utils-rustls-provider",
"constant_time_eq 0.3.1",
"core_test_support",
"ed25519-dalek",
"futures",
"gethostname",
"hmac",
@@ -1480,6 +1482,7 @@ dependencies = [
"opentelemetry_sdk",
"owo-colors",
"pretty_assertions",
"rand 0.9.3",
"reqwest",
"rmcp",
"serde",
@@ -1699,6 +1699,46 @@
}
]
},
"RemoteControlApprovalRequestParams": {
"properties": {
"approvalId": {
"type": "string"
},
"challenge": {
"type": "string"
},
"controllerName": {
"type": "string"
},
"environmentId": {
"type": "string"
},
"expiresAt": {
"format": "int64",
"type": "integer"
},
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
},
"prompt": {
"type": "string"
},
"serverId": {
"type": "string"
}
},
"required": [
"approvalId",
"challenge",
"controllerName",
"environmentId",
"expiresAt",
"mode",
"prompt",
"serverId"
],
"type": "object"
},
"RemoteControlPairingMode": {
"enum": [
"session",
@@ -1706,17 +1746,6 @@
],
"type": "string"
},
"RemoteControlPairingStartParams": {
"properties": {
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
}
},
"required": [
"mode"
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
@@ -4678,13 +4707,13 @@
},
"method": {
"enum": [
"remoteControl/pairing/start"
"remoteControl/approval/request"
],
"title": "RemoteControl/pairing/startRequestMethod",
"title": "RemoteControl/approval/requestRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/RemoteControlPairingStartParams"
"$ref": "#/definitions/RemoteControlApprovalRequestParams"
}
},
"required": [
@@ -4692,7 +4721,7 @@
"method",
"params"
],
"title": "RemoteControl/pairing/startRequest",
"title": "RemoteControl/approval/requestRequest",
"type": "object"
},
{
@@ -10,6 +10,15 @@
}
},
"properties": {
"approvalId": {
"type": "string"
},
"challenge": {
"type": "string"
},
"controllerName": {
"type": "string"
},
"environmentId": {
"type": "string"
},
@@ -20,12 +29,6 @@
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
},
"pairingCode": {
"type": "string"
},
"pairingId": {
"type": "string"
},
"prompt": {
"type": "string"
},
@@ -34,14 +37,15 @@
}
},
"required": [
"approvalId",
"challenge",
"controllerName",
"environmentId",
"expiresAt",
"mode",
"pairingCode",
"pairingId",
"prompt",
"serverId"
],
"title": "RemoteControlPairingStartResponse",
"title": "RemoteControlApprovalConfirmParams",
"type": "object"
}
@@ -0,0 +1,22 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RemoteControlApprovalDecision": {
"enum": [
"approve",
"deny"
],
"type": "string"
}
},
"properties": {
"decision": {
"$ref": "#/definitions/RemoteControlApprovalDecision"
}
},
"required": [
"decision"
],
"title": "RemoteControlApprovalConfirmResponse",
"type": "object"
}
@@ -1377,6 +1377,53 @@
],
"type": "object"
},
"RemoteControlApprovalConfirmParams": {
"properties": {
"approvalId": {
"type": "string"
},
"challenge": {
"type": "string"
},
"controllerName": {
"type": "string"
},
"environmentId": {
"type": "string"
},
"expiresAt": {
"format": "int64",
"type": "integer"
},
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
},
"prompt": {
"type": "string"
},
"serverId": {
"type": "string"
}
},
"required": [
"approvalId",
"challenge",
"controllerName",
"environmentId",
"expiresAt",
"mode",
"prompt",
"serverId"
],
"type": "object"
},
"RemoteControlPairingMode": {
"enum": [
"session",
"server"
],
"type": "string"
},
"RequestId": {
"anyOf": [
{
@@ -1674,6 +1721,30 @@
"title": "Account/chatgptAuthTokens/refreshRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"remoteControl/approval/confirm"
],
"title": "RemoteControl/approval/confirmRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/RemoteControlApprovalConfirmParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "RemoteControl/approval/confirmRequest",
"type": "object"
},
{
"description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).",
"properties": {
@@ -1186,13 +1186,13 @@
},
"method": {
"enum": [
"remoteControl/pairing/start"
"remoteControl/approval/request"
],
"title": "RemoteControl/pairing/startRequestMethod",
"title": "RemoteControl/approval/requestRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/RemoteControlPairingStartParams"
"$ref": "#/definitions/v2/RemoteControlApprovalRequestParams"
}
},
"required": [
@@ -1200,7 +1200,7 @@
"method",
"params"
],
"title": "RemoteControl/pairing/startRequest",
"title": "RemoteControl/approval/requestRequest",
"type": "object"
},
{
@@ -3389,6 +3389,61 @@
"title": "PermissionsRequestApprovalResponse",
"type": "object"
},
"RemoteControlApprovalConfirmParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalId": {
"type": "string"
},
"challenge": {
"type": "string"
},
"controllerName": {
"type": "string"
},
"environmentId": {
"type": "string"
},
"expiresAt": {
"format": "int64",
"type": "integer"
},
"mode": {
"$ref": "#/definitions/v2/RemoteControlPairingMode"
},
"prompt": {
"type": "string"
},
"serverId": {
"type": "string"
}
},
"required": [
"approvalId",
"challenge",
"controllerName",
"environmentId",
"expiresAt",
"mode",
"prompt",
"serverId"
],
"title": "RemoteControlApprovalConfirmParams",
"type": "object"
},
"RemoteControlApprovalConfirmResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"decision": {
"$ref": "#/definitions/v2/RemoteControlApprovalDecision"
}
},
"required": [
"decision"
],
"title": "RemoteControlApprovalConfirmResponse",
"type": "object"
},
"RequestId": {
"$schema": "http://json-schema.org/draft-07/schema#",
"anyOf": [
@@ -4765,6 +4820,30 @@
"title": "Account/chatgptAuthTokens/refreshRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"remoteControl/approval/confirm"
],
"title": "RemoteControl/approval/confirmRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/RemoteControlApprovalConfirmParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "RemoteControl/approval/confirmRequest",
"type": "object"
},
{
"description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).",
"properties": {
@@ -11064,29 +11143,25 @@
"title": "ReasoningTextDeltaNotification",
"type": "object"
},
"RemoteControlPairingMode": {
"RemoteControlApprovalDecision": {
"enum": [
"session",
"server"
"approve",
"deny"
],
"type": "string"
},
"RemoteControlPairingStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"mode": {
"$ref": "#/definitions/v2/RemoteControlPairingMode"
}
},
"required": [
"mode"
],
"title": "RemoteControlPairingStartParams",
"type": "object"
},
"RemoteControlPairingStartResponse": {
"RemoteControlApprovalRequestParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalId": {
"type": "string"
},
"challenge": {
"type": "string"
},
"controllerName": {
"type": "string"
},
"environmentId": {
"type": "string"
},
@@ -11097,12 +11172,6 @@
"mode": {
"$ref": "#/definitions/v2/RemoteControlPairingMode"
},
"pairingCode": {
"type": "string"
},
"pairingId": {
"type": "string"
},
"prompt": {
"type": "string"
},
@@ -11111,17 +11180,56 @@
}
},
"required": [
"approvalId",
"challenge",
"controllerName",
"environmentId",
"expiresAt",
"mode",
"pairingCode",
"pairingId",
"prompt",
"serverId"
],
"title": "RemoteControlPairingStartResponse",
"title": "RemoteControlApprovalRequestParams",
"type": "object"
},
"RemoteControlApprovalRequestResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalKeyId": {
"type": [
"string",
"null"
]
},
"decision": {
"$ref": "#/definitions/v2/RemoteControlApprovalDecision"
},
"signature": {
"type": [
"string",
"null"
]
},
"signatureAlgorithm": {
"type": [
"string",
"null"
]
}
},
"required": [
"decision"
],
"title": "RemoteControlApprovalRequestResponse",
"type": "object"
},
"RemoteControlPairingMode": {
"enum": [
"session",
"server"
],
"type": "string"
},
"RequestId": {
"anyOf": [
{
@@ -1768,13 +1768,13 @@
},
"method": {
"enum": [
"remoteControl/pairing/start"
"remoteControl/approval/request"
],
"title": "RemoteControl/pairing/startRequestMethod",
"title": "RemoteControl/approval/requestRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/RemoteControlPairingStartParams"
"$ref": "#/definitions/RemoteControlApprovalRequestParams"
}
},
"required": [
@@ -1782,7 +1782,7 @@
"method",
"params"
],
"title": "RemoteControl/pairing/startRequest",
"title": "RemoteControl/approval/requestRequest",
"type": "object"
},
{
@@ -7836,29 +7836,25 @@
"title": "ReasoningTextDeltaNotification",
"type": "object"
},
"RemoteControlPairingMode": {
"RemoteControlApprovalDecision": {
"enum": [
"session",
"server"
"approve",
"deny"
],
"type": "string"
},
"RemoteControlPairingStartParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
}
},
"required": [
"mode"
],
"title": "RemoteControlPairingStartParams",
"type": "object"
},
"RemoteControlPairingStartResponse": {
"RemoteControlApprovalRequestParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalId": {
"type": "string"
},
"challenge": {
"type": "string"
},
"controllerName": {
"type": "string"
},
"environmentId": {
"type": "string"
},
@@ -7869,12 +7865,6 @@
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
},
"pairingCode": {
"type": "string"
},
"pairingId": {
"type": "string"
},
"prompt": {
"type": "string"
},
@@ -7883,17 +7873,56 @@
}
},
"required": [
"approvalId",
"challenge",
"controllerName",
"environmentId",
"expiresAt",
"mode",
"pairingCode",
"pairingId",
"prompt",
"serverId"
],
"title": "RemoteControlPairingStartResponse",
"title": "RemoteControlApprovalRequestParams",
"type": "object"
},
"RemoteControlApprovalRequestResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"approvalKeyId": {
"type": [
"string",
"null"
]
},
"decision": {
"$ref": "#/definitions/RemoteControlApprovalDecision"
},
"signature": {
"type": [
"string",
"null"
]
},
"signatureAlgorithm": {
"type": [
"string",
"null"
]
}
},
"required": [
"decision"
],
"title": "RemoteControlApprovalRequestResponse",
"type": "object"
},
"RemoteControlPairingMode": {
"enum": [
"session",
"server"
],
"type": "string"
},
"RequestId": {
"anyOf": [
{
@@ -0,0 +1,51 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RemoteControlPairingMode": {
"enum": [
"session",
"server"
],
"type": "string"
}
},
"properties": {
"approvalId": {
"type": "string"
},
"challenge": {
"type": "string"
},
"controllerName": {
"type": "string"
},
"environmentId": {
"type": "string"
},
"expiresAt": {
"format": "int64",
"type": "integer"
},
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
},
"prompt": {
"type": "string"
},
"serverId": {
"type": "string"
}
},
"required": [
"approvalId",
"challenge",
"controllerName",
"environmentId",
"expiresAt",
"mode",
"prompt",
"serverId"
],
"title": "RemoteControlApprovalRequestParams",
"type": "object"
}
@@ -0,0 +1,40 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RemoteControlApprovalDecision": {
"enum": [
"approve",
"deny"
],
"type": "string"
}
},
"properties": {
"approvalKeyId": {
"type": [
"string",
"null"
]
},
"decision": {
"$ref": "#/definitions/RemoteControlApprovalDecision"
},
"signature": {
"type": [
"string",
"null"
]
},
"signatureAlgorithm": {
"type": [
"string",
"null"
]
}
},
"required": [
"decision"
],
"title": "RemoteControlApprovalRequestResponse",
"type": "object"
}
@@ -1,22 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RemoteControlPairingMode": {
"enum": [
"session",
"server"
],
"type": "string"
}
},
"properties": {
"mode": {
"$ref": "#/definitions/RemoteControlPairingMode"
}
},
"required": [
"mode"
],
"title": "RemoteControlPairingStartParams",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -10,9 +10,10 @@ import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams";
import type { FileChangeRequestApprovalParams } from "./v2/FileChangeRequestApprovalParams";
import type { McpServerElicitationRequestParams } from "./v2/McpServerElicitationRequestParams";
import type { PermissionsRequestApprovalParams } from "./v2/PermissionsRequestApprovalParams";
import type { RemoteControlApprovalConfirmParams } from "./v2/RemoteControlApprovalConfirmParams";
import type { ToolRequestUserInputParams } from "./v2/ToolRequestUserInputParams";
/**
* Request initiated from the server and sent to the client.
*/
export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, };
export type ServerRequest = { "method": "item/commandExecution/requestApproval", id: RequestId, params: CommandExecutionRequestApprovalParams, } | { "method": "item/fileChange/requestApproval", id: RequestId, params: FileChangeRequestApprovalParams, } | { "method": "item/tool/requestUserInput", id: RequestId, params: ToolRequestUserInputParams, } | { "method": "mcpServer/elicitation/request", id: RequestId, params: McpServerElicitationRequestParams, } | { "method": "item/permissions/requestApproval", id: RequestId, params: PermissionsRequestApprovalParams, } | { "method": "item/tool/call", id: RequestId, params: DynamicToolCallParams, } | { "method": "account/chatgptAuthTokens/refresh", id: RequestId, params: ChatgptAuthTokensRefreshParams, } | { "method": "remoteControl/approval/confirm", id: RequestId, params: RemoteControlApprovalConfirmParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, };
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RemoteControlPairingMode } from "./RemoteControlPairingMode";
export type RemoteControlPairingStartParams = { mode: RemoteControlPairingMode, };
export type RemoteControlApprovalConfirmParams = { approvalId: string, serverId: string, environmentId: string, mode: RemoteControlPairingMode, controllerName: string, prompt: string, expiresAt: bigint, challenge: string, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RemoteControlApprovalDecision } from "./RemoteControlApprovalDecision";
export type RemoteControlApprovalConfirmResponse = { decision: RemoteControlApprovalDecision, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type RemoteControlApprovalDecision = "approve" | "deny";
@@ -3,4 +3,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RemoteControlPairingMode } from "./RemoteControlPairingMode";
export type RemoteControlPairingStartResponse = { pairingId: string, pairingCode: string, expiresAt: bigint, serverId: string, environmentId: string, mode: RemoteControlPairingMode, prompt: string, };
export type RemoteControlApprovalRequestParams = { approvalId: string, serverId: string, environmentId: string, mode: RemoteControlPairingMode, controllerName: string, prompt: string, expiresAt: bigint, challenge: string, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RemoteControlApprovalDecision } from "./RemoteControlApprovalDecision";
export type RemoteControlApprovalRequestResponse = { decision: RemoteControlApprovalDecision, signature: string | null, signatureAlgorithm: string | null, approvalKeyId: string | null, };
@@ -247,9 +247,12 @@ export type { ReasoningEffortOption } from "./ReasoningEffortOption";
export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification";
export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification";
export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification";
export type { RemoteControlApprovalConfirmParams } from "./RemoteControlApprovalConfirmParams";
export type { RemoteControlApprovalConfirmResponse } from "./RemoteControlApprovalConfirmResponse";
export type { RemoteControlApprovalDecision } from "./RemoteControlApprovalDecision";
export type { RemoteControlApprovalRequestParams } from "./RemoteControlApprovalRequestParams";
export type { RemoteControlApprovalRequestResponse } from "./RemoteControlApprovalRequestResponse";
export type { RemoteControlPairingMode } from "./RemoteControlPairingMode";
export type { RemoteControlPairingStartParams } from "./RemoteControlPairingStartParams";
export type { RemoteControlPairingStartResponse } from "./RemoteControlPairingStartResponse";
export type { RequestPermissionProfile } from "./RequestPermissionProfile";
export type { ResidencyRequirement } from "./ResidencyRequirement";
export type { ReviewDelivery } from "./ReviewDelivery";
@@ -462,9 +462,9 @@ client_request_definitions! {
params: v2::CollaborationModeListParams,
response: v2::CollaborationModeListResponse,
},
RemoteControlPairingStart => "remoteControl/pairing/start" {
params: v2::RemoteControlPairingStartParams,
response: v2::RemoteControlPairingStartResponse,
RemoteControlApprovalRequest => "remoteControl/approval/request" {
params: v2::RemoteControlApprovalRequestParams,
response: v2::RemoteControlApprovalRequestResponse,
},
#[experimental("mock/experimentalMethod")]
/// Test-only method used to validate experimental gating.
@@ -885,6 +885,10 @@ server_request_definitions! {
params: v2::ChatgptAuthTokensRefreshParams,
response: v2::ChatgptAuthTokensRefreshResponse,
},
RemoteControlApprovalConfirm => "remoteControl/approval/confirm" {
params: v2::RemoteControlApprovalConfirmParams,
response: v2::RemoteControlApprovalConfirmResponse,
},
/// DEPRECATED APIs below
/// Request to approve a patch.
@@ -1689,27 +1693,6 @@ mod tests {
Ok(())
}
#[test]
fn serialize_remote_control_pairing_start() -> Result<()> {
let request = ClientRequest::RemoteControlPairingStart {
request_id: RequestId::Integer(8),
params: v2::RemoteControlPairingStartParams {
mode: v2::RemoteControlPairingMode::Session,
},
};
assert_eq!(
json!({
"method": "remoteControl/pairing/start",
"id": 8,
"params": {
"mode": "session"
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn serialize_list_apps() -> Result<()> {
let request = ClientRequest::AppsList {
@@ -1911,24 +1911,57 @@ pub enum RemoteControlPairingMode {
Server,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStartParams {
pub mode: RemoteControlPairingMode,
pub enum RemoteControlApprovalDecision {
Approve,
Deny,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlPairingStartResponse {
pub pairing_id: String,
pub pairing_code: String,
pub expires_at: i64,
pub struct RemoteControlApprovalRequestParams {
pub approval_id: String,
pub server_id: String,
pub environment_id: String,
pub mode: RemoteControlPairingMode,
pub controller_name: String,
pub prompt: String,
pub expires_at: i64,
pub challenge: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlApprovalRequestResponse {
pub decision: RemoteControlApprovalDecision,
pub signature: Option<String>,
pub signature_algorithm: Option<String>,
pub approval_key_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlApprovalConfirmParams {
pub approval_id: String,
pub server_id: String,
pub environment_id: String,
pub mode: RemoteControlPairingMode,
pub controller_name: String,
pub prompt: String,
pub expires_at: i64,
pub challenge: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteControlApprovalConfirmResponse {
pub decision: RemoteControlApprovalDecision,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
+3
View File
@@ -38,6 +38,7 @@ codex-core-plugins = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-git-utils = { workspace = true }
codex-keyring-store = { workspace = true }
codex-otel = { workspace = true }
codex-shell-command = { workspace = true }
codex-utils-cli = { workspace = true }
@@ -63,11 +64,13 @@ codex-utils-rustls-provider = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
constant_time_eq = { workspace = true }
ed25519-dalek = { workspace = true }
futures = { workspace = true }
gethostname = { workspace = true }
hmac = { workspace = true }
jsonwebtoken = { workspace = true }
owo-colors = { workspace = true, features = ["supports-colors"] }
rand = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
-1
View File
@@ -179,7 +179,6 @@ Example with notification opt-out:
- `fs/unwatch` — stop sending notifications for a prior `fs/watch`; returns `{}`.
- `fs/changed` — notification emitted when watched paths change, including the `watchId` and `changedPaths`.
- `model/list` — list available models (set `includeHidden: true` to include entries with `hidden: true`), with reasoning effort options, `additionalSpeedTiers`, optional legacy `upgrade` model ids, optional `upgradeInfo` metadata (`model`, `upgradeCopy`, `modelLink`, `migrationMarkdown`), and optional `availabilityNux` metadata.
- `remoteControl/pairing/start` — create a short-lived pairing challenge for the current remote-control server enrollment. Clients can display `pairingCode` directly or render it as a QR payload; `mode` is `"session"` for the current Codex session or `"server"` for a long-running server-mode process.
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
@@ -1149,9 +1149,9 @@ impl CodexMessageProcessor {
| ClientRequest::ExperimentalFeatureEnablementSet { .. } => {
warn!("Config request reached CodexMessageProcessor unexpectedly");
}
ClientRequest::RemoteControlPairingStart { .. } => {
ClientRequest::RemoteControlApprovalRequest { .. } => {
warn!(
"RemoteControlPairingStart request reached CodexMessageProcessor unexpectedly"
"RemoteControlApprovalRequest request reached CodexMessageProcessor unexpectedly"
);
}
ClientRequest::FsReadFile { .. }
+8
View File
@@ -120,6 +120,7 @@ enum OutboundControlEvent {
initialized: Arc<AtomicBool>,
experimental_api_enabled: Arc<AtomicBool>,
opted_out_notification_methods: Arc<RwLock<HashSet<String>>>,
registered_tx: oneshot::Sender<()>,
},
/// Remove state for a closed/disconnected connection.
Closed { connection_id: ConnectionId },
@@ -609,6 +610,7 @@ pub async fn run_main_with_transport(
initialized,
experimental_api_enabled,
opted_out_notification_methods,
registered_tx,
} => {
outbound_connections.insert(
connection_id,
@@ -620,6 +622,7 @@ pub async fn run_main_with_transport(
disconnect_sender,
),
);
let _ = registered_tx.send(());
}
OutboundControlEvent::Closed { connection_id } => {
outbound_connections.remove(&connection_id);
@@ -721,6 +724,7 @@ pub async fn run_main_with_transport(
Arc::new(AtomicBool::new(false));
let outbound_opted_out_notification_methods =
Arc::new(RwLock::new(HashSet::new()));
let (registered_tx, registered_rx) = oneshot::channel();
if outbound_control_tx
.send(OutboundControlEvent::Opened {
connection_id,
@@ -733,12 +737,16 @@ pub async fn run_main_with_transport(
opted_out_notification_methods: Arc::clone(
&outbound_opted_out_notification_methods,
),
registered_tx,
})
.await
.is_err()
{
break;
}
if registered_rx.await.is_err() {
break;
}
connections.insert(
connection_id,
ConnectionState::new(
+137 -67
View File
@@ -21,7 +21,6 @@ use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::RequestContext;
use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle;
use crate::transport::RemoteControlPairingMode as TransportRemoteControlPairingMode;
use async_trait::async_trait;
use axum::http::HeaderValue;
use codex_analytics::AnalyticsEventsClient;
@@ -57,9 +56,11 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RemoteControlPairingMode;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RemoteControlApprovalConfirmParams;
use codex_app_server_protocol::RemoteControlApprovalConfirmResponse;
use codex_app_server_protocol::RemoteControlApprovalDecision;
use codex_app_server_protocol::RemoteControlApprovalRequestParams;
use codex_app_server_protocol::RemoteControlApprovalRequestResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
@@ -96,6 +97,7 @@ use toml::Value as TomlValue;
use tracing::Instrument;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_CONTROL_APPROVAL_TIMEOUT: Duration = Duration::from_secs(5 * 60);
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
@@ -949,16 +951,22 @@ impl MessageProcessor {
)
.await;
}
ClientRequest::RemoteControlPairingStart { request_id, params } => {
self.handle_remote_control_pairing_start(
ConnectionRequestId {
connection_id,
request_id,
},
params,
app_server_client_name.as_deref(),
)
.await;
ClientRequest::RemoteControlApprovalRequest { request_id, params } => {
let processor = self.clone();
tokio::spawn(
async move {
processor
.handle_remote_control_approval_request(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
.instrument(request_context.span()),
);
}
other => {
// Box the delegated future so this wrapper's async state machine does not
@@ -1242,80 +1250,142 @@ impl MessageProcessor {
}
}
async fn handle_remote_control_pairing_start(
async fn handle_remote_control_approval_request(
&self,
request_id: ConnectionRequestId,
params: RemoteControlPairingStartParams,
app_server_client_name: Option<&str>,
params: RemoteControlApprovalRequestParams,
) {
let result = match &self.remote_control_handle {
Some(remote_control_handle) => {
remote_control_handle
.start_pairing(
app_server_client_name,
remote_control_pairing_mode(params.mode),
)
.await
}
None => Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"remote control is not available for this app-server",
)),
let RemoteControlApprovalRequestParams {
approval_id,
server_id,
environment_id,
mode,
controller_name,
prompt,
expires_at,
challenge,
} = params;
let confirm_params = RemoteControlApprovalConfirmParams {
approval_id: approval_id.clone(),
server_id,
environment_id,
mode,
controller_name,
prompt,
expires_at,
challenge: challenge.clone(),
};
match result {
Ok(pairing) => {
let (approval_request_id, rx) = self
.outgoing
.send_request(ServerRequestPayload::RemoteControlApprovalConfirm(
confirm_params,
))
.await;
let result = match timeout(REMOTE_CONTROL_APPROVAL_TIMEOUT, rx).await {
Ok(Ok(Ok(result))) => result,
Ok(Ok(Err(error))) => {
self.outgoing.send_error(request_id, error).await;
return;
}
Ok(Err(error)) => {
self.outgoing
.send_error(
request_id,
remote_control_approval_error(format!(
"remote control approval request canceled: {error}"
)),
)
.await;
return;
}
Err(_) => {
let _canceled = self.outgoing.cancel_request(&approval_request_id).await;
self.outgoing
.send_error(
request_id,
remote_control_approval_error(format!(
"remote control approval timed out after {}s",
REMOTE_CONTROL_APPROVAL_TIMEOUT.as_secs()
)),
)
.await;
return;
}
};
match serde_json::from_value::<RemoteControlApprovalConfirmResponse>(result) {
Ok(response) => {
let signature = if response.decision == RemoteControlApprovalDecision::Approve {
match self
.remote_control_handle
.as_ref()
.map(|handle| handle.sign_approval(&challenge))
{
Some(Ok(signature)) => Some(signature),
Some(Err(error)) => {
self.outgoing
.send_error(
request_id,
remote_control_approval_error(format!(
"failed to sign remote control approval: {error}"
)),
)
.await;
return;
}
None => {
self.outgoing
.send_error(
request_id,
remote_control_approval_error(
"remote control approval key is unavailable".to_string(),
),
)
.await;
return;
}
}
} else {
None
};
self.outgoing
.send_response(
request_id,
RemoteControlPairingStartResponse {
pairing_id: pairing.pairing_id,
pairing_code: pairing.pairing_code,
expires_at: pairing.expires_at,
server_id: pairing.server_id,
environment_id: pairing.environment_id,
mode: match pairing.mode {
TransportRemoteControlPairingMode::Session => {
RemoteControlPairingMode::Session
}
TransportRemoteControlPairingMode::Server => {
RemoteControlPairingMode::Server
}
},
prompt: pairing.prompt,
RemoteControlApprovalRequestResponse {
decision: response.decision,
signature: signature
.as_ref()
.map(|signature| signature.signature.clone()),
signature_algorithm: signature
.as_ref()
.map(|signature| signature.signature_algorithm.to_string()),
approval_key_id: signature
.as_ref()
.map(|signature| signature.approval_key_id.clone()),
},
)
.await;
}
Err(error) => {
self.outgoing
.send_error(request_id, remote_control_pairing_start_error(error))
.send_error(
request_id,
remote_control_approval_error(format!(
"invalid remote control approval response: {error}"
)),
)
.await;
}
}
}
}
fn remote_control_pairing_mode(
mode: RemoteControlPairingMode,
) -> TransportRemoteControlPairingMode {
match mode {
RemoteControlPairingMode::Session => TransportRemoteControlPairingMode::Session,
RemoteControlPairingMode::Server => TransportRemoteControlPairingMode::Server,
}
}
fn remote_control_pairing_start_error(error: std::io::Error) -> JSONRPCErrorError {
let code = match error.kind() {
std::io::ErrorKind::InvalidInput
| std::io::ErrorKind::NotFound
| std::io::ErrorKind::PermissionDenied
| std::io::ErrorKind::WouldBlock => INVALID_REQUEST_ERROR_CODE,
_ => INTERNAL_ERROR_CODE,
};
fn remote_control_approval_error(message: String) -> JSONRPCErrorError {
JSONRPCErrorError {
code,
message: error.to_string(),
code: INTERNAL_ERROR_CODE,
message,
data: None,
}
}
-1
View File
@@ -34,7 +34,6 @@ mod stdio;
mod websocket;
pub(crate) use remote_control::RemoteControlHandle;
pub(crate) use remote_control::RemoteControlPairingMode;
pub(crate) use remote_control::start_remote_control;
pub(crate) use stdio::start_stdio_connection;
pub(crate) use websocket::start_websocket_acceptor;
@@ -0,0 +1,194 @@
use super::protocol::RemoteControlTarget;
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
#[cfg(not(test))]
use codex_keyring_store::DefaultKeyringStore;
use codex_keyring_store::KeyringStore;
use ed25519_dalek::Signer;
use ed25519_dalek::SigningKey;
use sha2::Digest;
use sha2::Sha256;
use std::io;
use std::sync::Arc;
const KEYRING_SERVICE: &str = "Codex Remote Control Approval";
pub(super) const SIGNATURE_ALGORITHM: &str = "ed25519";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteControlApprovalSignature {
pub(crate) signature: String,
pub(crate) signature_algorithm: &'static str,
pub(crate) approval_key_id: String,
}
#[derive(Clone)]
pub(super) struct RemoteControlApprovalKey {
signing_key: SigningKey,
key_id: String,
public_key: String,
}
impl RemoteControlApprovalKey {
pub(super) fn load_or_generate(
remote_control_target: &RemoteControlTarget,
account_id: &str,
app_server_client_name: Option<&str>,
) -> io::Result<Self> {
#[cfg(test)]
{
let _ = (remote_control_target, account_id, app_server_client_name);
Ok(Self::new(SigningKey::from_bytes(&[42; 32])))
}
#[cfg(not(test))]
{
Self::load_or_generate_with_store(
remote_control_target,
account_id,
app_server_client_name,
Arc::new(DefaultKeyringStore),
)
}
}
fn load_or_generate_with_store(
remote_control_target: &RemoteControlTarget,
account_id: &str,
app_server_client_name: Option<&str>,
keyring_store: Arc<dyn KeyringStore>,
) -> io::Result<Self> {
let account = keyring_account(remote_control_target, account_id, app_server_client_name);
match keyring_store
.load(KEYRING_SERVICE, &account)
.map_err(|err| {
io::Error::other(format!(
"failed to load remote control approval key from keyring: {}",
err.message()
))
})? {
Some(serialized) => signing_key_from_serialized_seed(&serialized).map(Self::new),
None => {
let seed: [u8; 32] = rand::random();
let serialized = URL_SAFE_NO_PAD.encode(seed);
keyring_store
.save(KEYRING_SERVICE, &account, &serialized)
.map_err(|err| {
io::Error::other(format!(
"failed to save remote control approval key to keyring: {}",
err.message()
))
})?;
Ok(Self::new(SigningKey::from_bytes(&seed)))
}
}
}
fn new(signing_key: SigningKey) -> Self {
let public_key_bytes = signing_key.verifying_key().to_bytes();
let key_digest = Sha256::digest(public_key_bytes);
Self {
signing_key,
key_id: format!(
"{SIGNATURE_ALGORITHM}:{}",
URL_SAFE_NO_PAD.encode(key_digest)
),
public_key: URL_SAFE_NO_PAD.encode(public_key_bytes),
}
}
pub(super) fn public_key(&self) -> &str {
&self.public_key
}
pub(super) fn key_id(&self) -> &str {
&self.key_id
}
pub(super) fn sign(&self, challenge: &str) -> RemoteControlApprovalSignature {
let signature = self.signing_key.sign(challenge.as_bytes());
RemoteControlApprovalSignature {
signature: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
signature_algorithm: SIGNATURE_ALGORITHM,
approval_key_id: self.key_id.clone(),
}
}
}
fn signing_key_from_serialized_seed(serialized: &str) -> io::Result<SigningKey> {
let seed = URL_SAFE_NO_PAD
.decode(serialized.as_bytes())
.map_err(|err| {
io::Error::other(format!(
"failed to decode remote control approval key from keyring: {err}"
))
})?;
let seed: [u8; 32] = seed.try_into().map_err(|_| {
io::Error::other("remote control approval key in keyring has invalid length")
})?;
Ok(SigningKey::from_bytes(&seed))
}
fn keyring_account(
remote_control_target: &RemoteControlTarget,
account_id: &str,
app_server_client_name: Option<&str>,
) -> String {
let mut hasher = Sha256::new();
hasher.update(remote_control_target.websocket_url.as_bytes());
hasher.update(b"\n");
hasher.update(account_id.as_bytes());
hasher.update(b"\n");
if let Some(app_server_client_name) = app_server_client_name {
hasher.update(app_server_client_name.as_bytes());
}
let digest = hasher.finalize();
let encoded = URL_SAFE_NO_PAD.encode(digest);
format!("remote-control-approval|{encoded}")
}
#[cfg(test)]
mod tests {
use super::*;
use codex_keyring_store::tests::MockKeyringStore;
use pretty_assertions::assert_eq;
fn target() -> RemoteControlTarget {
RemoteControlTarget {
websocket_url: "wss://chatgpt.com/backend-api/wham/remote/control/server".to_string(),
enroll_url: "https://chatgpt.com/backend-api/wham/remote/control/server/enroll"
.to_string(),
}
}
#[test]
fn load_or_generate_reuses_keyring_seed() {
let keyring_store = Arc::new(MockKeyringStore::default());
let first = RemoteControlApprovalKey::load_or_generate_with_store(
&target(),
"account-id",
Some("desktop"),
keyring_store.clone(),
)
.expect("key should generate");
let second = RemoteControlApprovalKey::load_or_generate_with_store(
&target(),
"account-id",
Some("desktop"),
keyring_store,
)
.expect("key should load");
assert_eq!(first.key_id(), second.key_id());
assert_eq!(first.public_key(), second.public_key());
}
#[test]
fn sign_returns_key_bound_signature_metadata() {
let key = RemoteControlApprovalKey::new(SigningKey::from_bytes(&[7; 32]));
assert_eq!(
key.sign("challenge").signature_algorithm,
SIGNATURE_ALGORITHM
);
assert_eq!(key.sign("challenge").approval_key_id, key.key_id());
}
}
@@ -1,3 +1,4 @@
use super::approval_key::RemoteControlApprovalKey;
use super::protocol::EnrollRemoteServerRequest;
use super::protocol::EnrollRemoteServerResponse;
use super::protocol::RemoteControlTarget;
@@ -25,6 +26,7 @@ pub(super) struct RemoteControlEnrollment {
pub(super) environment_id: String,
pub(super) server_id: String,
pub(super) server_name: String,
pub(super) approval_key_id: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -79,6 +81,7 @@ pub(super) async fn load_persisted_remote_control_enrollment(
environment_id: enrollment.environment_id,
server_id: enrollment.server_id,
server_name: enrollment.server_name,
approval_key_id: enrollment.approval_key_id,
})
}
None => {
@@ -125,6 +128,7 @@ pub(super) async fn update_persisted_remote_control_enrollment(
server_id: enrollment.server_id.clone(),
environment_id: enrollment.environment_id.clone(),
server_name: enrollment.server_name.clone(),
approval_key_id: enrollment.approval_key_id.clone(),
})
.await
.map_err(io::Error::other)?;
@@ -189,6 +193,8 @@ pub(crate) fn format_headers(headers: &HeaderMap) -> String {
pub(super) async fn enroll_remote_control_server(
remote_control_target: &RemoteControlTarget,
auth: &RemoteControlConnectionAuth,
approval_key: &RemoteControlApprovalKey,
existing_enrollment: Option<&RemoteControlEnrollment>,
) -> io::Result<RemoteControlEnrollment> {
let enroll_url = &remote_control_target.enroll_url;
let server_name = gethostname().to_string_lossy().trim().to_string();
@@ -197,6 +203,10 @@ pub(super) async fn enroll_remote_control_server(
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
app_server_version: env!("CARGO_PKG_VERSION"),
approval_public_key: approval_key.public_key().to_string(),
approval_key_id: approval_key.key_id().to_string(),
server_id: existing_enrollment.map(|enrollment| enrollment.server_id.clone()),
environment_id: existing_enrollment.map(|enrollment| enrollment.environment_id.clone()),
};
let client = build_reqwest_client();
let http_request = client
@@ -221,10 +231,10 @@ pub(super) async fn enroll_remote_control_server(
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let headers_str = format_headers(&headers);
let error_kind = if matches!(status.as_u16(), 401 | 403) {
ErrorKind::PermissionDenied
} else {
ErrorKind::Other
let error_kind = match status.as_u16() {
401 | 403 => ErrorKind::PermissionDenied,
404 => ErrorKind::NotFound,
_ => ErrorKind::Other,
};
return Err(io::Error::new(
error_kind,
@@ -246,6 +256,7 @@ pub(super) async fn enroll_remote_control_server(
environment_id: enrollment.environment_id,
server_id: enrollment.server_id,
server_name,
approval_key_id: approval_key.key_id().to_string(),
})
}
@@ -286,12 +297,14 @@ mod tests {
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
server_name: "first-server".to_string(),
approval_key_id: "approval-key-a".to_string(),
};
let second_enrollment = RemoteControlEnrollment {
account_id: "account-a".to_string(),
environment_id: "env_second".to_string(),
server_id: "srv_e_second".to_string(),
server_name: "second-server".to_string(),
approval_key_id: "approval-key-b".to_string(),
};
update_persisted_remote_control_enrollment(
@@ -359,12 +372,14 @@ mod tests {
environment_id: "env_first".to_string(),
server_id: "srv_e_first".to_string(),
server_name: "first-server".to_string(),
approval_key_id: "approval-key-a".to_string(),
};
let second_enrollment = RemoteControlEnrollment {
account_id: "account-a".to_string(),
environment_id: "env_second".to_string(),
server_id: "srv_e_second".to_string(),
server_name: "second-server".to_string(),
approval_key_id: "approval-key-b".to_string(),
};
update_persisted_remote_control_enrollment(
@@ -448,6 +463,13 @@ mod tests {
bearer_token: "Access Token".to_string(),
account_id: "account_id".to_string(),
},
&RemoteControlApprovalKey::load_or_generate(
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
)
.expect("approval key should load"),
/*existing_enrollment*/ None,
)
.await
.expect_err("invalid response should fail to parse");
@@ -1,11 +1,14 @@
mod approval_key;
mod client_tracker;
mod enroll;
mod pairing;
mod protocol;
mod websocket;
use crate::transport::remote_control::pairing::start_remote_control_pairing;
use crate::transport::remote_control::approval_key::RemoteControlApprovalKey;
use crate::transport::remote_control::approval_key::RemoteControlApprovalSignature;
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
use crate::transport::remote_control::websocket::RemoteControlWebsocketControls;
use crate::transport::remote_control::websocket::load_remote_control_auth;
pub use self::protocol::ClientId;
use self::protocol::ServerEvent;
@@ -16,10 +19,9 @@ use super::TransportEvent;
use super::next_connection_id;
use codex_login::AuthManager;
use codex_state::StateRuntime;
pub(crate) use pairing::RemoteControlPairing;
pub(crate) use pairing::RemoteControlPairingMode;
use std::io;
use std::sync::Arc;
use std::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::watch;
@@ -36,9 +38,7 @@ pub(super) struct QueuedServerEnvelope {
#[derive(Clone)]
pub(crate) struct RemoteControlHandle {
enabled_tx: Arc<watch::Sender<bool>>,
remote_control_url: String,
state_db: Option<Arc<StateRuntime>>,
auth_manager: Arc<AuthManager>,
approval_key: Arc<Mutex<Option<RemoteControlApprovalKey>>>,
}
impl RemoteControlHandle {
@@ -50,19 +50,42 @@ impl RemoteControlHandle {
});
}
pub(crate) async fn start_pairing(
pub(crate) fn sign_approval(
&self,
app_server_client_name: Option<&str>,
mode: RemoteControlPairingMode,
) -> io::Result<RemoteControlPairing> {
start_remote_control_pairing(
&self.remote_control_url,
self.state_db.as_deref(),
&self.auth_manager,
app_server_client_name,
mode,
)
.await
challenge: &str,
) -> io::Result<RemoteControlApprovalSignature> {
let guard = self
.approval_key
.lock()
.map_err(|_| io::Error::other("remote control approval key lock poisoned"))?;
let Some(approval_key) = guard.as_ref() else {
return Err(io::Error::other(
"remote control approval key is not available",
));
};
Ok(approval_key.sign(challenge))
}
}
struct RemoteControlApprovalKeyHandle {
approval_key: Arc<Mutex<Option<RemoteControlApprovalKey>>>,
}
impl RemoteControlApprovalKeyHandle {
#[cfg(test)]
fn empty() -> Self {
Self {
approval_key: Arc::new(Mutex::new(None)),
}
}
fn set(&self, approval_key: RemoteControlApprovalKey) -> io::Result<()> {
let mut guard = self
.approval_key
.lock()
.map_err(|_| io::Error::other("remote control approval key lock poisoned"))?;
*guard = Some(approval_key);
Ok(())
}
}
@@ -82,9 +105,10 @@ pub(crate) async fn start_remote_control(
};
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
let handle_remote_control_url = remote_control_url.clone();
let handle_state_db = state_db.clone();
let handle_auth_manager = auth_manager.clone();
let approval_key = Arc::new(Mutex::new(None));
let approval_key_handle = RemoteControlApprovalKeyHandle {
approval_key: approval_key.clone(),
};
let join_handle = tokio::spawn(async move {
RemoteControlWebsocket::new(
remote_control_url,
@@ -93,7 +117,10 @@ pub(crate) async fn start_remote_control(
auth_manager,
transport_event_tx,
shutdown_token,
enabled_rx,
RemoteControlWebsocketControls {
enabled_rx,
approval_key_handle,
},
)
.run(app_server_client_name_rx)
.await;
@@ -103,9 +130,7 @@ pub(crate) async fn start_remote_control(
join_handle,
RemoteControlHandle {
enabled_tx: Arc::new(enabled_tx),
remote_control_url: handle_remote_control_url,
state_db: handle_state_db,
auth_manager: handle_auth_manager,
approval_key,
},
))
}
@@ -1,162 +0,0 @@
use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER;
use super::enroll::RemoteControlConnectionAuth;
use super::enroll::RemoteControlEnrollment;
use super::enroll::format_headers;
use super::enroll::load_persisted_remote_control_enrollment;
use super::enroll::preview_remote_control_response_body;
use super::protocol::RemoteControlTarget;
use super::protocol::normalize_remote_control_url;
use super::websocket::load_remote_control_auth;
use codex_login::AuthManager;
use codex_login::default_client::build_reqwest_client;
use codex_state::StateRuntime;
use serde::Deserialize;
use serde::Serialize;
use std::io;
use std::io::ErrorKind;
use std::sync::Arc;
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
const REMOTE_CONTROL_PAIRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum RemoteControlPairingMode {
Session,
Server,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RemoteControlPairing {
pub(crate) pairing_id: String,
pub(crate) pairing_code: String,
pub(crate) expires_at: i64,
pub(crate) server_id: String,
pub(crate) environment_id: String,
pub(crate) mode: RemoteControlPairingMode,
pub(crate) prompt: String,
}
#[derive(Debug, Serialize)]
struct StartRemoteControlPairingRequest<'a> {
server_id: &'a str,
environment_id: &'a str,
mode: RemoteControlPairingMode,
}
#[derive(Debug, Deserialize)]
struct StartRemoteControlPairingResponse {
pairing_id: String,
pairing_code: String,
expires_at: String,
server_id: String,
environment_id: String,
mode: RemoteControlPairingMode,
prompt: String,
}
pub(crate) async fn start_remote_control_pairing(
remote_control_url: &str,
state_db: Option<&StateRuntime>,
auth_manager: &Arc<AuthManager>,
app_server_client_name: Option<&str>,
mode: RemoteControlPairingMode,
) -> io::Result<RemoteControlPairing> {
let remote_control_target = normalize_remote_control_url(remote_control_url)?;
let auth = load_remote_control_auth(auth_manager).await?;
let enrollment = load_persisted_remote_control_enrollment(
state_db,
&remote_control_target,
&auth.account_id,
app_server_client_name,
)
.await
.ok_or_else(|| {
io::Error::new(
ErrorKind::NotFound,
"remote control server is not enrolled yet; enable remote control and wait for the server connection before starting pairing",
)
})?;
post_start_remote_control_pairing(&remote_control_target, &auth, &enrollment, mode).await
}
async fn post_start_remote_control_pairing(
remote_control_target: &RemoteControlTarget,
auth: &RemoteControlConnectionAuth,
enrollment: &RemoteControlEnrollment,
mode: RemoteControlPairingMode,
) -> io::Result<RemoteControlPairing> {
let pairing_start_url = &remote_control_target.pairing_start_url;
let request = StartRemoteControlPairingRequest {
server_id: &enrollment.server_id,
environment_id: &enrollment.environment_id,
mode,
};
let client = build_reqwest_client();
let http_request = client
.post(pairing_start_url)
.timeout(REMOTE_CONTROL_PAIRING_TIMEOUT)
.bearer_auth(&auth.bearer_token)
.header(REMOTE_CONTROL_ACCOUNT_ID_HEADER, &auth.account_id)
.json(&request);
let response = http_request.send().await.map_err(|err| {
io::Error::other(format!(
"failed to start remote control pairing at `{pairing_start_url}`: {err}"
))
})?;
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 response from `{pairing_start_url}`: {err}"
))
})?;
let body_preview = preview_remote_control_response_body(&body);
if !status.is_success() {
let headers_str = format_headers(&headers);
let error_kind = if matches!(status.as_u16(), 401 | 403) {
ErrorKind::PermissionDenied
} else {
ErrorKind::Other
};
return Err(io::Error::new(
error_kind,
format!(
"remote control pairing failed at `{pairing_start_url}`: HTTP {status}, {headers_str}, body: {body_preview}"
),
));
}
let response =
serde_json::from_slice::<StartRemoteControlPairingResponse>(&body).map_err(|err| {
let headers_str = format_headers(&headers);
io::Error::other(format!(
"failed to parse remote control pairing response from `{pairing_start_url}`: HTTP {status}, {headers_str}, body: {body_preview}, decode error: {err}"
))
})?;
if response.server_id != enrollment.server_id
|| response.environment_id != enrollment.environment_id
|| response.mode != mode
{
return Err(io::Error::new(
ErrorKind::InvalidData,
"remote control pairing response did not match the active server enrollment",
));
}
let expires_at = OffsetDateTime::parse(&response.expires_at, &Rfc3339)
.map_err(|err| io::Error::other(format!("invalid remote control pairing expiry: {err}")))?
.unix_timestamp();
Ok(RemoteControlPairing {
pairing_id: response.pairing_id,
pairing_code: response.pairing_code,
expires_at,
server_id: response.server_id,
environment_id: response.environment_id,
mode: response.mode,
prompt: response.prompt,
})
}
@@ -11,7 +11,6 @@ use url::Url;
pub(super) struct RemoteControlTarget {
pub(super) websocket_url: String,
pub(super) enroll_url: String,
pub(super) pairing_start_url: String,
}
#[derive(Debug, Serialize)]
@@ -20,6 +19,12 @@ pub(super) struct EnrollRemoteServerRequest {
pub(super) os: &'static str,
pub(super) arch: &'static str,
pub(super) app_server_version: &'static str,
pub(super) approval_public_key: String,
pub(super) approval_key_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) server_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(super) environment_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -152,9 +157,6 @@ pub(super) fn normalize_remote_control_url(
let enroll_url = remote_control_url
.join("wham/remote/control/server/enroll")
.map_err(map_url_parse_error)?;
let pairing_start_url = remote_control_url
.join("wham/remote/control/server/pairing/start")
.map_err(map_url_parse_error)?;
let mut websocket_url = remote_control_url
.join("wham/remote/control/server")
.map_err(map_url_parse_error)?;
@@ -172,7 +174,6 @@ pub(super) fn normalize_remote_control_url(
Ok(RemoteControlTarget {
websocket_url: websocket_url.to_string(),
enroll_url: enroll_url.to_string(),
pairing_start_url: pairing_start_url.to_string(),
})
}
@@ -191,9 +192,6 @@ mod tests {
.to_string(),
enroll_url: "https://chatgpt.com/backend-api/wham/remote/control/server/enroll"
.to_string(),
pairing_start_url:
"https://chatgpt.com/backend-api/wham/remote/control/server/pairing/start"
.to_string(),
}
);
assert_eq!(
@@ -206,9 +204,6 @@ mod tests {
enroll_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/enroll"
.to_string(),
pairing_start_url:
"https://api.chatgpt-staging.com/backend-api/wham/remote/control/server/pairing/start"
.to_string(),
}
);
}
@@ -223,9 +218,6 @@ mod tests {
.to_string(),
enroll_url: "http://localhost:8080/backend-api/wham/remote/control/server/enroll"
.to_string(),
pairing_start_url:
"http://localhost:8080/backend-api/wham/remote/control/server/pairing/start"
.to_string(),
}
);
assert_eq!(
@@ -236,9 +228,6 @@ mod tests {
.to_string(),
enroll_url: "https://localhost:8443/backend-api/wham/remote/control/server/enroll"
.to_string(),
pairing_start_url:
"https://localhost:8443/backend-api/wham/remote/control/server/pairing/start"
.to_string(),
}
);
}
@@ -2,8 +2,6 @@ use super::enroll::REMOTE_CONTROL_ACCOUNT_ID_HEADER;
use super::enroll::RemoteControlEnrollment;
use super::enroll::load_persisted_remote_control_enrollment;
use super::enroll::update_persisted_remote_control_enrollment;
use super::pairing::RemoteControlPairingMode;
use super::pairing::start_remote_control_pairing;
use super::protocol::ClientEnvelope;
use super::protocol::ClientEvent;
use super::protocol::ClientId;
@@ -116,6 +114,21 @@ fn remote_control_url_for_listener(listener: &TcpListener) -> String {
format!("http://{addr}/backend-api/")
}
fn test_approval_key_id(
remote_control_target: &super::protocol::RemoteControlTarget,
account_id: &str,
app_server_client_name: Option<&str>,
) -> String {
RemoteControlApprovalKey::load_or_generate(
remote_control_target,
account_id,
app_server_client_name,
)
.expect("test approval key should load")
.key_id()
.to_string()
}
#[tokio::test]
async fn remote_control_transport_manages_virtual_clients_and_routes_messages() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -776,16 +789,14 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
enroll_request.headers.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER),
Some(&"account_id".to_string())
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&enroll_request.body)
.expect("enroll body should deserialize"),
json!({
"name": expected_server_name,
"os": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"app_server_version": env!("CARGO_PKG_VERSION"),
})
);
let enroll_body = serde_json::from_str::<serde_json::Value>(&enroll_request.body)
.expect("enroll body should deserialize");
assert_eq!(enroll_body["name"], expected_server_name);
assert_eq!(enroll_body["os"], std::env::consts::OS);
assert_eq!(enroll_body["arch"], std::env::consts::ARCH);
assert_eq!(enroll_body["app_server_version"], env!("CARGO_PKG_VERSION"));
assert!(enroll_body["approval_public_key"].is_string());
assert!(enroll_body["approval_key_id"].is_string());
respond_with_json(
enroll_request.stream,
json!({ "server_id": "srv_e_test", "environment_id": "env_test" }),
@@ -949,11 +960,17 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling
let state_db = remote_control_state_runtime(&codex_home).await;
let remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let approval_key_id = test_approval_key_id(
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
);
let persisted_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_persisted".to_string(),
server_id: "srv_e_persisted".to_string(),
server_name: "persisted-server".to_string(),
approval_key_id,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1004,111 +1021,6 @@ async fn remote_control_http_mode_reuses_persisted_enrollment_before_reenrolling
let _ = remote_task.await;
}
#[tokio::test]
async fn remote_control_pairing_start_uses_persisted_enrollment() {
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 codex_home = TempDir::new().expect("temp dir should create");
let state_db = remote_control_state_runtime(&codex_home).await;
let remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let app_server_client_name = "desktop-client";
let persisted_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_persisted".to_string(),
server_id: "srv_e_persisted".to_string(),
server_name: "persisted-server".to_string(),
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
&remote_control_target,
"account_id",
Some(app_server_client_name),
Some(&persisted_enrollment),
)
.await
.expect("persisted enrollment should save");
let auth_manager = remote_control_auth_manager_with_home(&codex_home);
let pairing_task = tokio::spawn({
let remote_control_url = remote_control_url.clone();
let state_db = state_db.clone();
let auth_manager = auth_manager.clone();
async move {
start_remote_control_pairing(
&remote_control_url,
Some(state_db.as_ref()),
&auth_manager,
Some(app_server_client_name),
RemoteControlPairingMode::Session,
)
.await
}
});
let pairing_request = accept_http_request(&listener).await;
assert_eq!(
pairing_request.request_line,
"POST /backend-api/wham/remote/control/server/pairing/start HTTP/1.1"
);
assert_eq!(
pairing_request.headers.get("authorization"),
Some(&"Bearer Access Token".to_string())
);
assert_eq!(
pairing_request
.headers
.get(REMOTE_CONTROL_ACCOUNT_ID_HEADER),
Some(&"account_id".to_string())
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(&pairing_request.body)
.expect("pairing body should deserialize"),
json!({
"server_id": "srv_e_persisted",
"environment_id": "env_persisted",
"mode": "session",
})
);
respond_with_json(
pairing_request.stream,
json!({
"pairing_id": "pair_123",
"pairing_code": "TXNHF-FHZDA",
"expires_at": "2026-04-15T12:34:56Z",
"server_id": "srv_e_persisted",
"environment_id": "env_persisted",
"mode": "session",
"prompt": "Allow this device to control this Codex session?",
}),
)
.await;
let pairing = pairing_task
.await
.expect("pairing task should join")
.expect("pairing should start");
assert_eq!(
pairing,
super::pairing::RemoteControlPairing {
pairing_id: "pair_123".to_string(),
pairing_code: "TXNHF-FHZDA".to_string(),
expires_at: time::OffsetDateTime::parse(
"2026-04-15T12:34:56Z",
&time::format_description::well_known::Rfc3339,
)
.expect("expiry should parse")
.unix_timestamp(),
server_id: "srv_e_persisted".to_string(),
environment_id: "env_persisted".to_string(),
mode: RemoteControlPairingMode::Session,
prompt: "Allow this device to control this Codex session?".to_string(),
}
);
}
#[tokio::test]
async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -1120,11 +1032,17 @@ async fn remote_control_stdio_mode_waits_for_client_name_before_connecting() {
let remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let app_server_client_name = "stdio-client";
let approval_key_id = test_approval_key_id(
&remote_control_target,
"account_id",
Some(app_server_client_name),
);
let persisted_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_persisted".to_string(),
server_id: "srv_e_persisted".to_string(),
server_name: "persisted-server".to_string(),
approval_key_id,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1187,11 +1105,19 @@ async fn remote_control_waits_for_account_id_before_enrolling() {
AuthCredentialsStoreMode::File,
);
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
let remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let approval_key_id = test_approval_key_id(
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
);
let expected_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_ready".to_string(),
server_id: "srv_e_ready".to_string(),
server_name: expected_server_name,
approval_key_id,
};
let (transport_event_tx, _transport_event_rx) =
@@ -1255,17 +1181,24 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
let remote_control_target =
normalize_remote_control_url(&remote_control_url).expect("target should parse");
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
let approval_key_id = test_approval_key_id(
&remote_control_target,
"account_id",
/*app_server_client_name*/ None,
);
let stale_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_stale".to_string(),
server_id: "srv_e_stale".to_string(),
server_name: "stale-server".to_string(),
approval_key_id: approval_key_id.clone(),
};
let refreshed_enrollment = RemoteControlEnrollment {
account_id: "account_id".to_string(),
environment_id: "env_refreshed".to_string(),
server_id: "srv_e_refreshed".to_string(),
server_name: expected_server_name,
approval_key_id,
};
update_persisted_remote_control_enrollment(
Some(state_db.as_ref()),
@@ -1,4 +1,6 @@
use crate::transport::TransportEvent;
use crate::transport::remote_control::RemoteControlApprovalKeyHandle;
use crate::transport::remote_control::approval_key::RemoteControlApprovalKey;
use crate::transport::remote_control::client_tracker::ClientTracker;
use crate::transport::remote_control::client_tracker::REMOTE_CONTROL_IDLE_SWEEP_INTERVAL;
use crate::transport::remote_control::enroll::RemoteControlConnectionAuth;
@@ -126,6 +128,7 @@ pub(crate) struct RemoteControlWebsocket {
server_event_rx: Arc<Mutex<mpsc::Receiver<super::QueuedServerEnvelope>>>,
used_rx: watch::Receiver<usize>,
enabled_rx: watch::Receiver<bool>,
approval_key_handle: RemoteControlApprovalKeyHandle,
}
enum ConnectOutcome {
@@ -134,6 +137,22 @@ enum ConnectOutcome {
Shutdown,
}
pub(super) struct RemoteControlWebsocketControls {
pub(super) enabled_rx: watch::Receiver<bool>,
pub(super) approval_key_handle: RemoteControlApprovalKeyHandle,
}
pub(super) struct ConnectRemoteControlWebsocketArgs<'a> {
pub(super) remote_control_target: &'a RemoteControlTarget,
pub(super) state_db: Option<&'a StateRuntime>,
pub(super) auth_manager: &'a Arc<AuthManager>,
pub(super) auth_recovery: &'a mut UnauthorizedRecovery,
pub(super) enrollment: &'a mut Option<RemoteControlEnrollment>,
pub(super) subscribe_cursor: Option<&'a str>,
pub(super) app_server_client_name: Option<&'a str>,
pub(super) approval_key_handle: &'a RemoteControlApprovalKeyHandle,
}
impl RemoteControlWebsocket {
pub(crate) fn new(
remote_control_url: String,
@@ -142,7 +161,7 @@ impl RemoteControlWebsocket {
auth_manager: Arc<AuthManager>,
transport_event_tx: mpsc::Sender<TransportEvent>,
shutdown_token: CancellationToken,
enabled_rx: watch::Receiver<bool>,
controls: RemoteControlWebsocketControls,
) -> Self {
let shutdown_token = shutdown_token.child_token();
let (server_event_tx, server_event_rx) = mpsc::channel(super::CHANNEL_CAPACITY);
@@ -168,7 +187,8 @@ impl RemoteControlWebsocket {
})),
server_event_rx: Arc::new(Mutex::new(server_event_rx)),
used_rx,
enabled_rx,
enabled_rx: controls.enabled_rx,
approval_key_handle: controls.approval_key_handle,
}
}
@@ -271,15 +291,16 @@ impl RemoteControlWebsocket {
}
return ConnectOutcome::Disabled;
}
connect_result = connect_remote_control_websocket(
&remote_control_target,
self.state_db.as_deref(),
&self.auth_manager,
&mut self.auth_recovery,
&mut self.enrollment,
subscribe_cursor.as_deref(),
connect_result = connect_remote_control_websocket(ConnectRemoteControlWebsocketArgs {
remote_control_target: &remote_control_target,
state_db: self.state_db.as_deref(),
auth_manager: &self.auth_manager,
auth_recovery: &mut self.auth_recovery,
enrollment: &mut self.enrollment,
subscribe_cursor: subscribe_cursor.as_deref(),
app_server_client_name,
) => connect_result,
approval_key_handle: &self.approval_key_handle,
}) => connect_result,
};
match connect_result {
@@ -722,17 +743,21 @@ pub(crate) async fn load_remote_control_auth(
}
pub(super) async fn connect_remote_control_websocket(
remote_control_target: &RemoteControlTarget,
state_db: Option<&StateRuntime>,
auth_manager: &Arc<AuthManager>,
auth_recovery: &mut UnauthorizedRecovery,
enrollment: &mut Option<RemoteControlEnrollment>,
subscribe_cursor: Option<&str>,
app_server_client_name: Option<&str>,
args: ConnectRemoteControlWebsocketArgs<'_>,
) -> io::Result<(
WebSocketStream<MaybeTlsStream<TcpStream>>,
tungstenite::http::Response<()>,
)> {
let ConnectRemoteControlWebsocketArgs {
remote_control_target,
state_db,
auth_manager,
auth_recovery,
enrollment,
subscribe_cursor,
app_server_client_name,
approval_key_handle,
} = args;
ensure_rustls_crypto_provider();
let auth = load_remote_control_auth(auth_manager).await?;
@@ -759,14 +784,62 @@ pub(super) async fn connect_remote_control_websocket(
.await;
}
if enrollment.is_none() {
let approval_key = RemoteControlApprovalKey::load_or_generate(
remote_control_target,
&auth.account_id,
app_server_client_name,
)?;
let approval_key_id = approval_key.key_id().to_string();
approval_key_handle.set(approval_key.clone())?;
let should_refresh_enrollment = enrollment
.as_ref()
.is_none_or(|enrollment| enrollment.approval_key_id != approval_key_id);
if should_refresh_enrollment {
info!(
"creating new remote control enrollment: websocket_url={}, enroll_url={}, account_id={}",
remote_control_target.websocket_url, remote_control_target.enroll_url, auth.account_id
"refreshing remote control enrollment: websocket_url={}, enroll_url={}, account_id={}, existing_server_id={:?}",
remote_control_target.websocket_url,
remote_control_target.enroll_url,
auth.account_id,
enrollment
.as_ref()
.map(|enrollment| enrollment.server_id.as_str())
);
let new_enrollment = match enroll_remote_control_server(remote_control_target, &auth).await
let new_enrollment = match enroll_remote_control_server(
remote_control_target,
&auth,
&approval_key,
enrollment.as_ref(),
)
.await
{
Ok(new_enrollment) => new_enrollment,
Err(err) if err.kind() == ErrorKind::NotFound && enrollment.is_some() => {
info!(
"remote control enrollment no longer exists on backend; re-enrolling with a new server: websocket_url={}, account_id={}",
remote_control_target.websocket_url, auth.account_id
);
if let Err(clear_err) = update_persisted_remote_control_enrollment(
state_db,
remote_control_target,
&auth.account_id,
app_server_client_name,
/*enrollment*/ None,
)
.await
{
warn!(
"failed to clear stale remote control enrollment in sqlite state db: {clear_err}"
);
}
*enrollment = None;
enroll_remote_control_server(
remote_control_target,
&auth,
&approval_key,
/*existing_enrollment*/ None,
)
.await?
}
Err(err)
if err.kind() == ErrorKind::PermissionDenied
&& recover_remote_control_auth(auth_recovery).await =>
@@ -789,7 +862,7 @@ pub(super) async fn connect_remote_control_websocket(
warn!("failed to persist remote control enrollment in sqlite state db: {err}");
}
info!(
"created new remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}",
"refreshed remote control enrollment: websocket_url={}, account_id={}, server_id={}, environment_id={}",
remote_control_target.websocket_url,
new_enrollment.account_id,
new_enrollment.server_id,
@@ -1030,17 +1103,20 @@ mod tests {
environment_id: "env_test".to_string(),
server_id: "srv_e_test".to_string(),
server_name: "test-server".to_string(),
approval_key_id: test_approval_key_id(&remote_control_target),
});
let approval_key_handle = RemoteControlApprovalKeyHandle::empty();
let err = match connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
&mut enrollment,
/*subscribe_cursor*/ None,
/*app_server_client_name*/ None,
)
let err = match connect_remote_control_websocket(ConnectRemoteControlWebsocketArgs {
remote_control_target: &remote_control_target,
state_db: Some(state_db.as_ref()),
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
enrollment: &mut enrollment,
subscribe_cursor: None,
app_server_client_name: None,
approval_key_handle: &approval_key_handle,
})
.await
{
Ok(_) => panic!("http error response should fail the websocket connect"),
@@ -1078,6 +1154,7 @@ mod tests {
environment_id: "env_test".to_string(),
server_id: "srv_e_test".to_string(),
server_name: "test-server".to_string(),
approval_key_id: test_approval_key_id(&remote_control_target),
});
save_auth(
codex_home.path(),
@@ -1094,16 +1171,18 @@ mod tests {
);
respond_with_status_and_headers(stream, "401 Unauthorized", &[], "unauthorized").await;
});
let approval_key_handle = RemoteControlApprovalKeyHandle::empty();
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
&mut enrollment,
/*subscribe_cursor*/ None,
/*app_server_client_name*/ None,
)
let err = connect_remote_control_websocket(ConnectRemoteControlWebsocketArgs {
remote_control_target: &remote_control_target,
state_db: Some(state_db.as_ref()),
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
enrollment: &mut enrollment,
subscribe_cursor: None,
app_server_client_name: None,
approval_key_handle: &approval_key_handle,
})
.await
.expect_err("unauthorized response should fail the websocket connect");
@@ -1162,15 +1241,18 @@ mod tests {
)
.expect("fresh auth should save");
let err = connect_remote_control_websocket(
&remote_control_target,
Some(state_db.as_ref()),
&auth_manager,
&mut auth_recovery,
&mut enrollment,
/*subscribe_cursor*/ None,
/*app_server_client_name*/ None,
)
let approval_key_handle = RemoteControlApprovalKeyHandle::empty();
let err = connect_remote_control_websocket(ConnectRemoteControlWebsocketArgs {
remote_control_target: &remote_control_target,
state_db: Some(state_db.as_ref()),
auth_manager: &auth_manager,
auth_recovery: &mut auth_recovery,
enrollment: &mut enrollment,
subscribe_cursor: None,
app_server_client_name: None,
approval_key_handle: &approval_key_handle,
})
.await
.expect_err("unauthorized enrollment should fail the websocket connect");
@@ -1216,7 +1298,10 @@ mod tests {
remote_control_auth_manager(),
transport_event_tx,
shutdown_token,
enabled_rx,
RemoteControlWebsocketControls {
enabled_rx,
approval_key_handle: RemoteControlApprovalKeyHandle::empty(),
},
)
.run(/*app_server_client_name_rx*/ None)
.await
@@ -1524,6 +1609,17 @@ mod tests {
)
}
fn test_approval_key_id(remote_control_target: &RemoteControlTarget) -> String {
RemoteControlApprovalKey::load_or_generate(
remote_control_target,
"account_id",
/*app_server_client_name*/ None,
)
.expect("test approval key should load")
.key_id()
.to_string()
}
async fn connected_websocket_pair() -> (
WebSocketStream<MaybeTlsStream<TcpStream>>,
WebSocketStream<TcpStream>,
+9
View File
@@ -1483,6 +1483,15 @@ async fn handle_server_request(
)
.await
}
ServerRequest::RemoteControlApprovalConfirm { request_id, .. } => {
reject_server_request(
client,
request_id,
&method,
"remote control approval is not supported in exec mode".to_string(),
)
.await
}
ServerRequest::ApplyPatchApproval { request_id, params } => {
reject_server_request(
client,
@@ -0,0 +1,2 @@
ALTER TABLE remote_control_enrollments
ADD COLUMN approval_key_id TEXT NOT NULL DEFAULT '';
+13 -2
View File
@@ -11,6 +11,7 @@ pub struct RemoteControlEnrollmentRecord {
pub server_id: String,
pub environment_id: String,
pub server_name: String,
pub approval_key_id: String,
}
fn remote_control_app_server_client_name_key(app_server_client_name: Option<&str>) -> &str {
@@ -34,7 +35,7 @@ impl StateRuntime {
) -> anyhow::Result<Option<RemoteControlEnrollmentRecord>> {
let row = sqlx::query(
r#"
SELECT websocket_url, account_id, app_server_client_name, server_id, environment_id, server_name
SELECT websocket_url, account_id, app_server_client_name, server_id, environment_id, server_name, approval_key_id
FROM remote_control_enrollments
WHERE websocket_url = ? AND account_id = ? AND app_server_client_name = ?
"#,
@@ -56,6 +57,7 @@ WHERE websocket_url = ? AND account_id = ? AND app_server_client_name = ?
server_id: row.try_get("server_id")?,
environment_id: row.try_get("environment_id")?,
server_name: row.try_get("server_name")?,
approval_key_id: row.try_get("approval_key_id")?,
})
})
.transpose()
@@ -74,12 +76,14 @@ INSERT INTO remote_control_enrollments (
server_id,
environment_id,
server_name,
approval_key_id,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(websocket_url, account_id, app_server_client_name) DO UPDATE SET
server_id = excluded.server_id,
environment_id = excluded.environment_id,
server_name = excluded.server_name,
approval_key_id = excluded.approval_key_id,
updated_at = excluded.updated_at
"#,
)
@@ -91,6 +95,7 @@ ON CONFLICT(websocket_url, account_id, app_server_client_name) DO UPDATE SET
.bind(&enrollment.server_id)
.bind(&enrollment.environment_id)
.bind(&enrollment.server_name)
.bind(&enrollment.approval_key_id)
.bind(Utc::now().timestamp())
.execute(self.pool.as_ref())
.await?;
@@ -143,6 +148,7 @@ mod tests {
server_id: "srv_e_first".to_string(),
environment_id: "env_first".to_string(),
server_name: "first-server".to_string(),
approval_key_id: "approval-key-a".to_string(),
})
.await
.expect("insert first enrollment");
@@ -155,6 +161,7 @@ mod tests {
server_id: "srv_e_second".to_string(),
environment_id: "env_second".to_string(),
server_name: "second-server".to_string(),
approval_key_id: "approval-key-b".to_string(),
})
.await
.expect("insert second enrollment");
@@ -176,6 +183,7 @@ mod tests {
server_id: "srv_e_first".to_string(),
environment_id: "env_first".to_string(),
server_name: "first-server".to_string(),
approval_key_id: "approval-key-a".to_string(),
})
);
assert_eq!(
@@ -220,6 +228,7 @@ mod tests {
server_id: "srv_e_first".to_string(),
environment_id: "env_first".to_string(),
server_name: "first-server".to_string(),
approval_key_id: "approval-key-a".to_string(),
})
.await
.expect("insert first enrollment");
@@ -232,6 +241,7 @@ mod tests {
server_id: "srv_e_second".to_string(),
environment_id: "env_second".to_string(),
server_name: "second-server".to_string(),
approval_key_id: "approval-key-b".to_string(),
})
.await
.expect("insert second enrollment");
@@ -275,6 +285,7 @@ mod tests {
server_id: "srv_e_second".to_string(),
environment_id: "env_second".to_string(),
server_name: "second-server".to_string(),
approval_key_id: "approval-key-b".to_string(),
})
);
-35
View File
@@ -90,9 +90,6 @@ use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RemoteControlPairingMode;
use codex_app_server_protocol::RemoteControlPairingStartParams;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
@@ -2053,17 +2050,6 @@ impl App {
});
}
fn start_remote_control_pairing(&mut self, app_server: &AppServerSession) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_remote_control_pairing(request_handle)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::RemoteControlPairingLoaded { result });
});
}
/// Spawns a background task to fetch account rate limits and deliver the
/// result as a `RateLimitsLoaded` event.
///
@@ -4735,12 +4721,6 @@ impl App {
AppEvent::McpInventoryLoaded { result } => {
self.handle_mcp_inventory_result(result);
}
AppEvent::StartRemoteControlPairing => {
self.start_remote_control_pairing(app_server);
}
AppEvent::RemoteControlPairingLoaded { result } => {
self.chat_widget.on_remote_control_pairing_result(result);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
@@ -6421,21 +6401,6 @@ async fn fetch_account_rate_limits(
Ok(app_server_rate_limit_snapshots_to_core(response))
}
async fn fetch_remote_control_pairing(
request_handle: AppServerRequestHandle,
) -> Result<RemoteControlPairingStartResponse> {
let request_id = RequestId::String(format!("remote-control-pairing-{}", Uuid::new_v4()));
request_handle
.request_typed(ClientRequest::RemoteControlPairingStart {
request_id,
params: RemoteControlPairingStartParams {
mode: RemoteControlPairingMode::Session,
},
})
.await
.wrap_err("remoteControl/pairing/start failed in TUI")
}
async fn fetch_plugins_list(
request_handle: AppServerRequestHandle,
cwd: PathBuf,
@@ -306,6 +306,7 @@ fn server_request_thread_id(request: &ServerRequest) -> Option<ThreadId> {
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::RemoteControlApprovalConfirm { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => None,
}
@@ -116,6 +116,13 @@ impl PendingAppServerRequests {
})
}
ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
ServerRequest::RemoteControlApprovalConfirm { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "Remote control approval requests are not available in TUI yet."
.to_string(),
})
}
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
@@ -333,6 +340,7 @@ impl PendingAppServerRequests {
.any(|pending_request_id| pending_request_id == request_id),
ServerRequest::DynamicToolCall { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::RemoteControlApprovalConfirm { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => true,
}
-9
View File
@@ -16,7 +16,6 @@ use codex_app_server_protocol::PluginListResponse;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
@@ -296,14 +295,6 @@ pub(crate) enum AppEvent {
result: Result<Vec<McpServerStatus>, String>,
},
/// Start a session-scoped remote-control pairing challenge.
StartRemoteControlPairing,
/// Result of starting a remote-control pairing challenge.
RemoteControlPairingLoaded {
result: Result<RemoteControlPairingStartResponse, String>,
},
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
+5 -38
View File
@@ -94,8 +94,6 @@ use codex_app_server_protocol::ItemCompletedNotification;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::McpServerStartupState;
use codex_app_server_protocol::McpServerStatusUpdatedNotification;
use codex_app_server_protocol::RemoteControlPairingMode;
use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadItem;
@@ -458,12 +456,6 @@ fn is_unified_exec_source(source: ExecCommandSource) -> bool {
)
}
fn format_remote_control_pairing_expiry(expires_at: i64) -> String {
chrono::DateTime::from_timestamp(expires_at, 0)
.map(|expires_at| expires_at.format("%Y-%m-%d %H:%M:%S UTC").to_string())
.unwrap_or_else(|| expires_at.to_string())
}
fn is_standard_tool_call(parsed_cmd: &[ParsedCommand]) -> bool {
!parsed_cmd.is_empty()
&& parsed_cmd
@@ -6085,6 +6077,7 @@ impl ChatWidget {
}
ServerRequest::DynamicToolCall { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::RemoteControlApprovalConfirm { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => {
if replay_kind.is_none() {
@@ -9800,37 +9793,11 @@ impl ChatWidget {
self.request_redraw();
}
pub(crate) fn start_remote_control_pairing(&mut self) {
self.add_info_message(
"Starting remote control pairing...".to_string(),
Some("Enter the code shown here on your controller device.".to_string()),
);
self.app_event_tx.send(AppEvent::StartRemoteControlPairing);
}
pub(crate) fn on_remote_control_pairing_result(
&mut self,
result: Result<RemoteControlPairingStartResponse, String>,
) {
match result {
Ok(pairing) => self.add_remote_control_pairing(pairing),
Err(err) => self.add_error_message(format!("Remote control pairing failed: {err}")),
}
}
fn add_remote_control_pairing(&mut self, pairing: RemoteControlPairingStartResponse) {
let mode_label = match pairing.mode {
RemoteControlPairingMode::Session => "This Codex session",
RemoteControlPairingMode::Server => "This Codex server",
};
let expires_at = format_remote_control_pairing_expiry(pairing.expires_at);
pub(crate) fn show_remote_control_instructions(&mut self) {
self.add_plain_history_lines(vec![
"Remote control pairing".bold().into(),
pairing.prompt.into(),
vec!["Code: ".dim(), pairing.pairing_code.cyan().bold()].into(),
vec!["Scope: ".dim(), mode_label.into()].into(),
vec!["Expires: ".dim(), expires_at.into()].into(),
"Remote control".bold().into(),
"Open Codex on another device, choose this Codex server, then approve the prompt here."
.into(),
]);
}
@@ -162,7 +162,7 @@ impl ChatWidget {
self.open_collaboration_modes_popup();
}
SlashCommand::RemoteControl => {
self.start_remote_control_pairing();
self.show_remote_control_instructions();
}
SlashCommand::Agent | SlashCommand::MultiAgents => {
self.app_event_tx.send(AppEvent::OpenAgentPicker);
@@ -1,9 +0,0 @@
---
source: tui/src/chatwidget/tests/slash_commands.rs
expression: rendered
---
Remote control pairing
Allow this device to control this Codex session?
Code: TXNHF-FHZDA
Scope: This Codex session
Expires: 2026-04-15 11:54:56 UTC
-2
View File
@@ -84,8 +84,6 @@ pub(super) use codex_app_server_protocol::PluginReadResponse;
pub(super) use codex_app_server_protocol::PluginSource;
pub(super) use codex_app_server_protocol::PluginSummary;
pub(super) use codex_app_server_protocol::ReasoningSummaryTextDeltaNotification;
pub(super) use codex_app_server_protocol::RemoteControlPairingMode;
pub(super) use codex_app_server_protocol::RemoteControlPairingStartResponse;
pub(super) use codex_app_server_protocol::ServerNotification;
pub(super) use codex_app_server_protocol::SkillSummary;
pub(super) use codex_app_server_protocol::ThreadClosedNotification;
@@ -567,59 +567,30 @@ async fn slash_mcp_requests_inventory_via_app_server() {
}
#[tokio::test]
async fn slash_remote_control_requests_pairing_via_app_server() {
async fn slash_remote_control_shows_controller_instructions() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.dispatch_command(SlashCommand::RemoteControl);
let mut cells = Vec::new();
let mut saw_start_pairing = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(cell) => {
cells.push(cell.display_lines(/*width*/ 80));
}
AppEvent::StartRemoteControlPairing => {
saw_start_pairing = true;
}
other => panic!("unexpected app event: {other:?}"),
}
}
assert_eq!(cells.len(), 1, "expected pairing startup message");
assert_eq!(cells.len(), 1, "expected remote control instructions");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Starting remote control pairing"),
"expected pairing startup message, got {rendered:?}"
rendered.contains("Open Codex on another device"),
"expected remote-control instructions, got {rendered:?}"
);
assert!(saw_start_pairing, "expected remote-control pairing event");
assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");
}
#[tokio::test]
async fn remote_control_pairing_result_renders_code_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_remote_control_pairing_result(Ok(RemoteControlPairingStartResponse {
pairing_id: "pair_123".to_string(),
pairing_code: "TXNHF-FHZDA".to_string(),
expires_at: 1_776_254_096,
server_id: "srv_local".to_string(),
environment_id: "env_local".to_string(),
mode: RemoteControlPairingMode::Session,
prompt: "Allow this device to control this Codex session?".to_string(),
}));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one pairing result message");
let rendered = lines_to_single_string(&cells[0]);
assert_chatwidget_snapshot!("remote_control_pairing_result", rendered);
assert!(
rendered.contains("TXNHF-FHZDA"),
"expected pairing code, got {rendered:?}"
);
}
#[tokio::test]
async fn slash_memories_opens_memory_menu() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+3 -1
View File
@@ -103,7 +103,9 @@ impl SlashCommand {
SlashCommand::Settings => "configure realtime microphone/speaker",
SlashCommand::Plan => "switch to Plan mode",
SlashCommand::Collab => "change collaboration mode (experimental)",
SlashCommand::RemoteControl => "pair another device to control this Codex session",
SlashCommand::RemoteControl => {
"show how to control this Codex server from another device"
}
SlashCommand::Agent | SlashCommand::MultiAgents => "switch the active agent thread",
SlashCommand::Approvals => "choose what Codex is allowed to do",
SlashCommand::Permissions => "choose what Codex is allowed to do",