diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index a2a2c1d4788..6a88cd1f479 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -169,6 +169,8 @@ pub struct ResponsesApiRequest { pub prompt_cache_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_metadata: Option>, } impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { @@ -189,7 +191,7 @@ impl From<&ResponsesApiRequest> for ResponseCreateWsRequest { prompt_cache_key: request.prompt_cache_key.clone(), text: request.text.clone(), generate: None, - client_metadata: None, + client_metadata: request.client_metadata.clone(), } } } diff --git a/codex-rs/codex-api/tests/clients.rs b/codex-rs/codex-api/tests/clients.rs index 3c22c012034..2e8411dd81d 100644 --- a/codex-rs/codex-api/tests/clients.rs +++ b/codex-rs/codex-api/tests/clients.rs @@ -278,6 +278,7 @@ async fn streaming_client_retries_on_transport_error() -> Result<()> { service_tier: None, prompt_cache_key: None, text: None, + client_metadata: None, }; let client = ResponsesClient::new(transport.clone(), provider, NoAuth); @@ -320,6 +321,7 @@ async fn azure_default_store_attaches_ids_and_headers() -> Result<()> { service_tier: None, prompt_cache_key: None, text: None, + client_metadata: None, }; let mut extra_headers = HeaderMap::new(); diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index e11e4362ca5..80ad24be5b5 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -118,6 +118,7 @@ use codex_response_debug_context::telemetry_api_error_message; use codex_response_debug_context::telemetry_transport_error_message; pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; +pub const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state"; pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata"; pub const X_CODEX_PARENT_THREAD_ID_HEADER: &str = "x-codex-parent-thread-id"; @@ -142,6 +143,7 @@ struct ModelClientState { auth_manager: Option>, conversation_id: ThreadId, window_generation: AtomicU64, + installation_id: String, provider: ModelProviderInfo, auth_env_telemetry: AuthEnvTelemetry, session_source: SessionSource, @@ -263,6 +265,7 @@ impl ModelClient { pub fn new( auth_manager: Option>, conversation_id: ThreadId, + installation_id: String, provider: ModelProviderInfo, session_source: SessionSource, model_verbosity: Option, @@ -280,6 +283,7 @@ impl ModelClient { auth_manager, conversation_id, window_generation: AtomicU64::new(0), + installation_id, provider, auth_env_telemetry, session_source, @@ -429,7 +433,11 @@ impl ModelClient { text, }; - let mut extra_headers = self.build_responses_identity_headers(); + let mut extra_headers = ApiHeaderMap::new(); + if let Ok(header_value) = HeaderValue::from_str(&self.state.installation_id) { + extra_headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value); + } + extra_headers.extend(self.build_responses_identity_headers()); extra_headers.extend(build_conversation_headers(Some( self.state.conversation_id.to_string(), ))); @@ -515,6 +523,10 @@ impl ModelClient { turn_metadata_header: Option<&str>, ) -> HashMap { let mut client_metadata = HashMap::new(); + client_metadata.insert( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.state.installation_id.clone(), + ); client_metadata.insert( X_CODEX_WINDOW_ID_HEADER.to_string(), self.current_window_id(), @@ -817,6 +829,10 @@ impl ModelClientSession { }, prompt_cache_key, text, + client_metadata: Some(HashMap::from([( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + self.client.state.installation_id.clone(), + )])), }; Ok(request) } diff --git a/codex-rs/core/src/client_common_tests.rs b/codex-rs/core/src/client_common_tests.rs index 3d804cda839..4d4b5af4ff5 100644 --- a/codex-rs/core/src/client_common_tests.rs +++ b/codex-rs/core/src/client_common_tests.rs @@ -29,6 +29,7 @@ fn serializes_text_verbosity_when_set() { verbosity: Some(OpenAiVerbosity::Low), format: None, }), + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -69,6 +70,7 @@ fn serializes_text_schema_with_strict_format() { prompt_cache_key: None, service_tier: None, text: Some(text_controls), + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -106,6 +108,7 @@ fn omits_text_when_not_set() { prompt_cache_key: None, service_tier: None, text: None, + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); @@ -128,6 +131,7 @@ fn serializes_flex_service_tier_when_set() { prompt_cache_key: None, service_tier: Some(ServiceTier::Flex.to_string()), text: None, + client_metadata: None, }; let v = serde_json::to_value(&req).expect("json"); diff --git a/codex-rs/core/src/client_tests.rs b/codex-rs/core/src/client_tests.rs index 02d74224751..2fd5f04f951 100644 --- a/codex-rs/core/src/client_tests.rs +++ b/codex-rs/core/src/client_tests.rs @@ -2,6 +2,7 @@ use super::AuthRequestTelemetryContext; use super::ModelClient; use super::PendingUnauthorizedRetry; use super::UnauthorizedRecoveryExecution; +use super::X_CODEX_INSTALLATION_ID_HEADER; use super::X_CODEX_PARENT_THREAD_ID_HEADER; use super::X_CODEX_TURN_METADATA_HEADER; use super::X_CODEX_WINDOW_ID_HEADER; @@ -23,6 +24,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient { ModelClient::new( /*auth_manager*/ None, ThreadId::new(), + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, session_source, /*model_verbosity*/ None, @@ -107,6 +109,10 @@ fn build_ws_client_metadata_includes_window_lineage_and_turn_metadata() { assert_eq!( client_metadata, std::collections::HashMap::from([ + ( + X_CODEX_INSTALLATION_ID_HEADER.to_string(), + "11111111-1111-4111-8111-111111111111".to_string(), + ), ( X_CODEX_WINDOW_ID_HEADER.to_string(), format!("{conversation_id}:1"), diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 822dbefa5cb..e4d3006a9a9 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -24,6 +24,7 @@ use crate::compact_remote::run_inline_remote_auto_compact_task; use crate::config::ManagedFeatures; use crate::connectors; use crate::exec_policy::ExecPolicyManager; +use crate::installation_id::resolve_installation_id; use crate::parse_turn_item; use crate::path_utils::normalize_for_native_workdir; use crate::realtime_conversation::RealtimeConversationManager; @@ -1911,6 +1912,7 @@ impl Session { }); } + let installation_id = resolve_installation_id(&config.codex_home).await?; let services = SessionServices { // Initialize the MCP connection manager with an uninitialized // instance. It will be replaced with one created via @@ -1954,6 +1956,7 @@ impl Session { model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), conversation_id, + installation_id, session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, diff --git a/codex-rs/core/src/codex_tests.rs b/codex-rs/core/src/codex_tests.rs index ef9a69d3478..e096fd6fcd1 100644 --- a/codex-rs/core/src/codex_tests.rs +++ b/codex-rs/core/src/codex_tests.rs @@ -255,6 +255,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession { /*auth_manager*/ None, ThreadId::try_from("00000000-0000-4000-8000-000000000001") .expect("test thread id should be valid"), + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), ModelProviderInfo::create_openai_provider(/* base_url */ /*base_url*/ None), codex_protocol::protocol::SessionSource::Exec, /*model_verbosity*/ None, @@ -2766,6 +2767,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { model_client: ModelClient::new( Some(auth_manager.clone()), conversation_id, + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, @@ -3606,6 +3608,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx( model_client: ModelClient::new( Some(Arc::clone(&auth_manager)), conversation_id, + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), session_configuration.provider.clone(), session_configuration.session_source.clone(), config.model_verbosity, diff --git a/codex-rs/core/src/installation_id.rs b/codex-rs/core/src/installation_id.rs new file mode 100644 index 00000000000..940e17eb01f --- /dev/null +++ b/codex-rs/core/src/installation_id.rs @@ -0,0 +1,145 @@ +use std::fs::OpenOptions; +use std::io::Read; +use std::io::Result; +use std::io::Seek; +use std::io::SeekFrom; +use std::io::Write; +use std::path::Path; + +#[cfg(unix)] +use std::os::unix::fs::OpenOptionsExt; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +use tokio::fs; +use uuid::Uuid; + +pub(crate) const INSTALLATION_ID_FILENAME: &str = "installation_id"; + +pub(crate) async fn resolve_installation_id(codex_home: &Path) -> Result { + let path = codex_home.join(INSTALLATION_ID_FILENAME); + fs::create_dir_all(codex_home).await?; + tokio::task::spawn_blocking(move || { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + + #[cfg(unix)] + { + options.mode(0o644); + } + + let mut file = options.open(&path)?; + file.lock()?; + + #[cfg(unix)] + { + let metadata = file.metadata()?; + let current_mode = metadata.permissions().mode() & 0o777; + if current_mode != 0o644 { + let mut permissions = metadata.permissions(); + permissions.set_mode(0o644); + file.set_permissions(permissions)?; + } + } + + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + let trimmed = contents.trim(); + if !trimmed.is_empty() + && let Ok(existing) = Uuid::parse_str(trimmed) + { + return Ok(existing.to_string()); + } + + let installation_id = Uuid::new_v4().to_string(); + file.set_len(0)?; + file.seek(SeekFrom::Start(0))?; + file.write_all(installation_id.as_bytes())?; + file.flush()?; + file.sync_all()?; + + Ok(installation_id) + }) + .await? +} + +#[cfg(test)] +mod tests { + use super::INSTALLATION_ID_FILENAME; + use super::resolve_installation_id; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use uuid::Uuid; + + #[cfg(unix)] + use std::os::unix::fs::PermissionsExt; + + #[tokio::test] + async fn resolve_installation_id_generates_and_persists_uuid() { + let codex_home = TempDir::new().expect("create temp dir"); + let persisted_path = codex_home.path().join(INSTALLATION_ID_FILENAME); + + let installation_id = resolve_installation_id(codex_home.path()) + .await + .expect("resolve installation id"); + + assert_eq!( + std::fs::read_to_string(&persisted_path).expect("read persisted installation id"), + installation_id + ); + assert!(Uuid::parse_str(&installation_id).is_ok()); + + #[cfg(unix)] + { + let mode = std::fs::metadata(&persisted_path) + .expect("read installation id metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o644); + } + } + + #[tokio::test] + async fn resolve_installation_id_reuses_existing_uuid() { + let codex_home = TempDir::new().expect("create temp dir"); + let existing = Uuid::new_v4().to_string().to_uppercase(); + std::fs::write( + codex_home.path().join(INSTALLATION_ID_FILENAME), + existing.clone(), + ) + .expect("write installation id"); + + let resolved = resolve_installation_id(codex_home.path()) + .await + .expect("resolve installation id"); + + assert_eq!( + resolved, + Uuid::parse_str(existing.as_str()) + .expect("parse existing installation id") + .to_string() + ); + } + + #[tokio::test] + async fn resolve_installation_id_rewrites_invalid_file_contents() { + let codex_home = TempDir::new().expect("create temp dir"); + std::fs::write( + codex_home.path().join(INSTALLATION_ID_FILENAME), + "not-a-uuid", + ) + .expect("write invalid installation id"); + + let resolved = resolve_installation_id(codex_home.path()) + .await + .expect("resolve installation id"); + + assert!(Uuid::parse_str(&resolved).is_ok()); + assert_eq!( + std::fs::read_to_string(codex_home.path().join(INSTALLATION_ID_FILENAME)) + .expect("read rewritten installation id"), + resolved + ); + } +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index e13a19b290f..83232de734f 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -38,6 +38,7 @@ mod flags; mod git_info_tests; mod guardian; mod hook_runtime; +mod installation_id; pub(crate) mod instructions; pub(crate) mod landlock; pub use landlock::spawn_command_under_linux_sandbox; @@ -179,6 +180,7 @@ pub mod util; pub use client::ModelClient; pub use client::ModelClientSession; +pub use client::X_CODEX_INSTALLATION_ID_HEADER; pub use client::X_CODEX_TURN_METADATA_HEADER; pub use client_common::Prompt; pub use client_common::REVIEW_PROMPT; diff --git a/codex-rs/core/tests/responses_headers.rs b/codex-rs/core/tests/responses_headers.rs index 7a6e73ed082..03870beab2d 100644 --- a/codex-rs/core/tests/responses_headers.rs +++ b/codex-rs/core/tests/responses_headers.rs @@ -31,6 +31,8 @@ fn normalize_git_remote_url(url: &str) -> String { .to_string() } +const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; + #[tokio::test] async fn responses_stream_includes_subagent_header_on_review() { core_test_support::skip_if_no_network!(); @@ -98,6 +100,7 @@ async fn responses_stream_includes_subagent_header_on_review() { let client = ModelClient::new( /*auth_manager*/ None, conversation_id, + /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, config.model_verbosity, @@ -147,6 +150,10 @@ async fn responses_stream_includes_subagent_header_on_review() { Some(expected_window_id.as_str()) ); assert_eq!(request.header("x-codex-parent-thread-id"), None); + assert_eq!( + request.body_json()["client_metadata"]["x-codex-installation-id"].as_str(), + Some(TEST_INSTALLATION_ID) + ); assert_eq!(request.header("x-codex-sandbox"), None); } @@ -218,6 +225,7 @@ async fn responses_stream_includes_subagent_header_on_other() { let client = ModelClient::new( /*auth_manager*/ None, conversation_id, + /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, config.model_verbosity, @@ -331,6 +339,7 @@ async fn responses_respects_model_info_overrides_from_config() { let client = ModelClient::new( /*auth_manager*/ None, conversation_id, + /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), session_source, config.model_verbosity, diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index cb97e4a3b31..15303a58ffa 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -81,6 +81,8 @@ use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; +const INSTALLATION_ID_FILENAME: &str = "installation_id"; + #[expect(clippy::unwrap_used)] fn assert_message_role(request_body: &serde_json::Value, role: &str) { assert_eq!(request_body["role"].as_str().unwrap(), role); @@ -760,10 +762,18 @@ async fn includes_conversation_id_and_model_headers_in_request() { .header("authorization") .expect("authorization header"); let request_originator = request.header("originator").expect("originator header"); + let request_body = request.body_json(); + let installation_id = + std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) + .expect("read installation id"); assert_eq!(request_session_id, session_id.to_string()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Test API Key"); + assert_eq!( + request_body["client_metadata"]["x-codex-installation-id"].as_str(), + Some(installation_id.as_str()) + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -868,6 +878,7 @@ async fn send_provider_auth_request(server: &MockServer, auth: ModelProviderAuth "unused-api-key", ))), conversation_id, + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider, SessionSource::Exec, config.model_verbosity, @@ -1048,11 +1059,18 @@ async fn chatgpt_auth_sends_correct_request() { let request_body = request.body_json(); let session_id = request.header("session_id").expect("session_id header"); + let installation_id = + std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME)) + .expect("read installation id"); assert_eq!(session_id, thread_id.to_string()); assert_eq!(request_originator, originator().value); assert_eq!(request_authorization, "Bearer Access Token"); assert_eq!(request_chatgpt_account_id, "account_id"); + assert_eq!( + request_body["client_metadata"]["x-codex-installation-id"].as_str(), + Some(installation_id.as_str()) + ); assert!(request_body["stream"].as_bool().unwrap()); assert_eq!( request_body["include"][0].as_str().unwrap(), @@ -2179,6 +2197,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { let client = ModelClient::new( /*auth_manager*/ None, conversation_id, + /*installation_id*/ "11111111-1111-4111-8111-111111111111".to_string(), provider.clone(), SessionSource::Exec, config.model_verbosity, diff --git a/codex-rs/core/tests/suite/client_websockets.rs b/codex-rs/core/tests/suite/client_websockets.rs index 8bf45a23dde..7ff4a9f88d9 100755 --- a/codex-rs/core/tests/suite/client_websockets.rs +++ b/codex-rs/core/tests/suite/client_websockets.rs @@ -55,6 +55,7 @@ const MODEL: &str = "gpt-5.2-codex"; const OPENAI_BETA_HEADER: &str = "OpenAI-Beta"; const WS_V2_BETA_HEADER_VALUE: &str = "responses_websockets=2026-02-06"; const X_CLIENT_REQUEST_ID_HEADER: &str = "x-client-request-id"; +const TEST_INSTALLATION_ID: &str = "11111111-1111-4111-8111-111111111111"; fn assert_request_trace_matches(body: &serde_json::Value, expected_trace: &W3cTraceContext) { let client_metadata = body["client_metadata"] @@ -125,6 +126,10 @@ async fn responses_websocket_streams_request() { handshake.header(X_CLIENT_REQUEST_ID_HEADER), Some(harness.conversation_id.to_string()) ); + assert_eq!( + body["client_metadata"]["x-codex-installation-id"].as_str(), + Some(TEST_INSTALLATION_ID) + ); server.shutdown().await; } @@ -1756,6 +1761,7 @@ async fn websocket_harness_with_provider_options( let client = ModelClient::new( /*auth_manager*/ None, conversation_id, + /*installation_id*/ TEST_INSTALLATION_ID.to_string(), provider.clone(), SessionSource::Exec, config.model_verbosity,