From 3715bf4100cd42773cb978d8ec0f632e97883cf7 Mon Sep 17 00:00:00 2001 From: riley-oai Date: Thu, 10 Sep 2026 18:18:31 +0000 Subject: [PATCH] Enable user verification for local Codex Desktop sessions (#44613) ## What changed Allow app-server to advertise `openai/elicitation.userVerification` and route verification requests to local stdio clients named `Codex Desktop` when `experimentalApi` is enabled and the device supports verification. Preserve support for the in-process `codex-tui` client. Document the experimental verification API, local credential lifecycle, transport restrictions, and GUI requirements for cancellation and late proofs. ## Testing Extend activation tests to cover desktop capability advertisement and request eligibility, including rejection for other transports, unrecognized client names, missing opt-in, and unsupported devices. GitOrigin-RevId: ba4fd4b83b9c24541df3a490396d6c715e7b7433 --- codex-rs/app-server/README.md | 61 +++++++++++++++++++ .../initialize_processor.rs | 11 ++-- .../src/user_verification_activation_tests.rs | 26 ++++++++ 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index d64c2303f9..4f670720cb 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -44,6 +44,67 @@ after a client tries to archive or delete it. After the owner releases the worker, its saved conversation can be archived or deleted normally. Ordinary client-controlled threads keep their existing behavior. +## User verification (experimental) + +Codex app-server advertises `openai/elicitation.userVerification` to the +host-owned plugin service for bundled, in-process TUI sessions (`codex-tui`) and +local stdio desktop sessions (`Codex Desktop`) on devices with supported biometric +hardware and the `experimentalApi` opt-in. This is an app-server decision, +independent of whether a key exists; TUI/Desktop/mobile do not advertise this MCP +capability. Mobile integration requires a separate rollout. Other clients and +network connections do not receive this mode, even with a recognized client name. +Before sending verification requests to desktop sessions, deploy a GUI that +handles the typed verification request, cancellation, and late proofs. The general +`experimentalApi` opt-in does not identify a compatible GUI version. + +Local UI clients use five methods. They require the existing +`experimentalApi` opt-in. The local provider reports +`unavailable/providerUnavailable` on unsupported platforms or without the required +ChatGPT account identity. + +| Method | Params | Result | +| --- | --- | --- | +| `userVerification/status` | `{}` | `{credentialId, unavailableReason, unavailableMessage}` | +| `userVerification/enroll` | `{}` | `{credentialId}` | +| `userVerification/delete` | `{}` | `{}` | +| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}}` | +| `userVerification/cancel` | `{requestId}` | `{}` | + +Status reads local readiness without prompting or contacting a backend. A null +`unavailableReason` means local checks passed, not that registration is valid. +Unsupported platforms and missing account identity are reported in the status +response's `unavailableReason` field. +The initial enrollment creates or reuses the local key only. Backend +registration and revocation are integration TODOs; local success is not server +enrollment. Deletion currently removes that local key synchronously. +Enrollment and deletion coordinate credential lifecycle; callers do not issue +separate generate or rotate commands. Identity comes from the authenticated +account; this API exposes no caller-selected scope. + +Verify signs 1–4096 decoded challenge bytes using P-256 ECDSA with SHA-256. The +challenge and DER signature use unpadded base64url. Title is 1–256 UTF-8 bytes; +description is at most 4096 bytes. The UI obtains approval for that display +context before calling. Verify does not require a pending elicitation; a UI with +its own authenticator can return proof directly in elicitation response content. +The calling flow owns pending-request checks and discards late proofs. +Native enroll, delete, and verify accept local stdio and in-process connections. +WebSocket and remote-control peers must use their own device authenticator; +status remains available for local readiness. Dropping an embedded RPC, disconnecting, +or changing authentication cancels its native operation. Responses recheck the +captured identity after waiting for outbound queue capacity. +Canceling or resolving an elicitation does not itself stop a separate +`userVerification/verify` RPC. The GUI must use `userVerification/cancel` to +cancel that RPC and discard late proofs when an approval is canceled or resolved. +See [User verification cancellation](#user-verification-cancellation-experimental) +for request ID and acknowledgment semantics. +Only one native worker runs per app-server. If an OS call remains active after +cancellation or timeout, subsequent local operations return `failed/providerError` +until that worker exits. + +Failures use the normal JSON-RPC error envelope with closed `{type, reason}` data: +`invalidRequest`, `unavailable`, `cancelled`, or `failed`. UI clients branch on +these values rather than message text. Native diagnostic payloads stay private. + # Amazon Bedrock authentication If `model_providers.amazon-bedrock.aws.credential_export` is configured, Bedrock setup and diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index d6c77f12eb..0702c4418d 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -14,6 +14,7 @@ use codex_protocol::mcp::OPENAI_ELICITATION_EXTENSION_ID; use super::*; use crate::message_processor::ConnectionSessionState; use crate::message_processor::InitializedConnectionSessionState; +use crate::transport::ConnectionOrigin; const NON_ORIGINATING_CLIENT_NAMES: &[&str] = &["codex_app_server_daemon", "codex-backend"]; @@ -95,14 +96,14 @@ impl InitializeRequestProcessor { "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." ))); } - // The bundled TUI shares this build and implements the typed verification UI. - // Independently deployed UIs need their own rollout before receiving this mode. + // Activate only the embedded TUI and local desktop host. Client-supplied + // extensions cannot opt other hosts into verification. let user_verification_enabled = experimental_api_enabled && matches!( - session.origin, - crate::transport::ConnectionOrigin::InProcess + (session.origin, name.as_str()), + (ConnectionOrigin::InProcess, "codex-tui") + | (ConnectionOrigin::Stdio, "Codex Desktop") ) - && name == "codex-tui" && tokio::task::spawn_blocking(self.user_verification.device_supported) .await .unwrap_or(false); diff --git a/codex-rs/app-server/src/user_verification_activation_tests.rs b/codex-rs/app-server/src/user_verification_activation_tests.rs index 969d4df3f0..8de777c51b 100644 --- a/codex-rs/app-server/src/user_verification_activation_tests.rs +++ b/codex-rs/app-server/src/user_verification_activation_tests.rs @@ -26,6 +26,32 @@ async fn user_verification_initialize_owns_advertisement_and_eligibility() -> Re (ConnectionOrigin::InProcess, "other-ui", true, true, false), (ConnectionOrigin::InProcess, "codex-tui", false, true, false), (ConnectionOrigin::InProcess, "codex-tui", true, false, false), + (ConnectionOrigin::Stdio, "Codex Desktop", true, true, true), + ( + ConnectionOrigin::InProcess, + "Codex Desktop", + true, + true, + false, + ), + ( + ConnectionOrigin::WebSocket, + "Codex Desktop", + true, + true, + false, + ), + ( + ConnectionOrigin::RemoteControl, + "Codex Desktop", + true, + true, + false, + ), + (ConnectionOrigin::Stdio, "other-ui", true, true, false), + (ConnectionOrigin::Stdio, "codex_desktop", true, true, false), + (ConnectionOrigin::Stdio, "Codex Desktop", false, true, false), + (ConnectionOrigin::Stdio, "Codex Desktop", true, false, false), ] { let probe: fn() -> bool = if supported { || true } else { || false }; let mut h = Harness::new(origin, probe).await?;