diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 880adfc254..9bcc30bfae 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1122,6 +1122,7 @@ async fn initialize_caches_client_and_thread_lifecycle_publishes_once_initialize }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1269,6 +1270,7 @@ async fn compaction_event_ingests_custom_fact() { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, @@ -1382,6 +1384,7 @@ async fn guardian_review_event_ingests_custom_fact_with_optional_target_item() { }, capabilities: Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), }, diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs index ebafe351af..e167bdb7db 100644 --- a/codex-rs/app-server-client/src/lib.rs +++ b/codex-rs/app-server-client/src/lib.rs @@ -375,6 +375,7 @@ impl InProcessClientStartArgs { pub fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, + request_attestation: false, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { diff --git a/codex-rs/app-server-client/src/remote.rs b/codex-rs/app-server-client/src/remote.rs index d75534c160..4a4426a260 100644 --- a/codex-rs/app-server-client/src/remote.rs +++ b/codex-rs/app-server-client/src/remote.rs @@ -73,6 +73,7 @@ impl RemoteAppServerConnectArgs { fn initialize_params(&self) -> InitializeParams { let capabilities = InitializeCapabilities { experimental_api: self.experimental_api, + request_attestation: false, opt_out_notification_methods: if self.opt_out_notification_methods.is_empty() { None } else { diff --git a/codex-rs/app-server-protocol/schema/json/AttestationGenerateParams.json b/codex-rs/app-server-protocol/schema/json/AttestationGenerateParams.json new file mode 100644 index 0000000000..310552bb7d --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/AttestationGenerateParams.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AttestationGenerateParams", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json b/codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json new file mode 100644 index 0000000000..b7b7f8c474 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/AttestationGenerateResponse.json @@ -0,0 +1,14 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "headerValue": { + "description": "Opaque upstream `x-oai-attestation` header value.", + "type": "string" + } + }, + "required": [ + "headerValue" + ], + "title": "AttestationGenerateResponse", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index cac1c33d9b..4fe50a4d46 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1259,6 +1259,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/ServerRequest.json b/codex-rs/app-server-protocol/schema/json/ServerRequest.json index 51cab50810..31e3651951 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ServerRequest.json @@ -121,6 +121,9 @@ ], "type": "object" }, + "AttestationGenerateParams": { + "type": "object" + }, "ChatgptAuthTokensRefreshParams": { "properties": { "previousAccountId": { @@ -1900,6 +1903,31 @@ "title": "Account/chatgptAuthTokens/refreshRequest", "type": "object" }, + { + "description": "Generate a fresh upstream attestation result on demand.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": [ + "attestation/generate" + ], + "title": "Attestation/generateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AttestationGenerateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Attestation/generateRequest", + "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 5e24845729..71d9f81bc5 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 @@ -83,6 +83,25 @@ "title": "ApplyPatchApprovalResponse", "type": "object" }, + "AttestationGenerateParams": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "AttestationGenerateParams", + "type": "object" + }, + "AttestationGenerateResponse": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "headerValue": { + "description": "Opaque upstream `x-oai-attestation` header value.", + "type": "string" + } + }, + "required": [ + "headerValue" + ], + "title": "AttestationGenerateResponse", + "type": "object" + }, "ChatgptAuthTokensRefreshParams": { "$schema": "http://json-schema.org/draft-07/schema#", "properties": { @@ -2608,6 +2627,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" @@ -5189,6 +5213,31 @@ "title": "Account/chatgptAuthTokens/refreshRequest", "type": "object" }, + { + "description": "Generate a fresh upstream attestation result on demand.", + "properties": { + "id": { + "$ref": "#/definitions/v2/RequestId" + }, + "method": { + "enum": [ + "attestation/generate" + ], + "title": "Attestation/generateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AttestationGenerateParams" + } + }, + "required": [ + "id", + "method", + "params" + ], + "title": "Attestation/generateRequest", + "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.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 6153a54eb9..854083ceb2 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 @@ -6409,6 +6409,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json index 6048b82242..af5c509249 100644 --- a/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v1/InitializeParams.json @@ -39,6 +39,11 @@ "array", "null" ] + }, + "requestAttestation": { + "default": false, + "description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.", + "type": "boolean" } }, "type": "object" diff --git a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts index 5d42cc4852..c5043e3b64 100644 --- a/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts +++ b/codex-rs/app-server-protocol/schema/typescript/InitializeCapabilities.ts @@ -10,6 +10,10 @@ export type InitializeCapabilities = { * Opt into receiving experimental API methods and fields. */ experimentalApi: boolean, +/** + * Opt into `attestation/generate` requests for upstream `x-oai-attestation`. + */ +requestAttestation: boolean, /** * Exact notification method names that should be suppressed for this * connection (for example `thread/started`). diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts index 13d04b0be7..80e9ffc116 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerRequest.ts @@ -4,6 +4,7 @@ import type { ApplyPatchApprovalParams } from "./ApplyPatchApprovalParams"; import type { ExecCommandApprovalParams } from "./ExecCommandApprovalParams"; import type { RequestId } from "./RequestId"; +import type { AttestationGenerateParams } from "./v2/AttestationGenerateParams"; import type { ChatgptAuthTokensRefreshParams } from "./v2/ChatgptAuthTokensRefreshParams"; import type { CommandExecutionRequestApprovalParams } from "./v2/CommandExecutionRequestApprovalParams"; import type { DynamicToolCallParams } from "./v2/DynamicToolCallParams"; @@ -15,4 +16,4 @@ 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": "attestation/generate", id: RequestId, params: AttestationGenerateParams, } | { "method": "applyPatchApproval", id: RequestId, params: ApplyPatchApprovalParams, } | { "method": "execCommandApproval", id: RequestId, params: ExecCommandApprovalParams, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.ts new file mode 100644 index 0000000000..0e87e7d3e4 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateParams.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 AttestationGenerateParams = Record; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts new file mode 100644 index 0000000000..48eef943fc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/AttestationGenerateResponse.ts @@ -0,0 +1,9 @@ +// 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 AttestationGenerateResponse = { +/** + * Opaque upstream `x-oai-attestation` header value. + */ +headerValue: string, }; 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 950dd9839a..4517e2adb7 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -28,6 +28,8 @@ export type { AppsDefaultConfig } from "./AppsDefaultConfig"; export type { AppsListParams } from "./AppsListParams"; export type { AppsListResponse } from "./AppsListResponse"; export type { AskForApproval } from "./AskForApproval"; +export type { AttestationGenerateParams } from "./AttestationGenerateParams"; +export type { AttestationGenerateResponse } from "./AttestationGenerateResponse"; export type { AutoReviewDecisionSource } from "./AutoReviewDecisionSource"; export type { ByteRange } from "./ByteRange"; export type { CancelLoginAccountParams } from "./CancelLoginAccountParams"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index e79c99a9c9..eab1e6235a 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1305,6 +1305,12 @@ server_request_definitions! { response: v2::ChatgptAuthTokensRefreshResponse, }, + /// Generate a fresh upstream attestation result on demand. + AttestationGenerate => "attestation/generate" { + params: v2::AttestationGenerateParams, + response: v2::AttestationGenerateResponse, + }, + /// DEPRECATED APIs below /// Request to approve a patch. /// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage). @@ -1891,6 +1897,7 @@ mod tests { }, capabilities: Some(v1::InitializeCapabilities { experimental_api: true, + request_attestation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -1911,6 +1918,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1936,6 +1944,7 @@ mod tests { }, "capabilities": { "experimentalApi": true, + "requestAttestation": true, "optOutNotificationMethods": [ "thread/started", "item/agentMessage/delta" @@ -1956,6 +1965,7 @@ mod tests { }, capabilities: Some(v1::InitializeCapabilities { experimental_api: true, + request_attestation: true, opt_out_notification_methods: Some(vec![ "thread/started".to_string(), "item/agentMessage/delta".to_string(), @@ -2072,6 +2082,28 @@ mod tests { Ok(()) } + #[test] + fn serialize_attestation_generate_request() -> Result<()> { + let params = v2::AttestationGenerateParams {}; + let request = ServerRequest::AttestationGenerate { + request_id: RequestId::Integer(9), + params: params.clone(), + }; + assert_eq!( + json!({ + "method": "attestation/generate", + "id": 9, + "params": {} + }), + serde_json::to_value(&request)?, + ); + + let payload = ServerRequestPayload::AttestationGenerate(params); + assert_eq!(request.id(), &RequestId::Integer(9)); + assert_eq!(payload.request_with_id(RequestId::Integer(9)), request); + Ok(()) + } + #[test] fn serialize_server_response() -> Result<()> { let response = ServerResponse::CommandExecutionRequestApproval { diff --git a/codex-rs/app-server-protocol/src/protocol/v1.rs b/codex-rs/app-server-protocol/src/protocol/v1.rs index d642e7fab9..95ab710a6b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v1.rs +++ b/codex-rs/app-server-protocol/src/protocol/v1.rs @@ -46,6 +46,9 @@ pub struct InitializeCapabilities { /// Opt into receiving experimental API methods and fields. #[serde(default)] pub experimental_api: bool, + /// Opt into `attestation/generate` requests for upstream `x-oai-attestation`. + #[serde(default)] + pub request_attestation: bool, /// Exact notification method names that should be suppressed for this /// connection (for example `thread/started`). #[ts(optional = nullable)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs b/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs new file mode 100644 index 0000000000..36173b6360 --- /dev/null +++ b/codex-rs/app-server-protocol/src/protocol/v2/attestation.rs @@ -0,0 +1,17 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AttestationGenerateParams {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct AttestationGenerateResponse { + /// Opaque upstream `x-oai-attestation` header value. + pub header_value: String, +} 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 275e7ca45b..32c24bff1d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/mod.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/mod.rs @@ -2,6 +2,7 @@ mod shared; mod account; mod apps; +mod attestation; mod collaboration_mode; mod command_exec; mod config; @@ -26,6 +27,7 @@ mod windows_sandbox; pub use account::*; pub use apps::*; +pub use attestation::*; pub use collaboration_mode::*; pub use command_exec::*; pub use config::*; diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index edea431c61..ff3a181f2e 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -1551,6 +1551,7 @@ impl CodexClient { }, capabilities: Some(InitializeCapabilities { experimental_api, + request_attestation: false, opt_out_notification_methods: Some( NOTIFICATIONS_TO_OPT_OUT .iter() diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index babfac99ba..c55f84ab27 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1322,6 +1322,10 @@ UI guidance for IDEs: surface an approval dialog as soon as the request arrives. When the client responds to `item/tool/requestUserInput`, the server emits `serverRequest/resolved` with `{ threadId, requestId }`. If the pending request is cleared by turn start, turn completion, or turn interruption before the client answers, the server emits the same notification for that cleanup. +### Attestation generation + +Desktop hosts that provide upstream attestation should set `capabilities.requestAttestation` during `initialize` and handle the server-initiated `attestation/generate` request. App-server issues it just in time before ChatGPT Codex requests that forward `x-oai-attestation`; the client responds with `{ "headerValue": "v1." }`, where `headerValue` is the complete upstream header value. App-server treats that value as opaque and forwards it unchanged. If no initialized client opted into attestation, or if the opted-in client is unavailable, times out, or returns invalid data, app-server omits `x-oai-attestation` for that upstream request. + ### MCP server elicitations MCP servers can interrupt a turn and ask the client for structured input via `mcpServer/elicitation/request`. diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 2ca0a87d84..81dcaf02a6 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -40,6 +40,8 @@ use crate::transport::RemoteControlHandle; use async_trait::async_trait; use codex_analytics::AnalyticsEventsClient; use codex_analytics::AppServerRpcTransport; +use codex_app_server_protocol::AttestationGenerateParams; +use codex_app_server_protocol::AttestationGenerateResponse; use codex_app_server_protocol::AuthMode as LoginAuthMode; use codex_app_server_protocol::ChatgptAuthTokensRefreshParams; use codex_app_server_protocol::ChatgptAuthTokensRefreshReason; @@ -58,6 +60,7 @@ use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::experimental_required_message; use codex_arg0::Arg0DispatchPaths; use codex_chatgpt::workspace_settings; +use codex_core::AttestationProvider; use codex_core::ThreadManager; use codex_core::config::Config; use codex_core::thread_store_from_config; @@ -80,7 +83,9 @@ use tokio::sync::watch; use tokio::time::Duration; use tokio::time::timeout; use tracing::Instrument; +use tracing::warn; +const ATTESTATION_GENERATE_TIMEOUT: Duration = Duration::from_secs(5); const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Clone)] struct ExternalAuthRefreshBridge { @@ -150,6 +155,86 @@ impl ExternalAuth for ExternalAuthRefreshBridge { } } +fn app_server_attestation_provider( + outgoing: Arc, + attestation_connection_ids: Arc>>, +) -> AttestationProvider { + AttestationProvider::new(move || { + let outgoing = outgoing.clone(); + let attestation_connection_ids = attestation_connection_ids.clone(); + Box::pin(request_attestation_header_value( + outgoing, + attestation_connection_ids, + )) + }) +} + +async fn request_attestation_header_value( + outgoing: Arc, + attestation_connection_ids: Arc>>, +) -> Option { + request_attestation_header_value_with_timeout( + outgoing, + attestation_connection_ids, + ATTESTATION_GENERATE_TIMEOUT, + ) + .await +} + +async fn request_attestation_header_value_with_timeout( + outgoing: Arc, + attestation_connection_ids: Arc>>, + timeout_duration: Duration, +) -> Option { + let connection_id = attestation_connection_ids + .lock() + .await + .iter() + .min_by_key(|connection_id| connection_id.0) + .copied()?; + + let connection_ids = [connection_id]; + let (request_id, rx) = outgoing + .send_request_to_connections( + Some(&connection_ids), + ServerRequestPayload::AttestationGenerate(AttestationGenerateParams {}), + /*thread_id*/ None, + ) + .await; + + let result = match timeout(timeout_duration, rx).await { + Ok(Ok(Ok(result))) => result, + Ok(Ok(Err(err))) => { + warn!( + code = err.code, + message = %err.message, + "attestation generation request failed" + ); + return None; + } + Ok(Err(err)) => { + warn!("attestation generation request canceled: {err}"); + return None; + } + Err(_) => { + let _canceled = outgoing.cancel_request(&request_id).await; + warn!( + timeout_seconds = timeout_duration.as_secs(), + "attestation generation request timed out" + ); + return None; + } + }; + + match serde_json::from_value::(result) { + Ok(response) => Some(response.header_value), + Err(err) => { + warn!("failed to deserialize attestation generation response: {err}"); + None + } + } +} + pub(crate) struct MessageProcessor { outgoing: Arc, account_processor: AccountRequestProcessor, @@ -172,6 +257,7 @@ pub(crate) struct MessageProcessor { turn_processor: TurnRequestProcessor, windows_sandbox_processor: WindowsSandboxRequestProcessor, request_serialization_queues: RequestSerializationQueues, + attestation_connection_ids: Arc>>, } #[derive(Debug)] @@ -186,6 +272,7 @@ pub(crate) struct InitializedConnectionSessionState { pub(crate) opted_out_notification_methods: HashSet, pub(crate) app_server_client_name: String, pub(crate) client_version: String, + pub(crate) request_attestation: bool, } impl Default for ConnectionSessionState { @@ -231,6 +318,12 @@ impl ConnectionSessionState { .map(|session| session.client_version.as_str()) } + pub(crate) fn request_attestation(&self) -> bool { + self.initialized + .get() + .is_some_and(|session| session.request_attestation) + } + pub(crate) fn initialize(&self, session: InitializedConnectionSessionState) -> Result<(), ()> { self.initialized.set(session).map_err(|_| ()) } @@ -280,11 +373,12 @@ impl MessageProcessor { auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge { outgoing: outgoing.clone(), })); + let attestation_connection_ids = Arc::new(Mutex::new(HashSet::new())); // The thread store is intentionally process-scoped. Config reloads can // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. let thread_store = thread_store_from_config(config.as_ref(), state_db.clone()); - let thread_manager = Arc::new(ThreadManager::new( + let thread_manager = Arc::new(ThreadManager::new_with_attestation_provider( config.as_ref(), auth_manager.clone(), session_source, @@ -293,6 +387,10 @@ impl MessageProcessor { Arc::clone(&thread_store), state_db.clone(), installation_id, + Some(app_server_attestation_provider( + outgoing.clone(), + attestation_connection_ids.clone(), + )), )); thread_manager .plugins_manager() @@ -467,6 +565,7 @@ impl MessageProcessor { turn_processor, windows_sandbox_processor, request_serialization_queues: RequestSerializationQueues::default(), + attestation_connection_ids, } } @@ -664,6 +763,10 @@ impl MessageProcessor { session_state: &ConnectionSessionState, ) { session_state.rpc_gate.shutdown().await; + self.attestation_connection_ids + .lock() + .await + .remove(&connection_id); self.outgoing.connection_closed(connection_id).await; self.fs_processor.connection_closed(connection_id).await; self.command_exec_processor @@ -721,6 +824,12 @@ impl MessageProcessor { .connection_initialized(connection_id) .await; } + if session.request_attestation() { + self.attestation_connection_ids + .lock() + .await + .insert(connection_id); + } return Ok(()); } diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index a7420f8c78..1d20353054 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -265,7 +265,7 @@ impl OutgoingMessageSender { RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed)) } - async fn send_request_to_connections( + pub(crate) async fn send_request_to_connections( &self, connection_ids: Option<&[ConnectionId]>, request: ServerRequestPayload, 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 a206b2faa0..c13ce4340f 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -65,15 +65,17 @@ impl InitializeRequestProcessor { // experimental API). Proposed direction is instance-global first-write-wins // with initialize-time mismatch rejection. let analytics_initialize_params = params.clone(); - let (experimental_api_enabled, opt_out_notification_methods) = match params.capabilities { - Some(capabilities) => ( - capabilities.experimental_api, - capabilities - .opt_out_notification_methods - .unwrap_or_default(), - ), - None => (false, Vec::new()), - }; + let (experimental_api_enabled, request_attestation, opt_out_notification_methods) = + match params.capabilities { + Some(capabilities) => ( + capabilities.experimental_api, + capabilities.request_attestation, + capabilities + .opt_out_notification_methods + .unwrap_or_default(), + ), + None => (false, false, Vec::new()), + }; let ClientInfo { name, title: _title, @@ -95,6 +97,7 @@ impl InitializeRequestProcessor { opted_out_notification_methods: opt_out_notification_methods.into_iter().collect(), app_server_client_name: name.clone(), client_version: version, + request_attestation, }) .is_err() { diff --git a/codex-rs/app-server/tests/suite/v2/experimental_api.rs b/codex-rs/app-server/tests/suite/v2/experimental_api.rs index 9ac0dc3e21..4096e3d96f 100644 --- a/codex-rs/app-server/tests/suite/v2/experimental_api.rs +++ b/codex-rs/app-server/tests/suite/v2/experimental_api.rs @@ -36,6 +36,7 @@ async fn mock_experimental_method_requires_experimental_api_capability() -> Resu default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -66,6 +67,7 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -103,6 +105,7 @@ async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -136,6 +139,7 @@ async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result< default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -177,6 +181,7 @@ async fn thread_start_mock_field_requires_experimental_api_capability() -> Resul default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -214,6 +219,7 @@ async fn thread_start_without_dynamic_tools_allows_without_experimental_api_capa default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) @@ -250,6 +256,7 @@ async fn thread_start_granular_approval_policy_requires_experimental_api_capabil default_client_info(), Some(InitializeCapabilities { experimental_api: false, + request_attestation: false, opt_out_notification_methods: None, }), ) diff --git a/codex-rs/app-server/tests/suite/v2/initialize.rs b/codex-rs/app-server/tests/suite/v2/initialize.rs index 165160468f..dcfd4e5499 100644 --- a/codex-rs/app-server/tests/suite/v2/initialize.rs +++ b/codex-rs/app-server/tests/suite/v2/initialize.rs @@ -158,6 +158,7 @@ async fn initialize_opt_out_notification_methods_filters_notifications() -> Resu }, Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: Some(vec!["thread/started".to_string()]), }), ), diff --git a/codex-rs/app-server/tests/suite/v2/thread_status.rs b/codex-rs/app-server/tests/suite/v2/thread_status.rs index ad90e4900a..957969c3ea 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_status.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_status.rs @@ -145,6 +145,7 @@ async fn thread_status_changed_can_be_opted_out() -> Result<()> { }, Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: Some(vec!["thread/status/changed".to_string()]), }), ), diff --git a/codex-rs/core/src/attestation.rs b/codex-rs/core/src/attestation.rs new file mode 100644 index 0000000000..aa637304be --- /dev/null +++ b/codex-rs/core/src/attestation.rs @@ -0,0 +1,39 @@ +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use http::HeaderValue; + +pub(crate) const X_OAI_ATTESTATION_HEADER: &str = "x-oai-attestation"; + +type GenerateAttestationFuture = Pin> + Send>>; +type GenerateAttestationCallback = dyn Fn() -> GenerateAttestationFuture + Send + Sync + 'static; + +/// Session-scoped source for just-in-time attestation header values. +/// +/// Host integrations provide the opaque string expected by the upstream +/// `x-oai-attestation` header. Core validates only that it is legal as an HTTP +/// header value before forwarding it. +#[derive(Clone)] +pub struct AttestationProvider { + generate: Arc, +} + +impl fmt::Debug for AttestationProvider { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.debug_struct("AttestationProvider").finish() + } +} + +impl AttestationProvider { + pub fn new(generate: impl Fn() -> GenerateAttestationFuture + Send + Sync + 'static) -> Self { + Self { + generate: Arc::new(generate), + } + } + + pub(crate) async fn generate_header(&self) -> Option { + HeaderValue::from_str(&(self.generate)().await?).ok() + } +} diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 39e6e85e20..1f72ca93ea 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -105,6 +105,8 @@ use tracing::instrument; use tracing::trace; use tracing::warn; +use crate::attestation::AttestationProvider; +use crate::attestation::X_OAI_ATTESTATION_HEADER; use crate::client_common::Prompt; use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; @@ -118,6 +120,7 @@ use codex_login::auth_env_telemetry::AuthEnvTelemetry; use codex_login::auth_env_telemetry::collect_auth_env_telemetry; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; +use codex_model_provider_info::CHATGPT_CODEX_BASE_URL; #[cfg(test)] use codex_model_provider_info::DEFAULT_WEBSOCKET_CONNECT_TIMEOUT_MS; use codex_model_provider_info::ModelProviderInfo; @@ -170,6 +173,7 @@ struct ModelClientState { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + attestation_provider: Option, disable_websockets: AtomicBool, cached_websocket_session: StdMutex, } @@ -314,6 +318,35 @@ impl ModelClient { enable_request_compression: bool, include_timing_metrics: bool, beta_features_header: Option, + ) -> Self { + Self::new_with_attestation_provider( + auth_manager, + session_id, + thread_id, + installation_id, + provider_info, + session_source, + model_verbosity, + enable_request_compression, + include_timing_metrics, + beta_features_header, + /*attestation_provider*/ None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn new_with_attestation_provider( + auth_manager: Option>, + session_id: SessionId, + thread_id: ThreadId, + installation_id: String, + provider_info: ModelProviderInfo, + session_source: SessionSource, + model_verbosity: Option, + enable_request_compression: bool, + include_timing_metrics: bool, + beta_features_header: Option, + attestation_provider: Option, ) -> Self { let model_provider = create_model_provider(provider_info, auth_manager); let codex_api_key_env_enabled = model_provider @@ -335,6 +368,7 @@ impl ModelClient { enable_request_compression, include_timing_metrics, beta_features_header, + attestation_provider, disable_websockets: AtomicBool::new(false), cached_websocket_session: StdMutex::new(WebsocketSession::default()), }), @@ -463,9 +497,6 @@ impl ModelClient { text, .. } = request; - let client = - ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) - .with_telemetry(Some(request_telemetry)); let payload = ApiCompactionInput { model: &model, input: &input, @@ -492,6 +523,15 @@ impl ModelClient { Some(self.state.session_id.to_string()), Some(self.state.thread_id.to_string()), )); + self.extend_attestation_header_for( + &mut extra_headers, + &client_setup.api_provider, + AttestationPurpose::Compaction, + ) + .await; + let client = + ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth) + .with_telemetry(Some(request_telemetry)); let trace_attempt = compaction_trace.start_attempt(&payload); let result = client .compact_input(&payload, extra_headers) @@ -505,11 +545,17 @@ impl ModelClient { &self, sdp: String, session_config: ApiRealtimeSessionConfig, - extra_headers: ApiHeaderMap, + mut extra_headers: ApiHeaderMap, ) -> Result { // Create the media call over HTTP first, then retain matching auth so realtime can attach // the server-side control WebSocket to the call id from that HTTP response. let client_setup = self.current_client_setup().await?; + self.extend_attestation_header_for( + &mut extra_headers, + &client_setup.api_provider, + AttestationPurpose::RealtimeWebrtcCallSetup, + ) + .await; let mut sideband_headers = extra_headers.clone(); sideband_headers.extend(sideband_websocket_auth_headers( client_setup.api_auth.as_ref(), @@ -640,6 +686,25 @@ impl ModelClient { client_metadata } + async fn generate_attestation_header(&self) -> Option { + self.state + .attestation_provider + .as_ref()? + .generate_header() + .await + } + + async fn generate_attestation_header_for( + &self, + provider: &codex_api::Provider, + purpose: AttestationPurpose, + ) -> Option { + if !should_send_attestation(provider, purpose) { + return None; + } + self.generate_attestation_header().await + } + /// Builds request telemetry for unary API calls (e.g., Compact endpoint). fn build_request_telemetry( session_telemetry: &SessionTelemetry, @@ -777,7 +842,9 @@ impl ModelClient { auth_context: AuthRequestTelemetryContext, request_route_telemetry: RequestRouteTelemetry, ) -> std::result::Result { - let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header); + let headers = self + .build_websocket_headers(&api_provider, turn_state.as_ref(), turn_metadata_header) + .await; let websocket_telemetry = ModelClientSession::build_websocket_telemetry( session_telemetry, auth_context, @@ -854,8 +921,9 @@ impl ModelClient { /// /// Callers should pass the current turn-state lock when available so sticky-routing state is /// replayed on reconnect within the same turn. - fn build_websocket_headers( + async fn build_websocket_headers( &self, + provider: &codex_api::Provider, turn_state: Option<&Arc>>, turn_metadata_header: Option<&str>, ) -> ApiHeaderMap { @@ -872,6 +940,8 @@ impl ModelClient { } headers.extend(build_session_headers(Some(session_id), Some(thread_id))); headers.extend(self.build_responses_identity_headers()); + self.extend_attestation_header_for(&mut headers, provider, AttestationPurpose::Response) + .await; headers.insert( OPENAI_BETA_HEADER, HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE), @@ -920,8 +990,9 @@ impl ModelClientSession { /// /// Keeping option construction in one place ensures request-scoped headers are consistent /// regardless of transport choice. - fn build_responses_options( + async fn build_responses_options( &self, + provider: &codex_api::Provider, turn_metadata_header: Option<&str>, compression: Compression, ) -> ApiResponsesOptions { @@ -939,6 +1010,13 @@ impl ModelClientSession { turn_metadata_header.as_ref(), ); headers.extend(self.client.build_responses_identity_headers()); + self.client + .extend_attestation_header_for( + &mut headers, + provider, + AttestationPurpose::Response, + ) + .await; headers }, compression, @@ -1215,7 +1293,13 @@ impl ModelClientSession { self.client.state.auth_env_telemetry.clone(), ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); - let options = self.build_responses_options(turn_metadata_header, compression); + let options = self + .build_responses_options( + &client_setup.api_provider, + turn_metadata_header, + compression, + ) + .await; let request = self.client.build_responses_request( &client_setup.api_provider, @@ -1322,7 +1406,13 @@ impl ModelClientSession { ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); - let options = self.build_responses_options(turn_metadata_header, compression); + let options = self + .build_responses_options( + &client_setup.api_provider, + turn_metadata_header, + compression, + ) + .await; let request = self.client.build_responses_request( &client_setup.api_provider, prompt, @@ -1626,6 +1716,43 @@ fn build_responses_headers( headers } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AttestationPurpose { + Response, + Compaction, + RealtimeWebrtcCallSetup, +} + +fn should_send_attestation(provider: &codex_api::Provider, purpose: AttestationPurpose) -> bool { + let provider_is_chatgpt_codex = provider + .base_url + .trim_end_matches('/') + .eq_ignore_ascii_case(CHATGPT_CODEX_BASE_URL); + provider_is_chatgpt_codex + && matches!( + purpose, + AttestationPurpose::Response + | AttestationPurpose::Compaction + | AttestationPurpose::RealtimeWebrtcCallSetup + ) +} + +impl ModelClient { + async fn extend_attestation_header_for( + &self, + headers: &mut ApiHeaderMap, + provider: &codex_api::Provider, + purpose: AttestationPurpose, + ) { + if let Some(header_value) = self + .generate_attestation_header_for(provider, purpose) + .await + { + headers.insert(X_OAI_ATTESTATION_HEADER, header_value); + } + } +} + fn subagent_header_value(session_source: &SessionSource) -> Option { match session_source { SessionSource::SubAgent(subagent_source) => match subagent_source { diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 2ba65d7c45..622bddb77a 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -1,3 +1,4 @@ +use super::AttestationPurpose; use super::AuthRequestTelemetryContext; use super::ModelClient; use super::PendingUnauthorizedRetry; @@ -7,6 +8,7 @@ use super::X_CODEX_PARENT_THREAD_ID_HEADER; use super::X_CODEX_TURN_METADATA_HEADER; use super::X_CODEX_WINDOW_ID_HEADER; use super::X_OPENAI_SUBAGENT_HEADER; +use crate::AttestationProvider; use codex_api::ApiError; use codex_api::ResponseEvent; use codex_app_server_protocol::AuthMode; @@ -14,6 +16,7 @@ use codex_model_provider::BearerAuthProvider; use codex_model_provider_info::WireApi; use codex_model_provider_info::create_oss_provider_with_base_url; use codex_otel::SessionTelemetry; +use codex_protocol::SessionId; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -36,6 +39,8 @@ use std::collections::VecDeque; use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex; +use std::sync::atomic::AtomicUsize; +use std::sync::atomic::Ordering; use std::task::Context; use std::task::Poll; use std::time::Duration; @@ -67,6 +72,23 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { ) } +fn api_provider(base_url: &str) -> codex_api::Provider { + codex_api::Provider { + name: "test".to_string(), + base_url: base_url.to_string(), + query_params: None, + headers: http::HeaderMap::new(), + retry: codex_api::RetryConfig { + max_attempts: 1, + base_delay: Duration::from_millis(1), + retry_429: false, + retry_5xx: true, + retry_transport: true, + }, + stream_idle_timeout: Duration::from_secs(1), + } +} + fn test_model_info() -> ModelInfo { serde_json::from_value(json!({ "slug": "gpt-test", @@ -466,3 +488,219 @@ fn auth_request_telemetry_context_tracks_attached_auth_and_retry_phase() { assert_eq!(auth_context.recovery_mode, Some("managed")); assert_eq!(auth_context.recovery_phase, Some("refresh_token")); } + +fn model_client_with_counting_attestation() -> (ModelClient, Arc) { + let attestation_calls = Arc::new(AtomicUsize::new(0)); + let calls = attestation_calls.clone(); + let model_client = ModelClient::new_with_attestation_provider( + /*auth_manager*/ None, + SessionId::new(), + ThreadId::new(), + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), + create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses), + SessionSource::Exec, + /*model_verbosity*/ None, + /*enable_request_compression*/ false, + /*include_timing_metrics*/ false, + /*beta_features_header*/ None, + Some(AttestationProvider::new(move || { + let calls = calls.clone(); + Box::pin(async move { + let call = calls.fetch_add(1, Ordering::Relaxed) + 1; + Some(format!("v1.header-{call}")) + }) + })), + ); + (model_client, attestation_calls) +} + +#[test] +fn should_send_attestation_for_allowed_chatgpt_codex_purposes() { + let provider = api_provider("https://chatgpt.com/backend-api/codex/"); + + for purpose in [ + AttestationPurpose::Response, + AttestationPurpose::Compaction, + AttestationPurpose::RealtimeWebrtcCallSetup, + ] { + assert!(super::should_send_attestation(&provider, purpose)); + } +} + +#[test] +fn should_not_send_attestation_for_non_chatgpt_codex_provider() { + let provider = api_provider("https://api.openai.com/v1"); + + assert!(!super::should_send_attestation( + &provider, + AttestationPurpose::Response, + )); +} + +#[tokio::test] +async fn responses_generate_fresh_attestation_headers_for_chatgpt_codex() { + let provider = api_provider("https://chatgpt.com/backend-api/codex/"); + let (model_client, attestation_calls) = model_client_with_counting_attestation(); + let mut first_headers = http::HeaderMap::new(); + let mut second_headers = http::HeaderMap::new(); + + model_client + .extend_attestation_header_for(&mut first_headers, &provider, AttestationPurpose::Response) + .await; + model_client + .extend_attestation_header_for(&mut second_headers, &provider, AttestationPurpose::Response) + .await; + + assert_eq!( + first_headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-1"), + ); + assert_eq!( + second_headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-2"), + ); + assert_eq!(attestation_calls.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn websocket_handshake_includes_attestation_for_chatgpt_codex_responses() { + let provider = api_provider("https://chatgpt.com/backend-api/codex/"); + let (model_client, attestation_calls) = model_client_with_counting_attestation(); + + let headers = model_client + .build_websocket_headers( + &provider, /*turn_state*/ None, /*turn_metadata_header*/ None, + ) + .await; + + assert_eq!( + headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-1"), + ); + assert_eq!(attestation_calls.load(Ordering::Relaxed), 1); +} + +#[tokio::test] +async fn compact_generate_fresh_attestation_headers_for_chatgpt_codex() { + let provider = api_provider("https://chatgpt.com/backend-api/codex/"); + let (model_client, attestation_calls) = model_client_with_counting_attestation(); + let mut first_headers = http::HeaderMap::new(); + let mut second_headers = http::HeaderMap::new(); + + model_client + .extend_attestation_header_for( + &mut first_headers, + &provider, + AttestationPurpose::Compaction, + ) + .await; + model_client + .extend_attestation_header_for( + &mut second_headers, + &provider, + AttestationPurpose::Compaction, + ) + .await; + + assert_eq!( + first_headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-1"), + ); + assert_eq!( + second_headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-2"), + ); + assert_eq!(attestation_calls.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn realtime_setup_generate_fresh_attestation_headers_for_chatgpt_codex() { + let provider = api_provider("https://chatgpt.com/backend-api/codex/"); + let (model_client, attestation_calls) = model_client_with_counting_attestation(); + let mut first_headers = http::HeaderMap::new(); + let mut second_headers = http::HeaderMap::new(); + + model_client + .extend_attestation_header_for( + &mut first_headers, + &provider, + AttestationPurpose::RealtimeWebrtcCallSetup, + ) + .await; + model_client + .extend_attestation_header_for( + &mut second_headers, + &provider, + AttestationPurpose::RealtimeWebrtcCallSetup, + ) + .await; + + assert_eq!( + first_headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-1"), + ); + assert_eq!( + second_headers + .get(crate::attestation::X_OAI_ATTESTATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("v1.header-2"), + ); + assert_eq!(attestation_calls.load(Ordering::Relaxed), 2); +} + +#[tokio::test] +async fn non_chatgpt_codex_endpoints_omit_attestation_generation() { + let provider = api_provider("https://api.openai.com/v1"); + let (model_client, attestation_calls) = model_client_with_counting_attestation(); + let mut response_headers = http::HeaderMap::new(); + + model_client + .extend_attestation_header_for( + &mut response_headers, + &provider, + AttestationPurpose::Response, + ) + .await; + let mut compaction_headers = http::HeaderMap::new(); + model_client + .extend_attestation_header_for( + &mut compaction_headers, + &provider, + AttestationPurpose::Compaction, + ) + .await; + let mut realtime_headers = http::HeaderMap::new(); + model_client + .extend_attestation_header_for( + &mut realtime_headers, + &provider, + AttestationPurpose::RealtimeWebrtcCallSetup, + ) + .await; + + assert_eq!( + response_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER), + None, + ); + assert_eq!( + compaction_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER), + None, + ); + assert_eq!( + realtime_headers.get(crate::attestation::X_OAI_ATTESTATION_HEADER), + None, + ); + assert_eq!(attestation_calls.load(Ordering::Relaxed), 0); +} diff --git a/codex-rs/core/src/codex_delegate.rs b/codex-rs/core/src/codex_delegate.rs index a89d8fc973..2503764904 100644 --- a/codex-rs/core/src/codex_delegate.rs +++ b/codex-rs/core/src/codex_delegate.rs @@ -99,6 +99,7 @@ pub(crate) async fn run_codex_thread_interactive( environment_selections: parent_ctx.environments.clone(), analytics_events_client: Some(parent_session.services.analytics_events_client.clone()), thread_store: Arc::clone(&parent_session.services.thread_store), + attestation_provider: parent_session.services.attestation_provider.clone(), })) .or_cancel(&cancel_token) .await??; diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 0cdf0e2d46..c178b5d4e8 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -23,6 +23,7 @@ pub use codex_thread::CodexThread; pub use codex_thread::CodexThreadTurnContextOverrides; pub use codex_thread::ThreadConfigSnapshot; mod agent; +mod attestation; mod codex_delegate; mod command_canonicalization; mod commit_attribution; @@ -177,6 +178,7 @@ mod tasks; mod user_shell_command; pub mod util; +pub use attestation::AttestationProvider; pub use client::ModelClient; pub use client::ModelClientSession; pub use client::X_CODEX_INSTALLATION_ID_HEADER; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 6d4b275421..07ef550f51 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -14,6 +14,7 @@ use crate::agent::Mailbox; use crate::agent::MailboxReceiver; use crate::agent::agent_status_from_event; use crate::agent::status::is_final; +use crate::attestation::AttestationProvider; use crate::build_available_skills; use crate::commit_attribution::commit_message_trailer_instruction; use crate::compact; @@ -412,6 +413,7 @@ pub(crate) struct CodexSpawnArgs { pub(crate) environment_selections: ResolvedTurnEnvironments, pub(crate) analytics_events_client: Option, pub(crate) thread_store: Arc, + pub(crate) attestation_provider: Option, } pub(crate) const INITIAL_SUBMIT_ID: &str = ""; @@ -471,6 +473,7 @@ impl Codex { environment_selections, analytics_events_client, thread_store, + attestation_provider, } = args; let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -656,6 +659,7 @@ impl Codex { analytics_events_client, thread_store, parent_rollout_thread_trace, + attestation_provider, ) .await .map_err(|e| { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index f72a173c80..828ecca4c2 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -370,6 +370,7 @@ impl Session { analytics_events_client: Option, thread_store: Arc, parent_rollout_thread_trace: ThreadTraceContext, + attestation_provider: Option, ) -> anyhow::Result> { debug!( "Configuring session: model={}; provider={:?}", @@ -852,7 +853,8 @@ impl Session { state_db: state_db_ctx.clone(), live_thread: live_thread_init.as_ref().cloned(), thread_store: Arc::clone(&thread_store), - model_client: ModelClient::new( + attestation_provider: attestation_provider.clone(), + model_client: ModelClient::new_with_attestation_provider( Some(Arc::clone(&auth_manager)), session_id, thread_id, @@ -863,6 +865,7 @@ impl Session { config.features.enabled(Feature::EnableRequestCompression), config.features.enabled(Feature::RuntimeMetrics), Self::build_model_client_beta_features_header(config.as_ref()), + attestation_provider, ), code_mode_service: crate::tools::code_mode::CodeModeService::new(), environment_manager, diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index b63b16cbf4..67fe370ac7 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -3733,6 +3733,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() { /*state_db*/ None, )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await; @@ -3881,6 +3882,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), /*state_db*/ None, )), + attestation_provider: None, model_client: ModelClient::new( Some(auth_manager.clone()), thread_id.into(), @@ -4069,6 +4071,7 @@ async fn make_session_with_config_and_rx( /*state_db*/ None, )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await?; @@ -4178,6 +4181,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( ), )), codex_rollout_trace::ThreadTraceContext::disabled(), + /*attestation_provider*/ None, ) .await?; @@ -5596,6 +5600,7 @@ where codex_thread_store::LocalThreadStoreConfig::from_config(config.as_ref()), state_db, )), + attestation_provider: None, model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), thread_id.into(), diff --git a/codex-rs/core/src/session/tests/guardian_tests.rs b/codex-rs/core/src/session/tests/guardian_tests.rs index 5c473ef1f9..37a5e6bb09 100644 --- a/codex-rs/core/src/session/tests/guardian_tests.rs +++ b/codex-rs/core/src/session/tests/guardian_tests.rs @@ -763,6 +763,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() { }, analytics_events_client: None, thread_store, + attestation_provider: None, }) .await .expect("spawn guardian subagent"); diff --git a/codex-rs/core/src/state/service.rs b/codex-rs/core/src/state/service.rs index 9cd9e97fbb..4506c0054c 100644 --- a/codex-rs/core/src/state/service.rs +++ b/codex-rs/core/src/state/service.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::SkillsManager; use crate::agent::AgentControl; +use crate::attestation::AttestationProvider; use crate::client::ModelClient; use crate::config::StartedNetworkProxy; use crate::exec_policy::ExecPolicyManager; @@ -66,6 +67,7 @@ pub(crate) struct SessionServices { pub(crate) state_db: Option, pub(crate) live_thread: Option, pub(crate) thread_store: Arc, + pub(crate) attestation_provider: Option, /// Session-scoped model client shared across turns. pub(crate) model_client: ModelClient, pub(crate) code_mode_service: CodeModeService, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index a19832717d..b1bfdc47b0 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -1,5 +1,6 @@ use crate::SkillsManager; use crate::agent::AgentControl; +use crate::attestation::AttestationProvider; use crate::codex_thread::CodexThread; use crate::config::Config; use crate::config::ThreadStoreConfig; @@ -249,6 +250,7 @@ pub(crate) struct ThreadManagerState { mcp_manager: Arc, skills_watcher: Arc, thread_store: Arc, + attestation_provider: Option, session_source: SessionSource, installation_id: String, analytics_events_client: Option, @@ -293,6 +295,31 @@ impl ThreadManager { thread_store: Arc, state_db: Option, installation_id: String, + ) -> Self { + Self::new_with_attestation_provider( + config, + auth_manager, + session_source, + environment_manager, + analytics_events_client, + thread_store, + state_db, + installation_id, + /*attestation_provider*/ None, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_with_attestation_provider( + config: &Config, + auth_manager: Arc, + session_source: SessionSource, + environment_manager: Arc, + analytics_events_client: Option, + thread_store: Arc, + state_db: Option, + installation_id: String, + attestation_provider: Option, ) -> Self { let codex_home = config.codex_home.clone(); let restriction_product = session_source.restriction_product(); @@ -319,6 +346,7 @@ impl ThreadManager { mcp_manager, skills_watcher, thread_store, + attestation_provider, auth_manager, session_source, installation_id, @@ -420,6 +448,7 @@ impl ThreadManager { mcp_manager, skills_watcher, thread_store, + attestation_provider: None, auth_manager, session_source: SessionSource::Exec, installation_id, @@ -1206,6 +1235,7 @@ impl ThreadManagerState { environment_selections, analytics_events_client: self.analytics_events_client.clone(), thread_store: Arc::clone(&self.thread_store), + attestation_provider: self.attestation_provider.clone(), }) .await?; let new_thread = self diff --git a/codex-rs/debug-client/src/client.rs b/codex-rs/debug-client/src/client.rs index 2edabfac00..69eb474aaf 100644 --- a/codex-rs/debug-client/src/client.rs +++ b/codex-rs/debug-client/src/client.rs @@ -103,6 +103,7 @@ impl AppServerClient { }, capabilities: Some(InitializeCapabilities { experimental_api: true, + request_attestation: false, opt_out_notification_methods: None, }), }, diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b035a19517..6e79b8bb3d 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1604,6 +1604,15 @@ async fn handle_server_request( ) .await } + ServerRequest::AttestationGenerate { request_id, .. } => { + reject_server_request( + client, + request_id, + &method, + "attestation generation is not supported in exec mode".to_string(), + ) + .await + } ServerRequest::ApplyPatchApproval { request_id, params } => { reject_server_request( client, diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 0fb8be4746..6fca7e6a1f 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -34,6 +34,7 @@ const MAX_REQUEST_MAX_RETRIES: u64 = 100; const OPENAI_PROVIDER_NAME: &str = "OpenAI"; pub const OPENAI_PROVIDER_ID: &str = "openai"; +pub const CHATGPT_CODEX_BASE_URL: &str = "https://chatgpt.com/backend-api/codex"; const AMAZON_BEDROCK_PROVIDER_NAME: &str = "Amazon Bedrock"; pub const AMAZON_BEDROCK_PROVIDER_ID: &str = "amazon-bedrock"; pub const AMAZON_BEDROCK_DEFAULT_BASE_URL: &str = @@ -234,7 +235,7 @@ impl ModelProviderInfo { auth_mode, Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) ) { - "https://chatgpt.com/backend-api/codex" + CHATGPT_CODEX_BASE_URL } else { "https://api.openai.com/v1" }; diff --git a/codex-rs/tui/src/app/app_server_event_targets.rs b/codex-rs/tui/src/app/app_server_event_targets.rs index 382a82a19f..d535bf8e3d 100644 --- a/codex-rs/tui/src/app/app_server_event_targets.rs +++ b/codex-rs/tui/src/app/app_server_event_targets.rs @@ -25,6 +25,7 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option None, } diff --git a/codex-rs/tui/src/app/app_server_requests.rs b/codex-rs/tui/src/app/app_server_requests.rs index 4b587b0fc8..ff3c755b6e 100644 --- a/codex-rs/tui/src/app/app_server_requests.rs +++ b/codex-rs/tui/src/app/app_server_requests.rs @@ -134,6 +134,12 @@ impl PendingAppServerRequests { }) } ServerRequest::ChatgptAuthTokensRefresh { .. } => None, + ServerRequest::AttestationGenerate { request_id, .. } => { + Some(UnsupportedAppServerRequest { + request_id: request_id.clone(), + message: "Attestation generation is not available in TUI.".to_string(), + }) + } ServerRequest::ApplyPatchApproval { request_id, .. } => { Some(UnsupportedAppServerRequest { request_id: request_id.clone(), @@ -332,6 +338,7 @@ impl PendingAppServerRequests { .any(|pending_request_id| pending_request_id == request_id), ServerRequest::DynamicToolCall { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } + | ServerRequest::AttestationGenerate { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => true, } diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 59f3d71991..d3ea62da70 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -92,6 +92,7 @@ impl SideParentStatus { | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval), ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } => None, } } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index adaba76b24..1f6774da15 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -6204,6 +6204,7 @@ impl ChatWidget { self.on_request_user_input(params); } ServerRequest::DynamicToolCall { .. } + | ServerRequest::AttestationGenerate { .. } | ServerRequest::ChatgptAuthTokensRefresh { .. } | ServerRequest::ApplyPatchApproval { .. } | ServerRequest::ExecCommandApproval { .. } => {