diff --git a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst index d0b24c940d..9eba750ee0 100644 Binary files a/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst and b/codex-rs/app-server-protocol/schema/precomputed/app-server-exports-experimental.json.zst differ diff --git a/codex-rs/app-server-protocol/src/export.rs b/codex-rs/app-server-protocol/src/export.rs index da33b070c9..d68bd1e2c5 100644 --- a/codex-rs/app-server-protocol/src/export.rs +++ b/codex-rs/app-server-protocol/src/export.rs @@ -55,6 +55,13 @@ const EXPERIMENTAL_CLIENT_METHOD_DEPENDENCY_TYPES: &[&str] = &[ "ThreadSearchOccurrence", "ThreadSearchTextRange", "TurnSettingsUpdateStatus", + "UserVerificationProof", + "UserVerificationCancellationReason", + "UserVerificationErrorDetails", + "UserVerificationFailureReason", + "UserVerificationInvalidRequestReason", + "UserVerificationRpcError", + "UserVerificationUnavailableReason", ]; const SPECIAL_DEFINITIONS: &[&str] = &[ "ClientNotification", @@ -123,6 +130,7 @@ pub fn generate_ts_with_options( ClientRequest::export_all_to(out_dir)?; export_client_responses(out_dir)?; ClientNotification::export_all_to(out_dir)?; + crate::UserVerificationRpcError::export_all_to(out_dir)?; ServerRequest::export_all_to(out_dir)?; export_server_responses(out_dir)?; @@ -220,6 +228,10 @@ pub fn generate_json_with_experimental(out_dir: &Path, experimental_api: bool) - schemas.push(emit(out_dir)?); } + schemas.push(write_json_schema::( + out_dir, + "v2::UserVerificationRpcError", + )?); schemas.extend(export_client_param_schemas(out_dir)?); schemas.extend(export_client_response_schemas(out_dir)?); schemas.extend(export_server_param_schemas(out_dir)?); diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 90cc6e308f..a718641f7a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -518,6 +518,34 @@ client_request_definitions! { response: v2::ServerDiagnosticsResponse, }, + #[experimental("userVerification/status")] + UserVerificationStatus => "userVerification/status" { + params: v2::UserVerificationStatusParams, + serialization: None, + response: v2::UserVerificationStatusResponse, + }, + + #[experimental("userVerification/enroll")] + UserVerificationEnroll => "userVerification/enroll" { + params: v2::UserVerificationEnrollParams, + serialization: None, + response: v2::UserVerificationEnrollResponse, + }, + + #[experimental("userVerification/delete")] + UserVerificationDelete => "userVerification/delete" { + params: v2::UserVerificationDeleteParams, + serialization: None, + response: v2::UserVerificationDeleteResponse, + }, + + #[experimental("userVerification/verify")] + UserVerificationVerify => "userVerification/verify" { + params: v2::UserVerificationVerifyParams, + serialization: None, + response: v2::UserVerificationVerifyResponse, + }, + /// NEW APIs // Thread lifecycle // Uses `inspect_params` because only some fields are experimental. diff --git a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs index 240db7ef27..9c91c85c26 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -33,6 +33,7 @@ mod thread; mod thread_data; mod thread_usage; mod turn; +mod user_verification; mod windows_sandbox; pub use account::*; @@ -69,6 +70,7 @@ pub use thread::*; pub use thread_data::*; pub use thread_usage::*; pub use turn::*; +pub use user_verification::*; pub use windows_sandbox::*; #[cfg(test)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs b/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs new file mode 100644 index 0000000000..93ad4b8d7c --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/user_verification.rs @@ -0,0 +1,150 @@ +//! Experimental user-verification APIs for trusted UI clients. +//! Native implementation and registration transport are independent of these contracts. + +use crate::JsonSchema; +use crate::TS; +use serde::Deserialize; +use serde::Serialize; + +/// A signature over the exact decoded challenge. The verifier validates and consumes it. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct UserVerificationProof { + pub credential_id: String, + /// Unpadded base64url DER ECDSA signature using P-256 and SHA-256. + pub signature: String, +} + +impl std::fmt::Debug for UserVerificationProof { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("UserVerificationProof") + .finish_non_exhaustive() + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum UserVerificationUnavailableReason { + CredentialMissing, + BiometricsUnavailable, + ProviderUnavailable, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum UserVerificationCancellationReason { + UserCancelled, + Interrupted, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum UserVerificationFailureReason { + AuthenticationFailed, + Timeout, + ProviderError, + ServiceError, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +pub enum UserVerificationInvalidRequestReason { + InvalidParams, +} + +/// Closed error categories; native diagnostic payloads must not cross this boundary. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(tag = "type", rename_all = "camelCase", deny_unknown_fields)] +#[ts(tag = "type", rename_all = "camelCase", export_to = "v2/")] +pub enum UserVerificationErrorDetails { + InvalidRequest { + reason: UserVerificationInvalidRequestReason, + }, + Unavailable { + reason: UserVerificationUnavailableReason, + }, + Cancelled { + reason: UserVerificationCancellationReason, + }, + Failed { + reason: UserVerificationFailureReason, + }, +} + +/// The error object inside the normal JSON-RPC envelope. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct UserVerificationRpcError { + #[ts(type = "number")] + pub code: i64, + pub message: String, + pub data: UserVerificationErrorDetails, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct UserVerificationStatusParams {} + +/// Local readiness only; this neither prompts nor queries server registration. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct UserVerificationStatusResponse { + pub credential_id: Option, + pub unavailable_reason: Option, + pub unavailable_message: Option, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct UserVerificationEnrollParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct UserVerificationEnrollResponse { + pub credential_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct UserVerificationDeleteParams {} + +#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct UserVerificationDeleteResponse {} + +/// Local signing primitive, independent of any pending elicitation. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export_to = "v2/")] +pub struct UserVerificationVerifyParams { + /// Unpadded base64url encoding of 1–4096 challenge bytes. + pub challenge: String, + /// Display context already approved by the UI; 1–256 UTF-8 bytes. + pub title: String, + /// Additional display context; at most 4096 UTF-8 bytes. + pub description: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct UserVerificationVerifyResponse { + pub proof: UserVerificationProof, +} + +#[cfg(test)] +#[path = "user_verification_tests.rs"] +mod tests; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/user_verification_tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/user_verification_tests.rs new file mode 100644 index 0000000000..e8d6cd60c6 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/user_verification_tests.rs @@ -0,0 +1,44 @@ +use super::*; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[test] +fn local_readiness_retains_credential_during_biometric_unavailability() { + let status = UserVerificationStatusResponse { + credential_id: Some("credential".into()), + unavailable_reason: Some(UserVerificationUnavailableReason::BiometricsUnavailable), + unavailable_message: Some("Unlock biometrics to continue.".into()), + }; + assert_eq!( + serde_json::to_value(&status).unwrap(), + json!({ + "credentialId": "credential", + "unavailableReason": "biometricsUnavailable", + "unavailableMessage": "Unlock biometrics to continue." + }) + ); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&status).unwrap() + ) + .unwrap(), + status + ); +} + +#[test] +fn error_details_reject_cross_category_reasons_and_native_payloads() { + let details = UserVerificationErrorDetails::Cancelled { + reason: UserVerificationCancellationReason::UserCancelled, + }; + assert_eq!( + serde_json::to_value(details).unwrap(), + json!({"type": "cancelled", "reason": "userCancelled"}) + ); + for invalid in [ + json!({"type": "failed", "reason": "userCancelled"}), + json!({"type": "unavailable", "reason": "providerUnavailable", "nativeError": {"code": 1}}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } +} diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 4fc9588ab2..90f4317de7 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -3007,3 +3007,33 @@ For server-initiated request payloads, annotate the field the same way so schema ```bash just test -p codex-app-server-protocol ``` + +## User verification (experimental) + +Trusted UI clients use four experimental methods. They require the existing +`experimentalApi` opt-in. This contract-only stage returns +`unavailable/providerUnavailable` until native operations are connected. + +| Method | Params | Result | +| --- | --- | --- | +| `userVerification/status` | `{}` | `{credentialId, unavailableReason, unavailableMessage}` | +| `userVerification/enroll` | `{}` | `{credentialId}` | +| `userVerification/delete` | `{}` | `{}` | +| `userVerification/verify` | `{challenge, title, description}` | `{proof: {credentialId, signature}}` | + +Status reads local readiness without prompting or contacting a backend. A null +`unavailableReason` means local checks passed, not that registration is valid. +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. + +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. diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index 8c20146199..a7cdee38f2 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -132,6 +132,7 @@ mod thread_state; mod thread_status; mod transport; mod turn_cost_worker; +mod user_verification; pub use crate::code_mode_host::AppServerCodeModeHostArgs; pub use crate::code_mode_host::CodeModeHostTransport; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index e838e57c07..3be4c9770e 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -980,6 +980,12 @@ impl MessageProcessor { ClientRequest::Initialize { .. } => { panic!("Initialize should be handled before initialized request dispatch"); } + ClientRequest::UserVerificationStatus { .. } + | ClientRequest::UserVerificationEnroll { .. } + | ClientRequest::UserVerificationDelete { .. } + | ClientRequest::UserVerificationVerify { .. } => { + Err(crate::user_verification::unavailable()) + } ClientRequest::ServerDiagnostics { .. } => Ok(Some(read_server_diagnostics().into())), ClientRequest::ConfigRead { params, .. } => self .config_processor diff --git a/codex-rs/app-server/src/user_verification.rs b/codex-rs/app-server/src/user_verification.rs new file mode 100644 index 0000000000..1b3d1c16e6 --- /dev/null +++ b/codex-rs/app-server/src/user_verification.rs @@ -0,0 +1,17 @@ +//! Dispatch boundary for experimental verification APIs. +//! Native operations are introduced separately from the public contract. + +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::UserVerificationErrorDetails; +use codex_app_server_protocol::UserVerificationUnavailableReason; + +pub(crate) fn unavailable() -> JSONRPCErrorError { + JSONRPCErrorError { + code: -32603, + message: "User verification is not available in this build.".into(), + data: serde_json::to_value(UserVerificationErrorDetails::Unavailable { + reason: UserVerificationUnavailableReason::ProviderUnavailable, + }) + .ok(), + } +} diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index 1ccf6e8cd7..883ba0f61d 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -130,3 +130,5 @@ mod turn_steer; mod view_image; mod web_search; mod windows_sandbox_setup; + +mod user_verification; diff --git a/codex-rs/app-server/tests/suite/v2/user_verification.rs b/codex-rs/app-server/tests/suite/v2/user_verification.rs new file mode 100644 index 0000000000..74afd19a6a --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/user_verification.rs @@ -0,0 +1,76 @@ +use anyhow::Result; +use app_test_support::DEFAULT_CLIENT_NAME; +use app_test_support::TestAppServer; +use codex_app_server_protocol::ClientInfo; +use codex_app_server_protocol::InitializeCapabilities; +use codex_app_server_protocol::JSONRPCErrorError; +use codex_app_server_protocol::RequestId; +use pretty_assertions::assert_eq; +use serde_json::json; + +#[tokio::test] +async fn user_verification_methods_require_experimental_opt_in() -> Result<()> { + let mut app_server = TestAppServer::builder().build().await?; + app_server + .initialize_with_capabilities( + ClientInfo { + name: DEFAULT_CLIENT_NAME.into(), + title: None, + version: "0.1.0".into(), + }, + Some(InitializeCapabilities { + experimental_api: false, + ..Default::default() + }), + ) + .await?; + + for (method, params) in [ + ("userVerification/status", json!({})), + ("userVerification/enroll", json!({})), + ("userVerification/delete", json!({})), + ( + "userVerification/verify", + json!({"challenge": "AQ", "title": "Approve", "description": ""}), + ), + ] { + let id = app_server.send_raw_request(method, Some(params)).await?; + let response = app_server + .read_stream_until_error_message(RequestId::Integer(id)) + .await?; + assert_eq!( + response.error, + JSONRPCErrorError { + code: -32600, + message: format!("{method} requires experimentalApi capability"), + data: None, + } + ); + } + Ok(()) +} + +#[tokio::test] +async fn user_verification_without_provider_returns_typed_unavailability() -> Result<()> { + let mut app_server = TestAppServer::builder().build_initialized().await?; + let id = app_server + .send_raw_request( + "userVerification/verify", + Some(json!({ + "challenge": "AQ", "title": "Approve", "description": "" + })), + ) + .await?; + let response = app_server + .read_stream_until_error_message(RequestId::Integer(id)) + .await?; + assert_eq!( + response.error, + JSONRPCErrorError { + code: -32603, + message: "User verification is not available in this build.".into(), + data: Some(json!({"type": "unavailable", "reason": "providerUnavailable"})), + } + ); + Ok(()) +}