mirror of
https://github.com/openai/codex.git
synced 2026-10-25 18:28:38 +00:00
feat: update remote control harness
Co-authored-by: Codex noreply@openai.com
This commit is contained in:
co-authored by
Codex noreply@openai.com
parent
d22974712f
commit
e80542dd98
Generated
+3
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
+13
-9
@@ -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": [
|
||||
{
|
||||
|
||||
+59
-30
@@ -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, };
|
||||
|
||||
+1
-1
@@ -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, };
|
||||
+6
@@ -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";
|
||||
+1
-1
@@ -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, };
|
||||
+6
@@ -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)]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 { .. }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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 '';
|
||||
@@ -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(),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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(¶ms.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,
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
-9
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user