[codex] request desktop attestation from app (#20619)

## Summary

TL;DR: teaches `codex-rs` / app-server to request a desktop-provided
attestation token and attach it as `x-oai-attestation` on the scoped
ChatGPT Codex request paths.

![DeviceCheck attestation
interface](https://raw.githubusercontent.com/openai/codex/dev/jm/devicecheck-diagram-assets/pr-assets/devicecheck-attestation-interface.png)

## Details

This PR teaches the Codex app-server runtime how to request and attach
an attestation token. It does not generate DeviceCheck tokens directly;
instead, it relies on the connected desktop app to advertise that it can
generate attestation and then asks that app for a fresh header value
when needed.

The flow is:

1. The Codex desktop app connects to app-server.
2. During `initialize`, the app can advertise that it supports
`requestAttestation`.
3. Before app-server calls selected ChatGPT Codex endpoints, it sends
the internal server request `attestation/generate` to the app.
4. app-server receives a pre-encoded header value back.
5. app-server forwards that value as `x-oai-attestation` on the scoped
outbound requests.

The code in this repo is mostly protocol and runtime plumbing: it adds
the app-server request/response shape, introduces an attestation
provider in core, wires that provider into Responses / compaction /
realtime setup paths, and covers the intended scoping with tests. The
signed macOS DeviceCheck generation remains owned by the desktop app PR.

## Related PR

- Codex desktop app implementation:
https://github.com/openai/openai/pull/878649

## Validation

<details>
<summary>Tests run</summary>

```sh
cargo test -p codex-app-server-protocol
cargo test -p codex-core attestation --lib
cargo test -p codex-app-server --lib attestation
```

Also ran:

```sh
just fix -p codex-core
just fix -p codex-app-server
just fix -p codex-app-server-protocol
just fmt
just write-app-server-schema
```

</details>

<details>
<summary>E2E DeviceCheck validation</summary>

First validated the signed desktop app boundary directly: launched a
packaged signed `Codex.app`, sent `attestation/generate`, decoded the
returned `v1.` attestation header, and validated the extracted
DeviceCheck token with `personal/jm/verify_devicecheck_token.py` using
bundle ID `com.openai.codex`. Apple returned `status_code: 200` and
`is_ok: true`.

Then ran the fuller app + app-server flow. The packaged `Codex.app`
launched a current-branch app-server via `CODEX_CLI_PATH`, and a local
MITM proxy intercepted outbound `chatgpt.com` traffic. The app-server
requested `attestation/generate` from the real Electron app process, and
the intercepted `/backend-api/codex/responses` traffic included
`x-oai-attestation` on both routes:

```text
GET  /backend-api/codex/responses  Upgrade: websocket  x-oai-attestation: present
POST /backend-api/codex/responses  Upgrade: none       x-oai-attestation: present
```

The captured header decoded to a DeviceCheck token that also validated
with Apple for `com.openai.codex` (`status_code: 200`, `is_ok: true`,
team `2DC432GLL2`).

</details>

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Jiaming Zhang
2026-05-08 12:36:02 -07:00
committed by GitHub
Unverified
parent 61142b6169
commit 5f4d0ec343
65 changed files with 1086 additions and 39 deletions
@@ -640,6 +640,7 @@ fn sample_initialize_fact(connection_id: u64) -> AnalyticsFact {
},
capabilities: Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
}),
},
@@ -1326,6 +1327,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,
}),
},
@@ -1473,6 +1475,7 @@ async fn compaction_event_ingests_custom_fact() {
},
capabilities: Some(InitializeCapabilities {
experimental_api: false,
request_attestation: false,
opt_out_notification_methods: None,
}),
},
@@ -1586,6 +1589,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,
}),
},
+1
View File
@@ -374,6 +374,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 {
+1
View File
@@ -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 {
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "AttestationGenerateParams",
"type": "object"
}
@@ -0,0 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"token": {
"description": "Opaque client attestation token.",
"type": "string"
}
},
"required": [
"token"
],
"title": "AttestationGenerateResponse",
"type": "object"
}
@@ -1259,6 +1259,11 @@
"array",
"null"
]
},
"requestAttestation": {
"default": false,
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
"type": "boolean"
}
},
"type": "object"
@@ -121,6 +121,9 @@
],
"type": "object"
},
"AttestationGenerateParams": {
"type": "object"
},
"ChatgptAuthTokensRefreshParams": {
"properties": {
"previousAccountId": {
@@ -1918,6 +1921,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": {
@@ -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": {
"token": {
"description": "Opaque client attestation token.",
"type": "string"
}
},
"required": [
"token"
],
"title": "AttestationGenerateResponse",
"type": "object"
},
"ChatgptAuthTokensRefreshParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -2620,6 +2639,11 @@
"array",
"null"
]
},
"requestAttestation": {
"default": false,
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
"type": "boolean"
}
},
"type": "object"
@@ -5207,6 +5231,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": {
@@ -6409,6 +6409,11 @@
"array",
"null"
]
},
"requestAttestation": {
"default": false,
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
"type": "boolean"
}
},
"type": "object"
@@ -39,6 +39,11 @@
"array",
"null"
]
},
"requestAttestation": {
"default": false,
"description": "Opt into `attestation/generate` requests for upstream `x-oai-attestation`.",
"type": "boolean"
}
},
"type": "object"
@@ -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`).
@@ -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, };
@@ -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<string, never>;
@@ -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 client attestation token.
*/
token: string, };
@@ -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";
@@ -1312,6 +1312,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).
@@ -1910,6 +1916,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(),
@@ -1930,6 +1937,7 @@ mod tests {
},
"capabilities": {
"experimentalApi": true,
"requestAttestation": true,
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
@@ -1955,6 +1963,7 @@ mod tests {
},
"capabilities": {
"experimentalApi": true,
"requestAttestation": true,
"optOutNotificationMethods": [
"thread/started",
"item/agentMessage/delta"
@@ -1975,6 +1984,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(),
@@ -2091,6 +2101,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 {
@@ -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)]
@@ -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 client attestation token.
pub token: String,
}
@@ -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::*;
@@ -1551,6 +1551,7 @@ impl CodexClient {
},
capabilities: Some(InitializeCapabilities {
experimental_api,
request_attestation: false,
opt_out_notification_methods: Some(
NOTIFICATIONS_TO_OPT_OUT
.iter()
+4
View File
@@ -1337,6 +1337,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 `{ "token": "v1.<opaque>" }`, where `token` is an opaque client-owned value. When app-server receives a client response, it forwards a consistent outer envelope such as `{ "v": 1, "s": 0, "t": "v1.<opaque>" }`, where `t` contains the client token unchanged. If app-server attempts attestation but fails within its own boundary, it sends the same envelope shape with an app-server status code and without `t` (`1 = timeout`, `2 = request failed`, `3 = request canceled`, `4 = malformed response`). If no initialized client opted into attestation, 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`.
+217
View File
@@ -0,0 +1,217 @@
use std::sync::Arc;
use axum::http::HeaderValue;
use codex_app_server_protocol::AttestationGenerateParams;
use codex_app_server_protocol::AttestationGenerateResponse;
use codex_app_server_protocol::ServerRequestPayload;
use codex_core::AttestationContext;
use codex_core::AttestationProvider;
use codex_core::GenerateAttestationFuture;
use serde::Serialize;
use tokio::time::Duration;
use tokio::time::timeout;
use tracing::warn;
use crate::outgoing_message::OutgoingMessageSender;
use crate::thread_state::ThreadStateManager;
const ATTESTATION_GENERATE_TIMEOUT: Duration = Duration::from_millis(100);
pub(crate) fn app_server_attestation_provider(
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
) -> Arc<dyn AttestationProvider> {
Arc::new(AppServerAttestationProvider {
outgoing,
thread_state_manager,
})
}
struct AppServerAttestationProvider {
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
}
impl std::fmt::Debug for AppServerAttestationProvider {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("AppServerAttestationProvider")
.finish()
}
}
impl AttestationProvider for AppServerAttestationProvider {
fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_> {
let outgoing = self.outgoing.clone();
let thread_state_manager = self.thread_state_manager.clone();
Box::pin(async move {
request_attestation_header_value_with_timeout(
outgoing,
thread_state_manager,
context.thread_id,
ATTESTATION_GENERATE_TIMEOUT,
)
.await
.and_then(|value| HeaderValue::from_bytes(value.as_bytes()).ok())
})
}
}
async fn request_attestation_header_value_with_timeout(
outgoing: Arc<OutgoingMessageSender>,
thread_state_manager: ThreadStateManager,
thread_id: codex_protocol::ThreadId,
timeout_duration: Duration,
) -> Option<String> {
let connection_id = thread_state_manager
.first_attestation_capable_connection_for_thread(thread_id)
.await?;
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 app_server_attestation_header_value(
AppServerAttestationStatus::RequestFailed,
/*token*/ None,
);
}
Ok(Err(err)) => {
warn!("attestation generation request canceled: {err}");
return app_server_attestation_header_value(
AppServerAttestationStatus::RequestCanceled,
/*token*/ None,
);
}
Err(_) => {
let _canceled = outgoing.cancel_request(&request_id).await;
warn!(
timeout_seconds = timeout_duration.as_secs(),
"attestation generation request timed out"
);
return app_server_attestation_header_value(
AppServerAttestationStatus::Timeout,
/*token*/ None,
);
}
};
match serde_json::from_value::<AttestationGenerateResponse>(result) {
Ok(response) => app_server_attestation_header_value(
AppServerAttestationStatus::Ok,
Some(&response.token),
),
Err(err) => {
warn!("failed to deserialize attestation generation response: {err}");
app_server_attestation_header_value(
AppServerAttestationStatus::MalformedResponse,
/*token*/ None,
)
}
}
}
#[derive(Clone, Copy)]
enum AppServerAttestationStatus {
Ok,
Timeout,
RequestFailed,
RequestCanceled,
MalformedResponse,
}
impl AppServerAttestationStatus {
const fn code(self) -> u8 {
match self {
Self::Ok => 0,
Self::Timeout => 1,
Self::RequestFailed => 2,
Self::RequestCanceled => 3,
Self::MalformedResponse => 4,
}
}
}
#[derive(Serialize)]
struct AppServerAttestationEnvelope<'a> {
v: u8,
s: u8,
#[serde(skip_serializing_if = "Option::is_none")]
t: Option<&'a str>,
}
fn app_server_attestation_header_value(
status: AppServerAttestationStatus,
token: Option<&str>,
) -> Option<String> {
serde_json::to_string(&AppServerAttestationEnvelope {
v: 1,
s: status.code(),
t: token,
})
.map_err(|err| warn!("failed to serialize app-server attestation envelope: {err}"))
.ok()
}
#[cfg(test)]
mod tests {
use super::AppServerAttestationStatus;
use super::app_server_attestation_header_value;
use pretty_assertions::assert_eq;
#[test]
fn app_server_attestation_header_value_wraps_opaque_client_payloads() {
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::Ok,
Some("v1.opaque-client-payload"),
),
Some(r#"{"v":1,"s":0,"t":"v1.opaque-client-payload"}"#.to_string())
);
}
#[test]
fn app_server_attestation_header_value_reports_app_server_failures() {
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::Timeout,
/*token*/ None,
),
Some(r#"{"v":1,"s":1}"#.to_string())
);
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::RequestFailed,
/*token*/ None,
),
Some(r#"{"v":1,"s":2}"#.to_string())
);
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::RequestCanceled,
/*token*/ None,
),
Some(r#"{"v":1,"s":3}"#.to_string())
);
assert_eq!(
app_server_attestation_header_value(
AppServerAttestationStatus::MalformedResponse,
/*token*/ None
),
Some(r#"{"v":1,"s":4}"#.to_string())
);
}
}
+9 -1
View File
@@ -74,6 +74,7 @@ use tracing_subscriber::util::SubscriberInitExt;
mod analytics_utils;
mod app_server_tracing;
mod attestation;
mod bespoke_event_handling;
mod command_exec;
mod config;
@@ -938,7 +939,14 @@ pub async fn run_main_with_transport_options(
),
)
.await;
processor.connection_initialized(connection_id).await;
processor
.connection_initialized(
connection_id,
connection_state
.session
.request_attestation(),
)
.await;
connection_state
.outbound_initialized
.store(true, std::sync::atomic::Ordering::Release);
+1
View File
@@ -187,6 +187,7 @@ mod tests {
thread_store,
Some(state_db.clone()),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
));
thread_manager.start_thread(good_config).await?;
thread_manager.start_thread(bad_config).await?;
+32 -4
View File
@@ -4,6 +4,7 @@ use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use crate::attestation::app_server_attestation_provider;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::error_code::invalid_request;
@@ -34,6 +35,7 @@ use crate::request_processors::WindowsSandboxRequestProcessor;
use crate::request_serialization::QueuedInitializedRequest;
use crate::request_serialization::RequestSerializationQueueKey;
use crate::request_serialization::RequestSerializationQueues;
use crate::thread_state::ConnectionCapabilities;
use crate::thread_state::ThreadStateManager;
use crate::transport::AppServerTransport;
use crate::transport::RemoteControlHandle;
@@ -82,6 +84,7 @@ use tokio::time::timeout;
use tracing::Instrument;
const EXTERNAL_AUTH_REFRESH_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Clone)]
struct ExternalAuthRefreshBridge {
outgoing: Arc<OutgoingMessageSender>,
@@ -186,6 +189,7 @@ pub(crate) struct InitializedConnectionSessionState {
pub(crate) opted_out_notification_methods: HashSet<String>,
pub(crate) app_server_client_name: String,
pub(crate) client_version: String,
pub(crate) request_attestation: bool,
}
impl Default for ConnectionSessionState {
@@ -231,6 +235,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,6 +290,7 @@ impl MessageProcessor {
auth_manager.set_external_auth(Arc::new(ExternalAuthRefreshBridge {
outgoing: outgoing.clone(),
}));
let thread_state_manager = ThreadStateManager::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.
@@ -293,13 +304,16 @@ impl MessageProcessor {
Arc::clone(&thread_store),
state_db.clone(),
installation_id,
Some(app_server_attestation_provider(
outgoing.clone(),
thread_state_manager.clone(),
)),
));
thread_manager
.plugins_manager()
.set_analytics_events_client(analytics_events_client.clone());
let pending_thread_unloads = Arc::new(Mutex::new(HashSet::new()));
let thread_state_manager = ThreadStateManager::new();
let thread_watch_manager =
crate::thread_status::ThreadWatchManager::new_with_outgoing(outgoing.clone());
let thread_list_state_permit = Arc::new(Semaphore::new(/*permits*/ 1));
@@ -620,9 +634,18 @@ impl MessageProcessor {
.await;
}
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
pub(crate) async fn connection_initialized(
&self,
connection_id: ConnectionId,
request_attestation: bool,
) {
self.thread_processor
.connection_initialized(connection_id)
.connection_initialized(
connection_id,
ConnectionCapabilities {
request_attestation,
},
)
.await;
}
@@ -718,7 +741,12 @@ impl MessageProcessor {
.await?;
if connection_initialized {
self.thread_processor
.connection_initialized(connection_id)
.connection_initialized(
connection_id,
ConnectionCapabilities {
request_attestation: session.request_attestation(),
},
)
.await;
}
return Ok(());
+1 -1
View File
@@ -267,7 +267,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,
@@ -473,6 +473,7 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use crate::filters::compute_source_filters;
use crate::filters::source_kind_matches;
use crate::thread_state::ConnectionCapabilities;
use crate::thread_state::ThreadListenerCommand;
use crate::thread_state::ThreadState;
use crate::thread_state::ThreadStateManager;
@@ -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()
{
@@ -2230,9 +2230,13 @@ impl ThreadRequestProcessor {
self.thread_manager.subscribe_thread_created()
}
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
pub(crate) async fn connection_initialized(
&self,
connection_id: ConnectionId,
capabilities: ConnectionCapabilities,
) {
self.thread_state_manager
.connection_initialized(connection_id)
.connection_initialized(connection_id, capabilities)
.await;
}
@@ -1115,7 +1115,9 @@ mod thread_processor_behavior_tests {
let connection = ConnectionId(1);
let (cancel_tx, cancel_rx) = oneshot::channel();
manager.connection_initialized(connection).await;
manager
.connection_initialized(connection, ConnectionCapabilities::default())
.await;
manager
.try_ensure_connection_subscribed(
thread_id, connection, /*experimental_raw_events*/ false,
@@ -1158,8 +1160,12 @@ mod thread_processor_behavior_tests {
let connection_b = ConnectionId(2);
let (cancel_tx, mut cancel_rx) = oneshot::channel();
manager.connection_initialized(connection_a).await;
manager.connection_initialized(connection_b).await;
manager
.connection_initialized(connection_a, ConnectionCapabilities::default())
.await;
manager
.connection_initialized(connection_b, ConnectionCapabilities::default())
.await;
manager
.try_ensure_connection_subscribed(
thread_id,
@@ -1203,8 +1209,12 @@ mod thread_processor_behavior_tests {
let connection_a = ConnectionId(1);
let connection_b = ConnectionId(2);
manager.connection_initialized(connection_a).await;
manager.connection_initialized(connection_b).await;
manager
.connection_initialized(connection_a, ConnectionCapabilities::default())
.await;
manager
.connection_initialized(connection_b, ConnectionCapabilities::default())
.await;
manager
.try_ensure_connection_subscribed(
thread_id,
@@ -1249,7 +1259,9 @@ mod thread_processor_behavior_tests {
let thread_id = ThreadId::from_string("ad7f0408-99b8-4f6e-a46f-bd0eec433370")?;
let connection = ConnectionId(1);
manager.connection_initialized(connection).await;
manager
.connection_initialized(connection, ConnectionCapabilities::default())
.await;
let threads_to_unload = manager.remove_connection(connection).await;
assert_eq!(threads_to_unload, Vec::<ThreadId>::new());
@@ -1264,4 +1276,79 @@ mod thread_processor_behavior_tests {
assert!(!manager.has_subscribers(thread_id).await);
Ok(())
}
#[tokio::test]
async fn first_attestation_capable_connection_for_thread_only_uses_thread_subscribers()
-> Result<()> {
let manager = ThreadStateManager::new();
let thread_id = ThreadId::from_string("dfbd9a95-2f44-470a-8bd8-1cfc04efc243")?;
let other_thread_id = ThreadId::from_string("6c9a74e4-5e59-479e-90bf-5c5798bb50aa")?;
let unrelated_supported_connection = ConnectionId(1);
let earlier_supported_connection = ConnectionId(2);
let later_supported_connection = ConnectionId(3);
let unsupported_connection = ConnectionId(4);
manager
.connection_initialized(
unrelated_supported_connection,
ConnectionCapabilities {
request_attestation: true,
},
)
.await;
manager
.connection_initialized(
earlier_supported_connection,
ConnectionCapabilities {
request_attestation: true,
},
)
.await;
manager
.connection_initialized(
later_supported_connection,
ConnectionCapabilities {
request_attestation: true,
},
)
.await;
manager
.connection_initialized(unsupported_connection, ConnectionCapabilities::default())
.await;
assert!(
manager
.try_add_connection_to_thread(other_thread_id, unrelated_supported_connection)
.await
);
assert!(
manager
.try_add_connection_to_thread(thread_id, later_supported_connection)
.await
);
assert!(
manager
.try_add_connection_to_thread(thread_id, earlier_supported_connection)
.await
);
assert!(
manager
.try_add_connection_to_thread(thread_id, unsupported_connection)
.await
);
assert_eq!(
manager
.first_attestation_capable_connection_for_thread(thread_id)
.await,
Some(earlier_supported_connection)
);
assert_eq!(
manager
.first_attestation_capable_connection_for_thread(other_thread_id)
.await,
Some(unrelated_supported_connection)
);
Ok(())
}
}
+34 -5
View File
@@ -199,11 +199,16 @@ impl ThreadEntry {
#[derive(Default)]
struct ThreadStateManagerInner {
live_connections: HashSet<ConnectionId>,
live_connections: HashMap<ConnectionId, ConnectionCapabilities>,
threads: HashMap<ThreadId, ThreadEntry>,
thread_ids_by_connection: HashMap<ConnectionId, HashSet<ThreadId>>,
}
#[derive(Clone, Copy, Default)]
pub(crate) struct ConnectionCapabilities {
pub(crate) request_attestation: bool,
}
#[derive(Clone, Default)]
pub(crate) struct ThreadStateManager {
state: Arc<Mutex<ThreadStateManagerInner>>,
@@ -214,12 +219,36 @@ impl ThreadStateManager {
Self::default()
}
pub(crate) async fn connection_initialized(&self, connection_id: ConnectionId) {
pub(crate) async fn connection_initialized(
&self,
connection_id: ConnectionId,
capabilities: ConnectionCapabilities,
) {
self.state
.lock()
.await
.live_connections
.insert(connection_id);
.insert(connection_id, capabilities);
}
pub(crate) async fn first_attestation_capable_connection_for_thread(
&self,
thread_id: ThreadId,
) -> Option<ConnectionId> {
let state = self.state.lock().await;
state
.threads
.get(&thread_id)?
.connection_ids
.iter()
.filter_map(|connection_id| {
state
.live_connections
.get(connection_id)?
.request_attestation
.then_some(*connection_id)
})
.min_by_key(|connection_id| connection_id.0)
}
pub(crate) async fn subscribed_connection_ids(&self, thread_id: ThreadId) -> Vec<ConnectionId> {
@@ -338,7 +367,7 @@ impl ThreadStateManager {
) -> Option<Arc<Mutex<ThreadState>>> {
let thread_state = {
let mut state = self.state.lock().await;
if !state.live_connections.contains(&connection_id) {
if !state.live_connections.contains_key(&connection_id) {
return None;
}
state
@@ -366,7 +395,7 @@ impl ThreadStateManager {
connection_id: ConnectionId,
) -> bool {
let mut state = self.state.lock().await;
if !state.live_connections.contains(&connection_id) {
if !state.live_connections.contains_key(&connection_id) {
return false;
}
state
@@ -0,0 +1,194 @@
use anyhow::Result;
use anyhow::bail;
use app_test_support::ChatGptAuthFixture;
use app_test_support::McpProcess;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use codex_app_server_protocol::AttestationGenerateResponse;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeCapabilities;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use core_test_support::responses;
use core_test_support::responses::WebSocketConnectionConfig;
use core_test_support::responses::start_websocket_server_with_headers;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
const ATTESTATION_HEADER: &str = "v1.integration-test";
const APP_SERVER_ATTESTATION_HEADER: &str = r#"{"v":1,"s":0,"t":"v1.integration-test"}"#;
#[tokio::test]
async fn attestation_generate_round_trip_adds_header_to_responses_websocket_handshake() -> Result<()>
{
skip_if_no_network!(Ok(()));
let websocket_server = start_websocket_server_with_headers(vec![
// App-server refreshes `/models` over HTTP during thread startup. It points at the same
// local test base URL, so let that non-websocket probe consume one connection before the
// websocket handshake under test arrives.
WebSocketConnectionConfig {
requests: Vec::new(),
response_headers: Vec::new(),
accept_delay: None,
close_after_requests: true,
},
WebSocketConnectionConfig {
requests: vec![
vec![
responses::ev_response_created("warm-1"),
responses::ev_completed("warm-1"),
],
vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
],
],
response_headers: Vec::new(),
accept_delay: None,
close_after_requests: true,
},
])
.await;
let codex_home = TempDir::new()?;
create_chatgpt_websocket_config(
codex_home.path(),
&websocket_server.uri().replacen("ws://", "http://", 1),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"),
AuthCredentialsStoreMode::File,
)?;
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
let initialized = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_capabilities(
ClientInfo {
name: "codex_desktop".to_string(),
title: Some("Codex Desktop".to_string()),
version: "0.1.0".to_string(),
},
Some(InitializeCapabilities {
experimental_api: true,
request_attestation: true,
opt_out_notification_methods: None,
}),
),
)
.await??;
let JSONRPCMessage::Response(_) = initialized else {
bail!("expected initialize response, got {initialized:?}");
};
let thread_request_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let thread_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_request_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response(thread_response)?;
let turn_request_id = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
let turn_response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_request_id)),
)
.await??;
let _: TurnStartResponse = to_response(turn_response)?;
let mut attestation_requests = 0;
timeout(DEFAULT_READ_TIMEOUT, async {
loop {
match mcp.read_next_message().await? {
JSONRPCMessage::Request(request) => {
let request = ServerRequest::try_from(request)?;
let ServerRequest::AttestationGenerate { request_id, .. } = request else {
bail!("expected attestation/generate request, got {request:?}");
};
attestation_requests += 1;
mcp.send_response(
request_id,
serde_json::to_value(AttestationGenerateResponse {
token: ATTESTATION_HEADER.to_string(),
})?,
)
.await?;
}
JSONRPCMessage::Notification(notification)
if notification.method == "turn/completed" =>
{
break Ok(());
}
_ => {}
}
}
})
.await??;
assert!(attestation_requests > 0);
assert!(
websocket_server
.wait_for_handshakes(/*expected*/ 1, DEFAULT_READ_TIMEOUT)
.await
);
let handshake = websocket_server.single_handshake();
assert_eq!(
handshake.header("x-oai-attestation").as_deref(),
Some(APP_SERVER_ATTESTATION_HEADER)
);
websocket_server.shutdown().await;
Ok(())
}
fn create_chatgpt_websocket_config(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
std::fs::write(
codex_home.join("config.toml"),
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock ChatGPT provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
requires_openai_auth = true
supports_websockets = true
"#
),
)
}
@@ -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,
}),
)
@@ -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()]),
}),
),
@@ -1,6 +1,7 @@
mod account;
mod analytics;
mod app_list;
mod attestation;
mod client_metadata;
mod collaboration_mode_list;
#[cfg(unix)]
@@ -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()]),
}),
),
+26
View File
@@ -0,0 +1,26 @@
use std::future::Future;
use std::pin::Pin;
use codex_protocol::ThreadId;
use http::HeaderValue;
pub(crate) const X_OAI_ATTESTATION_HEADER: &str = "x-oai-attestation";
pub type GenerateAttestationFuture<'a> =
Pin<Box<dyn Future<Output = Option<HeaderValue>> + Send + 'a>>;
/// Request context that host integrations can use when deciding whether to
/// generate an attestation header value.
#[derive(Clone, Copy, Debug)]
pub struct AttestationContext {
/// Thread whose upstream request is being prepared.
pub thread_id: ThreadId,
}
/// Host integration boundary for just-in-time attestation header values.
///
/// Implementations own the policy for when attestation should be attempted and
/// return the upstream `x-oai-attestation` header value when one should be sent.
pub trait AttestationProvider: std::fmt::Debug + Send + Sync {
fn header_for_request(&self, context: AttestationContext) -> GenerateAttestationFuture<'_>;
}
+50 -9
View File
@@ -105,6 +105,9 @@ use tracing::instrument;
use tracing::trace;
use tracing::warn;
use crate::attestation::AttestationContext;
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;
@@ -170,6 +173,8 @@ struct ModelClientState {
enable_request_compression: bool,
include_timing_metrics: bool,
beta_features_header: Option<String>,
include_attestation: bool,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
disable_websockets: AtomicBool,
cached_websocket_session: StdMutex<WebsocketSession>,
}
@@ -314,6 +319,7 @@ impl ModelClient {
enable_request_compression: bool,
include_timing_metrics: bool,
beta_features_header: Option<String>,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
) -> Self {
let model_provider = create_model_provider(provider_info, auth_manager);
let codex_api_key_env_enabled = model_provider
@@ -322,6 +328,7 @@ impl ModelClient {
.is_some_and(|manager| manager.codex_api_key_env_enabled());
let auth_env_telemetry =
collect_auth_env_telemetry(model_provider.info(), codex_api_key_env_enabled);
let include_attestation = model_provider.supports_attestation();
Self {
state: Arc::new(ModelClientState {
session_id,
@@ -335,6 +342,8 @@ impl ModelClient {
enable_request_compression,
include_timing_metrics,
beta_features_header,
include_attestation,
attestation_provider,
disable_websockets: AtomicBool::new(false),
cached_websocket_session: StdMutex::new(WebsocketSession::default()),
}),
@@ -463,9 +472,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 +498,12 @@ impl ModelClient {
Some(self.state.session_id.to_string()),
Some(self.state.thread_id.to_string()),
));
if let Some(header_value) = self.generate_attestation_header_for().await {
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
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 +517,14 @@ impl ModelClient {
&self,
sdp: String,
session_config: ApiRealtimeSessionConfig,
extra_headers: ApiHeaderMap,
mut extra_headers: ApiHeaderMap,
) -> Result<RealtimeWebrtcCallStart> {
// 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?;
if let Some(header_value) = self.generate_attestation_header_for().await {
extra_headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
let mut sideband_headers = extra_headers.clone();
sideband_headers.extend(sideband_websocket_auth_headers(
client_setup.api_auth.as_ref(),
@@ -640,6 +655,20 @@ impl ModelClient {
client_metadata
}
async fn generate_attestation_header_for(&self) -> Option<HeaderValue> {
if !self.state.include_attestation {
return None;
}
self.state
.attestation_provider
.as_ref()?
.header_for_request(AttestationContext {
thread_id: self.state.thread_id,
})
.await
}
/// Builds request telemetry for unary API calls (e.g., Compact endpoint).
fn build_request_telemetry(
session_telemetry: &SessionTelemetry,
@@ -779,7 +808,9 @@ impl ModelClient {
auth_context: AuthRequestTelemetryContext,
request_route_telemetry: RequestRouteTelemetry,
) -> std::result::Result<ApiWebSocketConnection, ApiError> {
let headers = self.build_websocket_headers(turn_state.as_ref(), turn_metadata_header);
let headers = self
.build_websocket_headers(turn_state.as_ref(), turn_metadata_header)
.await;
let websocket_telemetry = ModelClientSession::build_websocket_telemetry(
session_telemetry,
auth_context,
@@ -856,7 +887,7 @@ 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,
turn_state: Option<&Arc<OnceLock<String>>>,
turn_metadata_header: Option<&str>,
@@ -874,6 +905,9 @@ impl ModelClient {
}
headers.extend(build_session_headers(Some(session_id), Some(thread_id)));
headers.extend(self.build_responses_identity_headers());
if let Some(header_value) = self.generate_attestation_header_for().await {
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
headers.insert(
OPENAI_BETA_HEADER,
HeaderValue::from_static(RESPONSES_WEBSOCKETS_V2_BETA_HEADER_VALUE),
@@ -922,7 +956,7 @@ 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,
turn_metadata_header: Option<&str>,
compression: Compression,
@@ -941,6 +975,9 @@ impl ModelClientSession {
turn_metadata_header.as_ref(),
);
headers.extend(self.client.build_responses_identity_headers());
if let Some(header_value) = self.client.generate_attestation_header_for().await {
headers.insert(X_OAI_ATTESTATION_HEADER, header_value);
}
headers
},
compression,
@@ -1217,7 +1254,9 @@ 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(turn_metadata_header, compression)
.await;
let request = self.client.build_responses_request(
&client_setup.api_provider,
@@ -1324,7 +1363,9 @@ 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(turn_metadata_header, compression)
.await;
let request = self.client.build_responses_request(
&client_setup.api_provider,
prompt,
+115
View File
@@ -7,13 +7,21 @@ 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::AttestationContext;
use crate::AttestationProvider;
use crate::GenerateAttestationFuture;
use codex_api::ApiError;
use codex_api::ResponseEvent;
use codex_app_server_protocol::AuthMode;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::BearerAuthProvider;
use codex_model_provider_info::CHATGPT_CODEX_BASE_URL;
use codex_model_provider_info::ModelProviderInfo;
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 +44,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;
@@ -64,6 +74,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
)
}
@@ -466,3 +477,107 @@ 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(
include_attestation: bool,
) -> (ModelClient, Arc<AtomicUsize>) {
#[derive(Debug)]
struct CountingAttestationProvider {
calls: Arc<AtomicUsize>,
}
impl AttestationProvider for CountingAttestationProvider {
fn header_for_request(
&self,
_context: AttestationContext,
) -> GenerateAttestationFuture<'_> {
let calls = self.calls.clone();
Box::pin(async move {
let call = calls.fetch_add(1, Ordering::Relaxed) + 1;
Some(http::HeaderValue::from_bytes(format!("v1.header-{call}").as_bytes()).unwrap())
})
}
}
let attestation_calls = Arc::new(AtomicUsize::new(0));
let (auth_manager, provider) = if include_attestation {
(
Some(AuthManager::from_auth_for_testing(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
)),
ModelProviderInfo::create_openai_provider(Some(CHATGPT_CODEX_BASE_URL.to_string())),
)
} else {
(
None,
create_oss_provider_with_base_url("https://example.com/v1", WireApi::Responses),
)
};
let model_client = ModelClient::new(
auth_manager,
SessionId::new(),
ThreadId::new(),
/*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(),
provider,
SessionSource::Exec,
/*model_verbosity*/ None,
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
Some(Arc::new(CountingAttestationProvider {
calls: attestation_calls.clone(),
})),
);
(model_client, attestation_calls)
}
#[tokio::test]
async fn websocket_handshake_includes_attestation_for_chatgpt_codex_responses() {
let (model_client, attestation_calls) =
model_client_with_counting_attestation(/*include_attestation*/ true);
let headers = model_client
.build_websocket_headers(/*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 non_chatgpt_codex_endpoints_omit_attestation_generation() {
let (model_client, attestation_calls) =
model_client_with_counting_attestation(/*include_attestation*/ false);
let mut response_headers = http::HeaderMap::new();
if let Some(header_value) = model_client.generate_attestation_header_for().await {
response_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);
}
let mut compaction_headers = http::HeaderMap::new();
if let Some(header_value) = model_client.generate_attestation_header_for().await {
compaction_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);
}
let mut realtime_headers = http::HeaderMap::new();
if let Some(header_value) = model_client.generate_attestation_header_for().await {
realtime_headers.insert(crate::attestation::X_OAI_ATTESTATION_HEADER, header_value);
}
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);
}
+1
View File
@@ -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??;
+4
View File
@@ -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,9 @@ mod tasks;
mod user_shell_command;
pub mod util;
pub use attestation::AttestationContext;
pub use attestation::AttestationProvider;
pub use attestation::GenerateAttestationFuture;
pub use client::ModelClient;
pub use client::ModelClientSession;
pub use client::X_CODEX_INSTALLATION_ID_HEADER;
+1
View File
@@ -53,6 +53,7 @@ pub async fn build_prompt_input(
thread_store,
state_db.clone(),
installation_id,
/*attestation_provider*/ None,
);
let thread = thread_manager.start_thread(config).await?;
+4
View File
@@ -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<AnalyticsEventsClient>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
}
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| {
+3
View File
@@ -370,6 +370,7 @@ impl Session {
analytics_events_client: Option<AnalyticsEventsClient>,
thread_store: Arc<dyn ThreadStore>,
parent_rollout_thread_trace: ThreadTraceContext,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
) -> anyhow::Result<Arc<Self>> {
debug!(
"Configuring session: model={}; provider={:?}",
@@ -852,6 +853,7 @@ impl Session {
state_db: state_db_ctx.clone(),
live_thread: live_thread_init.as_ref().cloned(),
thread_store: Arc::clone(&thread_store),
attestation_provider: attestation_provider.clone(),
model_client: ModelClient::new(
Some(Arc::clone(&auth_manager)),
session_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,
+8
View File
@@ -407,6 +407,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
)
.new_session()
}
@@ -3733,6 +3734,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 +3883,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(),
@@ -3892,6 +3895,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
Session::build_model_client_beta_features_header(config.as_ref()),
/*attestation_provider*/ None,
),
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -4069,6 +4073,7 @@ async fn make_session_with_config_and_rx(
/*state_db*/ None,
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
/*attestation_provider*/ None,
)
.await?;
@@ -4178,6 +4183,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
),
)),
codex_rollout_trace::ThreadTraceContext::disabled(),
/*attestation_provider*/ None,
)
.await?;
@@ -5596,6 +5602,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(),
@@ -5607,6 +5614,7 @@ where
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
Session::build_model_client_beta_features_header(config.as_ref()),
/*attestation_provider*/ None,
),
code_mode_service: crate::tools::code_mode::CodeModeService::new(),
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -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");
+2
View File
@@ -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<StateDbHandle>,
pub(crate) live_thread: Option<LiveThread>,
pub(crate) thread_store: Arc<dyn ThreadStore>,
pub(crate) attestation_provider: Option<Arc<dyn AttestationProvider>>,
/// Session-scoped model client shared across turns.
pub(crate) model_client: ModelClient,
pub(crate) code_mode_service: CodeModeService,
+6
View File
@@ -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;
@@ -248,6 +249,7 @@ pub(crate) struct ThreadManagerState {
mcp_manager: Arc<McpManager>,
skills_watcher: Arc<SkillsWatcher>,
thread_store: Arc<dyn ThreadStore>,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
session_source: SessionSource,
installation_id: String,
analytics_events_client: Option<AnalyticsEventsClient>,
@@ -291,6 +293,7 @@ impl ThreadManager {
thread_store: Arc<dyn ThreadStore>,
state_db: Option<StateDbHandle>,
installation_id: String,
attestation_provider: Option<Arc<dyn AttestationProvider>>,
) -> Self {
let codex_home = config.codex_home.clone();
let restriction_product = session_source.restriction_product();
@@ -317,6 +320,7 @@ impl ThreadManager {
mcp_manager,
skills_watcher,
thread_store,
attestation_provider,
auth_manager,
session_source,
installation_id,
@@ -418,6 +422,7 @@ impl ThreadManager {
mcp_manager,
skills_watcher,
thread_store,
attestation_provider: None,
auth_manager,
session_source: SessionSource::Exec,
installation_id,
@@ -1204,6 +1209,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
+11
View File
@@ -495,6 +495,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let selected_cwd =
AbsolutePathBuf::try_from(config.cwd.as_path().join("selected")).expect("absolute path");
@@ -611,6 +612,7 @@ async fn explicit_installation_id_skips_codex_home_file() {
thread_store,
state_db.clone(),
installation_id.clone(),
/*attestation_provider*/ None,
);
let thread = manager
@@ -648,6 +650,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -703,6 +706,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -765,6 +769,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
thread_store,
state_db.clone(),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -853,6 +858,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
thread_store.clone(),
state_db,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -954,6 +960,7 @@ async fn new_uses_active_provider_for_model_refresh() {
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let _ = manager.list_models(RefreshStrategy::Online).await;
@@ -1168,6 +1175,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -1274,6 +1282,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -1369,6 +1378,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -1510,6 +1520,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> {
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
TEST_INSTALLATION_ID.to_string(),
/*attestation_provider*/ None,
);
let source = manager
@@ -3167,6 +3167,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
"11111111-1111-4111-8111-111111111111".to_string(),
/*attestation_provider*/ None,
);
let parent = manager
+1
View File
@@ -435,6 +435,7 @@ impl TestCodexBuilder {
thread_store,
state_db.clone(),
installation_id,
/*attestation_provider*/ None,
);
let thread_manager = Arc::new(thread_manager);
let user_shell_override = self.user_shell_override.clone();
+3
View File
@@ -109,6 +109,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = client.new_session();
@@ -236,6 +237,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = client.new_session();
@@ -352,6 +354,7 @@ async fn responses_respects_model_info_overrides_from_config() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = client.new_session();
+3
View File
@@ -907,6 +907,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = client.new_session();
let mut prompt = Prompt::default();
@@ -1140,6 +1141,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
installation_id,
/*attestation_provider*/ None,
);
let NewThread { thread: codex, .. } = thread_manager
.start_thread(config.clone())
@@ -2327,6 +2329,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
/*enable_request_compression*/ false,
/*include_timing_metrics*/ false,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = client.new_session();
@@ -2035,6 +2035,7 @@ async fn websocket_harness_with_provider_options(
/*enable_request_compression*/ false,
runtime_metrics_enabled,
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
WebsocketTestHarness {
+1
View File
@@ -103,6 +103,7 @@ impl AppServerClient {
},
capabilities: Some(InitializeCapabilities {
experimental_api: true,
request_attestation: false,
opt_out_notification_methods: None,
}),
},
+9
View File
@@ -1606,6 +1606,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,
@@ -72,6 +72,7 @@ impl MessageProcessor {
thread_store_from_config(config.as_ref(), state_db.clone()),
state_db.clone(),
installation_id,
/*attestation_provider*/ None,
));
Self {
outgoing,
+1
View File
@@ -183,6 +183,7 @@ impl MemoryStartupContext {
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = model_client.new_session();
+2 -1
View File
@@ -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"
};
+12
View File
@@ -85,6 +85,11 @@ pub trait ModelProvider: fmt::Debug + Send + Sync {
ProviderCapabilities::default()
}
/// Returns whether requests made through this provider should include attestation.
fn supports_attestation(&self) -> bool {
false
}
/// Returns the provider-scoped auth manager, when this provider uses one.
///
/// TODO(celia-oai): Make auth manager access internal to this crate so callers
@@ -167,6 +172,13 @@ impl ModelProvider for ConfiguredModelProvider {
self.auth_manager.clone()
}
fn supports_attestation(&self) -> bool {
self.auth_manager
.as_ref()
.and_then(|auth_manager| auth_manager.auth_cached())
.is_some_and(|auth| auth.is_chatgpt_auth())
}
async fn auth(&self) -> Option<CodexAuth> {
match self.auth_manager.as_ref() {
Some(auth_manager) => auth_manager.auth().await,
@@ -126,6 +126,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
Arc::clone(&thread_store),
state_db,
installation_id,
/*attestation_provider*/ None,
);
let NewThread {
@@ -25,6 +25,7 @@ pub(super) fn server_request_thread_id(request: &ServerRequest) -> Option<Thread
ThreadId::from_string(&params.thread_id).ok()
}
ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => None,
}
@@ -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,
}
+1
View File
@@ -92,6 +92,7 @@ impl SideParentStatus {
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => Some(SideParentStatus::NeedsApproval),
ServerRequest::DynamicToolCall { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
}
}
+1
View File
@@ -6207,6 +6207,7 @@ impl ChatWidget {
self.on_request_user_input(params);
}
ServerRequest::DynamicToolCall { .. }
| ServerRequest::AttestationGenerate { .. }
| ServerRequest::ChatgptAuthTokensRefresh { .. }
| ServerRequest::ApplyPatchApproval { .. }
| ServerRequest::ExecCommandApproval { .. } => {