Compare commits

...

1 Commits

Author SHA1 Message Date
Owen Lin
7c50c42959 feat: set installation_id and pass as header 2026-03-27 16:19:47 -07:00
9 changed files with 258 additions and 0 deletions

View File

@@ -113,6 +113,7 @@ use crate::util::emit_feedback_auth_recovery_tags;
use crate::util::emit_feedback_request_tags_with_auth_env;
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_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str =
@@ -133,6 +134,7 @@ pub(crate) const WEBSOCKET_CONNECT_TIMEOUT: Duration =
struct ModelClientState {
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
installation_id: String,
provider: ModelProviderInfo,
auth_env_telemetry: AuthEnvTelemetry,
session_source: SessionSource,
@@ -254,6 +256,7 @@ impl ModelClient {
pub fn new(
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
installation_id: String,
provider: ModelProviderInfo,
session_source: SessionSource,
model_verbosity: Option<VerbosityConfig>,
@@ -269,6 +272,7 @@ impl ModelClient {
state: Arc::new(ModelClientState {
auth_manager,
conversation_id,
installation_id,
provider,
auth_env_telemetry,
session_source,
@@ -393,6 +397,9 @@ impl ModelClient {
};
let mut extra_headers = self.build_subagent_headers();
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(build_conversation_headers(Some(
self.state.conversation_id.to_string(),
)));
@@ -640,6 +647,7 @@ impl ModelClient {
let turn_metadata_header = parse_turn_metadata_header(turn_metadata_header);
let conversation_id = self.state.conversation_id.to_string();
let mut headers = build_responses_headers(
Some(self.state.installation_id.as_str()),
self.state.beta_features_header.as_deref(),
turn_state,
turn_metadata_header.as_ref(),
@@ -763,6 +771,7 @@ impl ModelClientSession {
conversation_id: Some(conversation_id),
session_source: Some(self.client.state.session_source.clone()),
extra_headers: build_responses_headers(
Some(self.client.state.installation_id.as_str()),
self.client.state.beta_features_header.as_deref(),
Some(&self.turn_state),
turn_metadata_header.as_ref(),
@@ -1375,15 +1384,22 @@ fn build_ws_client_metadata(turn_metadata_header: Option<&str>) -> Option<HashMa
///
/// These headers implement Codex-specific conventions:
///
/// - `x-codex-installation-id`: stable identifier persisted under `CODEX_HOME`.
/// - `x-codex-beta-features`: comma-separated beta feature keys enabled for the session.
/// - `x-codex-turn-state`: sticky routing token captured earlier in the turn.
/// - `x-codex-turn-metadata`: optional per-turn metadata for observability.
fn build_responses_headers(
installation_id: Option<&str>,
beta_features_header: Option<&str>,
turn_state: Option<&Arc<OnceLock<String>>>,
turn_metadata_header: Option<&HeaderValue>,
) -> ApiHeaderMap {
let mut headers = ApiHeaderMap::new();
if let Some(value) = installation_id
&& let Ok(header_value) = HeaderValue::from_str(value)
{
headers.insert(X_CODEX_INSTALLATION_ID_HEADER, header_value);
}
if let Some(value) = beta_features_header
&& !value.is_empty()
&& let Ok(header_value) = HeaderValue::from_str(value)

View File

@@ -18,6 +18,7 @@ fn test_model_client(session_source: SessionSource) -> ModelClient {
ModelClient::new(
None,
ThreadId::new(),
"11111111-1111-4111-8111-111111111111".to_string(),
provider,
session_source,
None,

View File

@@ -23,6 +23,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;
#[cfg(test)]
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use crate::models_manager::manager::ModelsManager;
@@ -1845,6 +1846,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
@@ -1888,6 +1890,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,

View File

@@ -250,6 +250,7 @@ fn test_model_client_session() -> crate::client::ModelClientSession {
None,
ThreadId::try_from("00000000-0000-4000-8000-000000000001")
.expect("test thread id should be valid"),
"11111111-1111-4111-8111-111111111111".to_string(),
crate::model_provider_info::ModelProviderInfo::create_openai_provider(
/* base_url */ None,
),
@@ -2676,6 +2677,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
model_client: ModelClient::new(
Some(auth_manager.clone()),
conversation_id,
"11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,
@@ -3513,6 +3515,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,
"11111111-1111-4111-8111-111111111111".to_string(),
session_configuration.provider.clone(),
session_configuration.session_source.clone(),
config.model_verbosity,

View File

@@ -0,0 +1,198 @@
use std::fs::OpenOptions;
use std::io::Result;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
#[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";
enum InstallationIdWriteMode {
CreateNew,
Overwrite,
}
pub(crate) async fn resolve_installation_id(codex_home: &Path) -> Result<String> {
let path = codex_home.join(INSTALLATION_ID_FILENAME);
if let Some(existing) = read_installation_id(&path).await? {
return Ok(existing);
}
fs::create_dir_all(codex_home).await?;
loop {
let installation_id = Uuid::new_v4().to_string();
match write_installation_id(
path.clone(),
installation_id.clone(),
InstallationIdWriteMode::CreateNew,
)
.await
{
Ok(()) => return Ok(installation_id),
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
if let Some(existing) = read_installation_id(&path).await? {
return Ok(existing);
}
write_installation_id(
path.clone(),
installation_id.clone(),
InstallationIdWriteMode::Overwrite,
)
.await?;
return Ok(installation_id);
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
fs::create_dir_all(codex_home).await?;
}
Err(err) => return Err(err),
}
}
}
async fn read_installation_id(path: &Path) -> Result<Option<String>> {
let contents = match fs::read_to_string(path).await {
Ok(contents) => contents,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let trimmed = contents.trim();
if trimmed.is_empty() {
return Ok(None);
}
Ok(Uuid::parse_str(trimmed).ok().map(|uuid| uuid.to_string()))
}
async fn write_installation_id(
path: PathBuf,
installation_id: String,
mode: InstallationIdWriteMode,
) -> Result<()> {
tokio::task::spawn_blocking(move || {
let mut options = OpenOptions::new();
options.write(true);
match mode {
InstallationIdWriteMode::CreateNew => {
options.create_new(true);
}
InstallationIdWriteMode::Overwrite => {
options.create(true).truncate(true);
}
}
#[cfg(unix)]
{
options.mode(0o600);
}
let mut file = options.open(&path)?;
file.write_all(installation_id.as_bytes())?;
file.flush()?;
#[cfg(unix)]
{
let metadata = file.metadata()?;
let current_mode = metadata.permissions().mode() & 0o777;
if current_mode != 0o600 {
let mut permissions = metadata.permissions();
permissions.set_mode(0o600);
file.set_permissions(permissions)?;
}
}
Ok(())
})
.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, 0o600);
}
}
#[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
);
}
}

View File

@@ -44,6 +44,7 @@ mod flags;
mod git_info_tests;
mod guardian;
mod hook_runtime;
mod installation_id;
pub mod instructions;
pub mod landlock;
pub mod mcp;
@@ -195,6 +196,7 @@ pub(crate) use codex_shell_command::powershell;
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;

View File

@@ -7,6 +7,7 @@ use codex_core::ModelProviderInfo;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::WireApi;
use codex_core::X_CODEX_INSTALLATION_ID_HEADER;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId;
@@ -23,6 +24,8 @@ use pretty_assertions::assert_eq;
use tempfile::TempDir;
use wiremock::matchers::header;
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!();
@@ -89,6 +92,7 @@ async fn responses_stream_includes_subagent_header_on_review() {
let client = ModelClient::new(
None,
conversation_id,
TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
config.model_verbosity,
@@ -132,6 +136,10 @@ async fn responses_stream_includes_subagent_header_on_review() {
request.header("x-openai-subagent").as_deref(),
Some("review")
);
assert_eq!(
request.header(X_CODEX_INSTALLATION_ID_HEADER).as_deref(),
Some(TEST_INSTALLATION_ID)
);
assert_eq!(request.header("x-codex-sandbox"), None);
}
@@ -202,6 +210,7 @@ async fn responses_stream_includes_subagent_header_on_other() {
let client = ModelClient::new(
None,
conversation_id,
TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
config.model_verbosity,
@@ -314,6 +323,7 @@ async fn responses_respects_model_info_overrides_from_config() {
let client = ModelClient::new(
None,
conversation_id,
TEST_INSTALLATION_ID.to_string(),
provider.clone(),
session_source,
config.model_verbosity,

View File

@@ -6,6 +6,7 @@ use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::ThreadManager;
use codex_core::WireApi;
use codex_core::X_CODEX_INSTALLATION_ID_HEADER;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::built_in_model_providers;
use codex_core::default_client::originator;
@@ -76,6 +77,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);
@@ -653,10 +656,17 @@ 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 installation_id =
std::fs::read_to_string(test.codex_home_path().join(INSTALLATION_ID_FILENAME))
.expect("read installation id");
let request_installation_id = request
.header(X_CODEX_INSTALLATION_ID_HEADER)
.expect("installation id header");
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_installation_id, installation_id);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
@@ -758,11 +768,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");
let request_installation_id = request
.header(X_CODEX_INSTALLATION_ID_HEADER)
.expect("installation id header");
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_installation_id, installation_id);
assert!(request_body["stream"].as_bool().unwrap());
assert_eq!(
request_body["include"][0].as_str().unwrap(),
@@ -1837,6 +1854,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() {
let client = ModelClient::new(
None,
conversation_id,
"11111111-1111-4111-8111-111111111111".to_string(),
provider.clone(),
SessionSource::Exec,
config.model_verbosity,

View File

@@ -8,6 +8,7 @@ use codex_core::ModelProviderInfo;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::WireApi;
use codex_core::X_CODEX_INSTALLATION_ID_HEADER;
use codex_core::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
use codex_features::Feature;
use codex_otel::SessionTelemetry;
@@ -55,6 +56,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 +127,10 @@ async fn responses_websocket_streams_request() {
handshake.header(X_CLIENT_REQUEST_ID_HEADER),
Some(harness.conversation_id.to_string())
);
assert_eq!(
handshake.header(X_CODEX_INSTALLATION_ID_HEADER),
Some(TEST_INSTALLATION_ID.to_string())
);
server.shutdown().await;
}
@@ -1753,6 +1759,7 @@ async fn websocket_harness_with_provider_options(
let client = ModelClient::new(
None,
conversation_id,
TEST_INSTALLATION_ID.to_string(),
provider.clone(),
SessionSource::Exec,
config.model_verbosity,