diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a499a511ca..82f7f8e03f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -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", diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 893a4484d1..459a8012af 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -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" }, { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlPairingStartResponse.json b/codex-rs/app-server-protocol/schema/json/RemoteControlApprovalConfirmParams.json similarity index 78% rename from codex-rs/app-server-protocol/schema/json/v2/RemoteControlPairingStartResponse.json rename to codex-rs/app-server-protocol/schema/json/RemoteControlApprovalConfirmParams.json index 6651e694a7..e9f89d9cdd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlPairingStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/RemoteControlApprovalConfirmParams.json @@ -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" } \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/RemoteControlApprovalConfirmResponse.json b/codex-rs/app-server-protocol/schema/json/RemoteControlApprovalConfirmResponse.json new file mode 100644 index 0000000000..e118438269 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/RemoteControlApprovalConfirmResponse.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index b31e69f203..6054e13909 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -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": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index f5b8ac8c88..a608754fb3 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -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": [ { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index bea2e58a5f..8f71b17e5b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -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": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlApprovalRequestParams.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlApprovalRequestParams.json new file mode 100644 index 0000000000..1e0bf60427 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlApprovalRequestParams.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlApprovalRequestResponse.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlApprovalRequestResponse.json new file mode 100644 index 0000000000..0da8221ff1 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlApprovalRequestResponse.json @@ -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" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlPairingStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/RemoteControlPairingStartParams.json deleted file mode 100644 index 66199c481c..0000000000 --- a/codex-rs/app-server-protocol/schema/json/v2/RemoteControlPairingStartParams.json +++ /dev/null @@ -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" -} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts index 795dc9d9d2..cae9dd8618 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ClientRequest.ts @@ -42,7 +42,7 @@ import type { PluginInstallParams } from "./v2/PluginInstallParams"; import type { PluginListParams } from "./v2/PluginListParams"; import type { PluginReadParams } from "./v2/PluginReadParams"; import type { PluginUninstallParams } from "./v2/PluginUninstallParams"; -import type { RemoteControlPairingStartParams } from "./v2/RemoteControlPairingStartParams"; +import type { RemoteControlApprovalRequestParams } from "./v2/RemoteControlApprovalRequestParams"; import type { ReviewStartParams } from "./v2/ReviewStartParams"; import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams"; import type { SkillsListParams } from "./v2/SkillsListParams"; @@ -69,4 +69,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta /** * Request from the client to the server. */ -export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "remoteControl/pairing/start", id: RequestId, params: RemoteControlPairingStartParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; +export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/shellCommand", id: RequestId, params: ThreadShellCommandParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "thread/inject_items", id: RequestId, params: ThreadInjectItemsParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "marketplace/add", id: RequestId, params: MarketplaceAddParams, } | { "method": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "fs/watch", id: RequestId, params: FsWatchParams, } | { "method": "fs/unwatch", id: RequestId, params: FsUnwatchParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "experimentalFeature/enablement/set", id: RequestId, params: ExperimentalFeatureEnablementSetParams, } | { "method": "remoteControl/approval/request", id: RequestId, params: RemoteControlApprovalRequestParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "mcpServer/resource/read", id: RequestId, params: McpResourceReadParams, } | { "method": "mcpServer/tool/call", id: RequestId, params: McpServerToolCallParams, } | { "method": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 13d04b0be7..6c600f7596 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlPairingStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalConfirmParams.ts similarity index 50% rename from codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlPairingStartParams.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalConfirmParams.ts index 18b91d5254..053db158ff 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlPairingStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalConfirmParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalConfirmResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalConfirmResponse.ts new file mode 100644 index 0000000000..da27695c23 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalConfirmResponse.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalDecision.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalDecision.ts new file mode 100644 index 0000000000..26f1a9bc25 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalDecision.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlPairingStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalRequestParams.ts similarity index 50% rename from codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlPairingStartResponse.ts rename to codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalRequestParams.ts index 0f69cd8b48..993ea6473f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlPairingStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalRequestParams.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalRequestResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalRequestResponse.ts new file mode 100644 index 0000000000..61da823f31 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/RemoteControlApprovalRequestResponse.ts @@ -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, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 0f7d702884..3b15f485a5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 2bcfe99199..63fa59d6cf 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -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 { diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index b4b893f786..7b806f9672 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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, + pub signature_algorithm: Option, + pub approval_key_id: Option, +} + +#[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)] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 097abdb713..ab7dc63b40 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -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 } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index b0d392bc8f..5cfe052b50 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -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 > 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. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 821c9ed829..49218a983e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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 { .. } diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index ba946c67b6..4363315c82 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -120,6 +120,7 @@ enum OutboundControlEvent { initialized: Arc, experimental_api_enabled: Arc, opted_out_notification_methods: Arc>>, + 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( diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f2bd0e7f28..31678195ce 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -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::(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, } } diff --git a/codex-rs/app-server/src/transport/mod.rs b/codex-rs/app-server/src/transport/mod.rs index f0e53aa4b9..75a4971905 100644 --- a/codex-rs/app-server/src/transport/mod.rs +++ b/codex-rs/app-server/src/transport/mod.rs @@ -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; diff --git a/codex-rs/app-server/src/transport/remote_control/approval_key.rs b/codex-rs/app-server/src/transport/remote_control/approval_key.rs new file mode 100644 index 0000000000..bbeaa2df33 --- /dev/null +++ b/codex-rs/app-server/src/transport/remote_control/approval_key.rs @@ -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 { + #[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, + ) -> io::Result { + 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 { + 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()); + } +} diff --git a/codex-rs/app-server/src/transport/remote_control/enroll.rs b/codex-rs/app-server/src/transport/remote_control/enroll.rs index dbe18c8355..d28924277a 100644 --- a/codex-rs/app-server/src/transport/remote_control/enroll.rs +++ b/codex-rs/app-server/src/transport/remote_control/enroll.rs @@ -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 { 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"); diff --git a/codex-rs/app-server/src/transport/remote_control/mod.rs b/codex-rs/app-server/src/transport/remote_control/mod.rs index aafca13b99..9cdf5a50c5 100644 --- a/codex-rs/app-server/src/transport/remote_control/mod.rs +++ b/codex-rs/app-server/src/transport/remote_control/mod.rs @@ -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>, - remote_control_url: String, - state_db: Option>, - auth_manager: Arc, + approval_key: Arc>>, } 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 { - 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 { + 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>>, +} + +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, }, )) } diff --git a/codex-rs/app-server/src/transport/remote_control/pairing.rs b/codex-rs/app-server/src/transport/remote_control/pairing.rs deleted file mode 100644 index ece75496b5..0000000000 --- a/codex-rs/app-server/src/transport/remote_control/pairing.rs +++ /dev/null @@ -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, - app_server_client_name: Option<&str>, - mode: RemoteControlPairingMode, -) -> io::Result { - 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 { - 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::(&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, - }) -} diff --git a/codex-rs/app-server/src/transport/remote_control/protocol.rs b/codex-rs/app-server/src/transport/remote_control/protocol.rs index d29a1c47b9..20a4a01674 100644 --- a/codex-rs/app-server/src/transport/remote_control/protocol.rs +++ b/codex-rs/app-server/src/transport/remote_control/protocol.rs @@ -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, + #[serde(skip_serializing_if = "Option::is_none")] + pub(super) environment_id: Option, } #[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(), } ); } diff --git a/codex-rs/app-server/src/transport/remote_control/tests.rs b/codex-rs/app-server/src/transport/remote_control/tests.rs index 3b9ecfcf3c..004cd8536d 100644 --- a/codex-rs/app-server/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server/src/transport/remote_control/tests.rs @@ -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::(&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::(&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::(&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()), diff --git a/codex-rs/app-server/src/transport/remote_control/websocket.rs b/codex-rs/app-server/src/transport/remote_control/websocket.rs index d813be1f6a..1e7ebd19c2 100644 --- a/codex-rs/app-server/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server/src/transport/remote_control/websocket.rs @@ -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>>, used_rx: watch::Receiver, enabled_rx: watch::Receiver, + approval_key_handle: RemoteControlApprovalKeyHandle, } enum ConnectOutcome { @@ -134,6 +137,22 @@ enum ConnectOutcome { Shutdown, } +pub(super) struct RemoteControlWebsocketControls { + pub(super) enabled_rx: watch::Receiver, + 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, + pub(super) auth_recovery: &'a mut UnauthorizedRecovery, + pub(super) enrollment: &'a mut Option, + 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, transport_event_tx: mpsc::Sender, shutdown_token: CancellationToken, - enabled_rx: watch::Receiver, + 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, - auth_recovery: &mut UnauthorizedRecovery, - enrollment: &mut Option, - subscribe_cursor: Option<&str>, - app_server_client_name: Option<&str>, + args: ConnectRemoteControlWebsocketArgs<'_>, ) -> io::Result<( WebSocketStream>, 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>, WebSocketStream, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 123038ffaa..8942ff321b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -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, diff --git a/codex-rs/state/migrations/0026_remote_control_approval_key_id.sql b/codex-rs/state/migrations/0026_remote_control_approval_key_id.sql new file mode 100644 index 0000000000..bc87860d45 --- /dev/null +++ b/codex-rs/state/migrations/0026_remote_control_approval_key_id.sql @@ -0,0 +1,2 @@ +ALTER TABLE remote_control_enrollments +ADD COLUMN approval_key_id TEXT NOT NULL DEFAULT ''; diff --git a/codex-rs/state/src/runtime/remote_control.rs b/codex-rs/state/src/runtime/remote_control.rs index fa0b1823f8..0a6c2f2887 100644 --- a/codex-rs/state/src/runtime/remote_control.rs +++ b/codex-rs/state/src/runtime/remote_control.rs @@ -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> { 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(), }) ); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 769cb9aa2f..1db3dbc7ed 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -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 { - 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, diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index f2490ca6a2..12655e984b 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -306,6 +306,7 @@ fn server_request_thread_id(request: &ServerRequest) -> Option { ThreadId::from_string(¶ms.thread_id).ok() } ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::RemoteControlApprovalConfirm { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => None, } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index e2483eb25d..2f18c86fad 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -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, } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 2b5699b8d0..a3b31aa988 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -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, String>, }, - /// Start a session-scoped remote-control pairing challenge. - StartRemoteControlPairing, - - /// Result of starting a remote-control pairing challenge. - RemoteControlPairingLoaded { - result: Result, - }, - InsertHistoryCell(Box), /// Apply rollback semantics to local transcript cells. diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 1c412fb3a4..927e5e7b7c 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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, - ) { - 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(), ]); } diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index e92728d72a..65690aefca 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -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); diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__remote_control_pairing_result.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__remote_control_pairing_result.snap deleted file mode 100644 index bcd9ac4746..0000000000 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__remote_control_pairing_result.snap +++ /dev/null @@ -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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 8213097975..e92a809e80 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a84db4b9c1..cdfbdc9806 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -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; diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 189e0f8251..f2750873c2 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -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",