diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index f20071b1e4..3b49804fcc 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -70,6 +70,7 @@ use codex_feedback::CodexFeedback; use codex_goal_extension::GoalService; use codex_home::CodexHomeUserInstructionsProvider; use codex_login::AuthManager; +use codex_login::default_client::CodexClientIdentity; use codex_protocol::ThreadId; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::W3cTraceContext; @@ -1316,13 +1317,11 @@ impl MessageProcessor { .await } ClientRequest::LoginAccount { params, .. } => { + let client_identity = app_server_client_name + .zip(client_version) + .map(|(name, version)| CodexClientIdentity::new(name, version)); self.account_processor - .login_account( - request_id.clone(), - params, - app_server_client_name, - client_version, - ) + .login_account(request_id.clone(), params, client_identity) .await } ClientRequest::LogoutAccount { .. } => { diff --git a/codex-rs/app-server/src/request_processors/account_processor.rs b/codex-rs/app-server/src/request_processors/account_processor.rs index 2dcbf09a9a..d9419bdf59 100644 --- a/codex-rs/app-server/src/request_processors/account_processor.rs +++ b/codex-rs/app-server/src/request_processors/account_processor.rs @@ -2,6 +2,8 @@ use super::*; use crate::auth_mode::auth_mode_to_api; use crate::external_auth::ExternalAuthBridge; use chrono::DateTime; +use codex_login::DeviceAuthMetadata; +use codex_login::default_client::CodexClientIdentity; mod rate_limit_resets; @@ -98,10 +100,9 @@ impl AccountRequestProcessor { &self, request_id: ConnectionRequestId, params: LoginAccountParams, - app_server_client_name: Option, - client_version: Option, + client_identity: Option, ) -> Result, JSONRPCErrorError> { - self.login_v2(request_id, params, app_server_client_name, client_version) + self.login_v2(request_id, params, client_identity) .await .map(|()| None) } @@ -256,8 +257,7 @@ impl AccountRequestProcessor { &self, request_id: ConnectionRequestId, params: LoginAccountParams, - app_server_client_name: Option, - client_version: Option, + client_identity: Option, ) -> Result<(), JSONRPCErrorError> { match params { LoginAccountParams::ApiKey { api_key } => { @@ -287,12 +287,8 @@ impl AccountRequestProcessor { .await; } LoginAccountParams::ChatgptDeviceCode => { - self.login_chatgpt_device_code_v2( - request_id, - app_server_client_name, - client_version, - ) - .await; + self.login_chatgpt_device_code_v2(request_id, client_identity) + .await; } LoginAccountParams::ChatgptAuthTokens { access_token, @@ -392,7 +388,10 @@ impl AccountRequestProcessor { open_browser: false, codex_streamlined_login, login_success_page, - device_auth_installation_id: Some(self.installation_id.clone()), + device_auth_metadata: DeviceAuthMetadata { + installation_id: Some(self.installation_id.clone()), + ..Default::default() + }, ..LoginServerOptions::new( config.codex_home.to_path_buf(), oauth_client_id(), @@ -409,7 +408,7 @@ impl AccountRequestProcessor { && !issuer.trim().is_empty() { opts.issuer = issuer; - opts.device_auth_installation_id = None; + opts.device_auth_metadata.installation_id = None; } if let LoginSuccessPage::Hosted { url, .. } = &mut opts.login_success_page && let Ok(open_app_url) = std::env::var(LOGIN_OPEN_APP_URL_OVERRIDE_ENV_VAR) @@ -519,19 +518,17 @@ impl AccountRequestProcessor { async fn login_chatgpt_device_code_v2( &self, request_id: ConnectionRequestId, - app_server_client_name: Option, - client_version: Option, + client_identity: Option, ) { let result = self - .login_chatgpt_device_code_response(app_server_client_name, client_version) + .login_chatgpt_device_code_response(client_identity) .await; self.outgoing.send_result(request_id, result).await; } async fn login_chatgpt_device_code_response( &self, - app_server_client_name: Option, - client_version: Option, + client_identity: Option, ) -> Result { let mut opts = self .login_chatgpt_common( @@ -539,12 +536,7 @@ impl AccountRequestProcessor { LoginSuccessPage::default(), ) .await?; - if let (Some(client_name), Some(client_version)) = (app_server_client_name, client_version) - { - opts.device_auth_client_identity = Some( - codex_login::default_client::CodexClientIdentity::new(client_name, client_version), - ); - } + opts.device_auth_metadata.client_identity = client_identity; let device_code = request_device_code(&opts) .await .map_err(Self::login_chatgpt_device_code_start_error)?; diff --git a/codex-rs/app-server/src/request_processors/initialize_processor.rs b/codex-rs/app-server/src/request_processors/initialize_processor.rs index f17f66dbea..cfdad27f50 100644 --- a/codex-rs/app-server/src/request_processors/initialize_processor.rs +++ b/codex-rs/app-server/src/request_processors/initialize_processor.rs @@ -3,7 +3,6 @@ use std::sync::atomic::Ordering; use axum::http::HeaderValue; use codex_analytics::AppServerRpcTransport; -use codex_login::default_client::CodexClientIdentity; use codex_login::default_client::SetOriginatorError; use codex_login::default_client::USER_AGENT_SUFFIX; use codex_login::default_client::get_codex_user_agent; @@ -87,9 +86,8 @@ impl InitializeRequestProcessor { "Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value." ))); } - let client_identity = CodexClientIdentity::new(name.clone(), version.clone()); - let originator = client_identity.originator().to_string(); - let user_agent_suffix = client_identity.user_agent_suffix().to_string(); + let originator = name.clone(); + let user_agent_suffix = format!("{name}; {version}"); let mutates_global_identity = !NON_ORIGINATING_CLIENT_NAMES.contains(&name.as_str()); let codex_home = self.config.codex_home.clone(); if session diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index b67aee0275..516e2a268f 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -302,6 +302,22 @@ fn read_stdin_secret(terminal_message: &str, reading_message: &str, empty_messag secret } +async fn resolve_device_auth_installation_id( + config: &Config, + issuer_base_url: Option<&str>, +) -> Option { + if issuer_base_url.is_some() { + return None; + } + match codex_core::resolve_installation_id(&config.codex_home).await { + Ok(installation_id) => Some(installation_id), + Err(err) => { + eprintln!("Error resolving installation ID: {err}"); + std::process::exit(1); + } + } +} + /// Login using the OAuth device code flow. pub async fn run_login_with_device_code( cli_config_overrides: CliConfigOverrides, @@ -316,17 +332,8 @@ pub async fn run_login_with_device_code( std::process::exit(1); } let auth_route_config = config.auth_route_config(); - let installation_id = if issuer_base_url.is_none() { - match codex_core::resolve_installation_id(&config.codex_home).await { - Ok(installation_id) => Some(installation_id), - Err(err) => { - eprintln!("Error resolving installation ID: {err}"); - std::process::exit(1); - } - } - } else { - None - }; + let installation_id = + resolve_device_auth_installation_id(&config, issuer_base_url.as_deref()).await; clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, @@ -346,7 +353,7 @@ pub async fn run_login_with_device_code( if let Some(iss) = issuer_base_url { opts.issuer = iss; } - opts.device_auth_installation_id = installation_id; + opts.device_auth_metadata.installation_id = installation_id; match run_device_code_login(opts).await { Ok(()) => { eprintln!("{LOGIN_SUCCESS_MESSAGE}"); @@ -376,17 +383,8 @@ pub async fn run_login_with_device_code_fallback_to_browser( std::process::exit(1); } let auth_route_config = config.auth_route_config(); - let installation_id = if issuer_base_url.is_none() { - match codex_core::resolve_installation_id(&config.codex_home).await { - Ok(installation_id) => Some(installation_id), - Err(err) => { - eprintln!("Error resolving installation ID: {err}"); - std::process::exit(1); - } - } - } else { - None - }; + let installation_id = + resolve_device_auth_installation_id(&config, issuer_base_url.as_deref()).await; clear_existing_auth_before_login( &config.codex_home, config.cli_auth_credentials_store_mode, @@ -407,7 +405,7 @@ pub async fn run_login_with_device_code_fallback_to_browser( if let Some(iss) = issuer_base_url { opts.issuer = iss; } - opts.device_auth_installation_id = installation_id; + opts.device_auth_metadata.installation_id = installation_id; opts.open_browser = false; match run_device_code_login(opts.clone()).await { diff --git a/codex-rs/login/src/auth/default_client.rs b/codex-rs/login/src/auth/default_client.rs index 0345cd37fe..3d04d89cb5 100644 --- a/codex-rs/login/src/auth/default_client.rs +++ b/codex-rs/login/src/auth/default_client.rs @@ -65,14 +65,6 @@ impl CodexClientIdentity { } } - pub fn originator(&self) -> &str { - &self.originator - } - - pub fn user_agent_suffix(&self) -> &str { - &self.user_agent_suffix - } - pub(crate) fn headers(&self) -> HeaderMap { default_headers_for_identity(Some(self)) } diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 8e4433cfe9..6d99c94fe9 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -6,9 +6,9 @@ use serde::de::{self}; use std::time::Duration; use std::time::Instant; -use crate::default_client::CodexClientIdentity; use crate::default_client::build_default_auth_reqwest_client; use crate::pkce::PkceCodes; +use crate::server::DeviceAuthMetadata; use crate::server::ServerOptions; use std::io; @@ -17,6 +17,19 @@ const ANSI_GRAY: &str = "\x1b[90m"; const ANSI_RESET: &str = "\x1b[0m"; const X_CODEX_INSTALLATION_ID_HEADER: &str = "x-codex-installation-id"; +fn with_device_auth_headers( + mut request: reqwest::RequestBuilder, + metadata: &DeviceAuthMetadata, +) -> reqwest::RequestBuilder { + if let Some(installation_id) = metadata.installation_id.as_deref() { + request = request.header(X_CODEX_INSTALLATION_ID_HEADER, installation_id); + } + if let Some(client_identity) = metadata.client_identity.as_ref() { + request = request.headers(client_identity.headers()); + } + request +} + #[derive(Debug, Clone)] pub struct DeviceCode { pub verification_url: String, @@ -65,24 +78,18 @@ async fn request_user_code( client: &reqwest::Client, auth_base_url: &str, client_id: &str, - installation_id: Option<&str>, - client_identity: Option<&CodexClientIdentity>, + metadata: &DeviceAuthMetadata, ) -> std::io::Result { let url = format!("{auth_base_url}/deviceauth/usercode"); let body = serde_json::to_string(&UserCodeReq { client_id: client_id.to_string(), }) .map_err(std::io::Error::other)?; - let mut request = client + let request = client .post(url) .header("Content-Type", "application/json") .body(body); - if let Some(installation_id) = installation_id { - request = request.header(X_CODEX_INSTALLATION_ID_HEADER, installation_id); - } - if let Some(client_identity) = client_identity { - request = request.headers(client_identity.headers()); - } + let request = with_device_auth_headers(request, metadata); let resp = request.send().await.map_err(std::io::Error::other)?; if !resp.status().is_success() { @@ -110,8 +117,7 @@ async fn poll_for_token( device_auth_id: &str, user_code: &str, interval: u64, - installation_id: Option<&str>, - client_identity: Option<&CodexClientIdentity>, + metadata: &DeviceAuthMetadata, ) -> std::io::Result { let url = format!("{auth_base_url}/deviceauth/token"); let max_wait = Duration::from_secs(15 * 60); @@ -123,16 +129,11 @@ async fn poll_for_token( user_code: user_code.to_string(), }) .map_err(std::io::Error::other)?; - let mut request = client + let request = client .post(&url) .header("Content-Type", "application/json") .body(body); - if let Some(installation_id) = installation_id { - request = request.header(X_CODEX_INSTALLATION_ID_HEADER, installation_id); - } - if let Some(client_identity) = client_identity { - request = request.headers(client_identity.headers()); - } + let request = with_device_auth_headers(request, metadata); let resp = request.send().await.map_err(std::io::Error::other)?; let status = resp.status(); @@ -176,13 +177,11 @@ pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result = LazyLock::new(|| { .unwrap_or_else(|err| panic!("login error page template must parse: {err}")) }); +#[derive(Debug, Clone, Default)] +pub struct DeviceAuthMetadata { + pub installation_id: Option, + pub client_identity: Option, +} + /// Options for launching the local login callback server. #[derive(Debug, Clone)] pub struct ServerOptions { @@ -79,10 +85,7 @@ pub struct ServerOptions { pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub auth_keyring_backend_kind: AuthKeyringBackendKind, pub auth_route_config: Option, - /// Installation ID attached only to device authorization requests sent to a trusted issuer. - pub device_auth_installation_id: Option, - /// Per-caller identity for device authorization requests, when available. - pub device_auth_client_identity: Option, + pub device_auth_metadata: DeviceAuthMetadata, } impl ServerOptions { @@ -108,8 +111,7 @@ impl ServerOptions { cli_auth_credentials_store_mode, auth_keyring_backend_kind, auth_route_config, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: DeviceAuthMetadata::default(), } } } diff --git a/codex-rs/login/tests/suite/device_code_login.rs b/codex-rs/login/tests/suite/device_code_login.rs index 0412757c11..6d5a780fb7 100644 --- a/codex-rs/login/tests/suite/device_code_login.rs +++ b/codex-rs/login/tests/suite/device_code_login.rs @@ -146,7 +146,7 @@ async fn device_code_login_integration_succeeds() -> anyhow::Result<()> { let issuer = mock_server.uri(); let installation_id = "123e4567-e89b-42d3-a456-426614174099"; let mut opts = server_opts(&codex_home, issuer, AuthCredentialsStoreMode::File); - opts.device_auth_installation_id = Some(installation_id.to_string()); + opts.device_auth_metadata.installation_id = Some(installation_id.to_string()); run_device_code_login(opts) .await diff --git a/codex-rs/login/tests/suite/login_server_e2e.rs b/codex-rs/login/tests/suite/login_server_e2e.rs index e2b11a5d4c..99a5c0c44f 100644 --- a/codex-rs/login/tests/suite/login_server_e2e.rs +++ b/codex-rs/login/tests/suite/login_server_e2e.rs @@ -125,8 +125,7 @@ async fn end_to_end_login_flow_persists_auth_json() -> Result<()> { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -192,8 +191,7 @@ async fn hosted_login_redirects_to_configured_open_app_url() -> Result<()> { codex_home: tmp.path().to_path_buf(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -247,8 +245,7 @@ async fn creates_missing_codex_home_dir() -> Result<()> { codex_home: server_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -292,8 +289,7 @@ async fn login_server_includes_forced_workspaces_as_one_query_param() -> Result< codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -338,8 +334,7 @@ async fn forced_chatgpt_workspace_id_mismatch_blocks_login() -> Result<()> { codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -403,8 +398,7 @@ async fn oauth_access_denied_missing_entitlement_blocks_login_with_clear_error() codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -476,8 +470,7 @@ async fn oauth_access_denied_unknown_reason_uses_generic_error_page() -> Result< codex_home: codex_home.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: 0, @@ -628,8 +621,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { codex_home: first_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer: issuer.clone(), port: 0, @@ -654,8 +646,7 @@ async fn cancels_previous_login_server_when_port_is_in_use() -> Result<()> { codex_home: second_codex_home, cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, auth_route_config: None, - device_auth_installation_id: None, - device_auth_client_identity: None, + device_auth_metadata: Default::default(), client_id: codex_login::CLIENT_ID.to_string(), issuer, port: login_port,