Files
codex/codex-rs/memories/write/src/runtime.rs
T
5f4d0ec343 [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>
2026-05-08 12:36:02 -07:00

298 lines
10 KiB
Rust

use codex_core::CodexThread;
use codex_core::ModelClient;
use codex_core::NewThread;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::content_items_to_text;
use codex_core::resolve_installation_id;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use codex_rollout_trace::InferenceTraceContext;
use codex_state::StateRuntime;
use codex_terminal_detection::user_agent;
use futures::StreamExt;
use std::sync::Arc;
use std::time::Duration;
pub(crate) struct SpawnedConsolidationAgent {
pub(crate) thread_id: ThreadId,
pub(crate) thread: Arc<CodexThread>,
}
#[derive(Clone, Debug)]
pub(crate) struct StageOneRequestContext {
pub(crate) model_info: ModelInfo,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) reasoning_summary: ReasoningSummary,
pub(crate) service_tier: Option<String>,
pub(crate) turn_metadata_header: Option<String>,
}
impl StageOneRequestContext {
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
}
pub(crate) struct MemoryStartupContext {
thread_id: ThreadId,
thread: Arc<CodexThread>,
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
session_telemetry: SessionTelemetry,
}
impl MemoryStartupContext {
pub(crate) fn new(
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
thread_id: ThreadId,
thread: Arc<CodexThread>,
config: &Config,
source: SessionSource,
) -> Self {
let auth = auth_manager.auth_cached();
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let account_id = auth.and_then(CodexAuth::get_account_id);
let account_email = auth.and_then(CodexAuth::get_account_email);
let model = config.model.as_deref().unwrap_or("unknown");
let auth_env_telemetry = collect_auth_env_telemetry(
&config.model_provider,
auth_manager.codex_api_key_env_enabled(),
);
let session_telemetry = SessionTelemetry::new(
thread_id,
model,
model,
account_id,
account_email,
auth_mode,
originator().value,
config.otel.log_user_prompt,
user_agent(),
source,
)
.with_auth_env(auth_env_telemetry.to_otel_metadata());
Self {
thread_id,
thread,
thread_manager,
auth_manager,
session_telemetry,
}
}
pub(crate) fn thread_id(&self) -> ThreadId {
self.thread_id
}
pub(crate) fn state_db(&self) -> Option<Arc<StateRuntime>> {
self.thread.state_db()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) async fn stage_one_request_context(
&self,
config: &Config,
model_name: &str,
reasoning_effort: ReasoningEffort,
) -> StageOneRequestContext {
let config_snapshot = self.thread.config_snapshot().await;
let model_info = self
.thread_manager
.get_models_manager()
.get_model_info(model_name, &config.to_models_manager_config())
.await;
let turn_metadata_header =
codex_core::build_turn_metadata_header(&config.cwd, /*sandbox*/ None).await;
let reasoning_summary = config
.model_reasoning_summary
.unwrap_or(model_info.default_reasoning_summary);
StageOneRequestContext {
model_info,
turn_metadata_header,
session_telemetry: self
.session_telemetry
.clone()
.with_model(model_name, model_name),
reasoning_effort: Some(reasoning_effort),
reasoning_summary,
service_tier: config_snapshot.service_tier,
}
}
pub(crate) async fn stream_stage_one_prompt(
&self,
config: &Config,
prompt: &Prompt,
context: &StageOneRequestContext,
) -> anyhow::Result<(String, Option<TokenUsage>)> {
let installation_id = resolve_installation_id(&config.codex_home).await?;
let session_source = self.thread.config_snapshot().await.session_source;
let model_client = ModelClient::new(
Some(Arc::clone(&self.auth_manager)),
SessionId::from(self.thread_id), // We use thread_id to detach this query from the foreground user session.
self.thread_id,
installation_id,
config.model_provider.clone(),
session_source,
config.model_verbosity,
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();
let mut stream = client_session
.stream(
prompt,
&context.model_info,
&context.session_telemetry,
context.reasoning_effort,
context.reasoning_summary,
context.service_tier.clone(),
context.turn_metadata_header.as_deref(),
&InferenceTraceContext::disabled(),
)
.await?;
let mut result = String::new();
let mut token_usage = None;
while let Some(message) = stream.next().await.transpose()? {
match message {
ResponseEvent::OutputTextDelta(delta) => result.push_str(&delta),
ResponseEvent::OutputItemDone(item) => {
if result.is_empty()
&& let codex_protocol::models::ResponseItem::Message { content, .. } = item
&& let Some(text) = content_items_to_text(&content)
{
result.push_str(&text);
}
}
ResponseEvent::Completed {
token_usage: usage, ..
} => {
token_usage = usage;
break;
}
_ => {}
}
}
Ok((result, token_usage))
}
pub(crate) async fn spawn_consolidation_agent(
&self,
config: Config,
prompt: Vec<UserInput>,
) -> anyhow::Result<SpawnedConsolidationAgent> {
let environments = self
.thread_manager
.default_environment_selections(&config.cwd);
let NewThread {
thread_id, thread, ..
} = self
.thread_manager
.start_thread_with_options(StartThreadOptions {
config,
initial_history: InitialHistory::New,
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
thread_source: Some(ThreadSource::MemoryConsolidation),
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
parent_trace: None,
environments,
})
.await?;
let agent = SpawnedConsolidationAgent { thread_id, thread };
if let Err(err) = agent
.thread
.submit(Op::UserInput {
items: prompt,
environments: None,
final_output_json_schema: None,
responsesapi_client_metadata: None,
})
.await
{
if let Err(shutdown_err) = self.shutdown_consolidation_agent(agent).await {
tracing::warn!(
"failed to shut down consolidation agent after submit error: {shutdown_err}"
);
}
return Err(err.into());
}
Ok(agent)
}
pub(crate) async fn shutdown_consolidation_agent(
&self,
agent: SpawnedConsolidationAgent,
) -> anyhow::Result<()> {
let SpawnedConsolidationAgent { thread_id, thread } = agent;
let thread = self
.thread_manager
.remove_thread(&thread_id)
.await
.unwrap_or(thread);
tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait())
.await
.map_err(|_| {
anyhow::anyhow!("memory consolidation agent {thread_id} shutdown timed out")
})??;
Ok(())
}
}