diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 707c16001680..5f7569dfddda 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1748,6 +1748,7 @@ dependencies = [ "ed25519-dalek", "pretty_assertions", "rand 0.9.3", + "reqwest", "serde", "serde_json", "sha2", @@ -2782,6 +2783,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "codex-agent-identity", "codex-app-server-protocol", "codex-client", "codex-config", diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index ad9442279da1..cb587918723d 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -113,6 +113,7 @@ license = "Apache-2.0" # Internal app_test_support = { path = "app-server/tests/common" } codex-analytics = { path = "analytics" } +codex-agent-identity = { path = "agent-identity" } codex-ansi-escape = { path = "ansi-escape" } codex-api = { path = "codex-api" } codex-aws-auth = { path = "aws-auth" } diff --git a/codex-rs/agent-identity/Cargo.toml b/codex-rs/agent-identity/Cargo.toml index 079f57bcbef5..7976c3354b37 100644 --- a/codex-rs/agent-identity/Cargo.toml +++ b/codex-rs/agent-identity/Cargo.toml @@ -20,6 +20,7 @@ codex-protocol = { workspace = true } crypto_box = { workspace = true } ed25519-dalek = { workspace = true } rand = { workspace = true } +reqwest = { workspace = true, features = ["json"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha2 = { workspace = true } diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index a3c6d1f7ae13..a6d7e25dfdd8 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::time::Duration; use anyhow::Context; use anyhow::Result; @@ -21,6 +22,8 @@ use serde::Serialize; use sha2::Digest as _; use sha2::Sha512; +const AGENT_TASK_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(30); + /// Stored key material for a registered agent identity. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct AgentIdentityKey<'a> { @@ -55,6 +58,24 @@ struct AgentAssertionEnvelope { signature: String, } +#[derive(Serialize)] +struct RegisterTaskRequest { + timestamp: String, + signature: String, +} + +#[derive(Deserialize)] +struct RegisterTaskResponse { + #[serde(default)] + task_id: Option, + #[serde(default, rename = "taskId")] + task_id_camel: Option, + #[serde(default)] + encrypted_task_id: Option, + #[serde(default, rename = "encryptedTaskId")] + encrypted_task_id_camel: Option, +} + pub fn authorization_header_for_agent_task( key: AgentIdentityKey<'_>, target: AgentTaskAuthorizationTarget<'_>, @@ -86,6 +107,50 @@ pub fn sign_task_registration_payload( Ok(BASE64_STANDARD.encode(signing_key.sign(payload.as_bytes()).to_bytes())) } +pub async fn register_agent_task( + client: &reqwest::Client, + chatgpt_base_url: &str, + key: AgentIdentityKey<'_>, +) -> Result { + let timestamp = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let request = RegisterTaskRequest { + signature: sign_task_registration_payload(key, ×tamp)?, + timestamp, + }; + + let response = client + .post(agent_task_registration_url( + chatgpt_base_url, + key.agent_runtime_id, + )) + .timeout(AGENT_TASK_REGISTRATION_TIMEOUT) + .json(&request) + .send() + .await + .context("failed to register agent task")? + .error_for_status() + .context("failed to register agent task")? + .json() + .await + .context("failed to decode agent task registration response")?; + + task_id_from_register_task_response(key, response) +} + +fn task_id_from_register_task_response( + key: AgentIdentityKey<'_>, + response: RegisterTaskResponse, +) -> Result { + if let Some(task_id) = response.task_id.or(response.task_id_camel) { + return Ok(task_id); + } + let encrypted_task_id = response + .encrypted_task_id + .or(response.encrypted_task_id_camel) + .context("agent task registration response omitted task id")?; + decrypt_task_id_response(key, &encrypted_task_id) +} + pub fn decrypt_task_id_response( key: AgentIdentityKey<'_>, encrypted_task_id: &str, diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index 144344468322..167c6379187e 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -436,6 +436,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 7af378859bc8..5e1e44bdd668 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5856,6 +5856,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 9ba97ed05e83..d91c1c1604d4 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -777,6 +777,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json index 64109ca2a0bf..e7546f5570e9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/AccountUpdatedNotification.json @@ -24,6 +24,13 @@ "chatgptAuthTokens" ], "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": [ + "agentIdentity" + ], + "type": "string" } ] }, diff --git a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts index 5e0cad8864d9..210e54c4a5fe 100644 --- a/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts +++ b/codex-rs/app-server-protocol/schema/typescript/AuthMode.ts @@ -5,4 +5,4 @@ /** * Authentication mode for OpenAI-backed providers. */ -export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens"; +export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 4179e18b55e0..f59551057cb4 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -30,6 +30,11 @@ pub enum AuthMode { #[ts(rename = "chatgptAuthTokens")] #[strum(serialize = "chatgptAuthTokens")] ChatgptAuthTokens, + /// Programmatic Codex auth backed by a registered Agent Identity. + #[serde(rename = "agentIdentity")] + #[ts(rename = "agentIdentity")] + #[strum(serialize = "agentIdentity")] + AgentIdentity, } macro_rules! experimental_reason_expr { diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index afdc55df0ca4..61ba231b6a51 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1782,7 +1782,9 @@ impl CodexMessageProcessor { self.auth_manager.refresh_failure_for_auth(&auth).is_some(); let auth_mode = auth.api_auth_mode(); let (reported_auth_method, token_opt) = - if include_token && permanent_refresh_failure { + if matches!(auth, CodexAuth::AgentIdentity(_)) + || include_token && permanent_refresh_failure + { (Some(auth_mode), None) } else { match auth.get_token() { @@ -1834,7 +1836,9 @@ impl CodexMessageProcessor { let account = match self.auth_manager.auth_cached() { Some(auth) => match auth.auth_mode() { CoreAuthMode::ApiKey => Some(Account::ApiKey {}), - CoreAuthMode::Chatgpt | CoreAuthMode::ChatgptAuthTokens => { + CoreAuthMode::Chatgpt + | CoreAuthMode::ChatgptAuthTokens + | CoreAuthMode::AgentIdentity => { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); diff --git a/codex-rs/app-server/src/transport/remote_control/tests.rs b/codex-rs/app-server/src/transport/remote_control/tests.rs index 99946b49167e..6b0051f8dba4 100644 --- a/codex-rs/app-server/src/transport/remote_control/tests.rs +++ b/codex-rs/app-server/src/transport/remote_control/tests.rs @@ -98,6 +98,7 @@ fn remote_control_auth_dot_json(account_id: Option<&str>) -> AuthDotJson { account_id: account_id.map(str::to_string), }), last_refresh: Some(chrono::Utc::now()), + agent_identity: None, } } @@ -495,6 +496,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, ); let (transport_event_tx, _transport_event_rx) = mpsc::channel::(CHANNEL_CAPACITY); @@ -1082,6 +1084,7 @@ async fn remote_control_waits_for_account_id_before_enrolling() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, ); let expected_server_name = gethostname().to_string_lossy().trim().to_string(); let expected_enrollment = RemoteControlEnrollment { diff --git a/codex-rs/app-server/src/transport/remote_control/websocket.rs b/codex-rs/app-server/src/transport/remote_control/websocket.rs index b68c98e733ca..4eb58a87f2c9 100644 --- a/codex-rs/app-server/src/transport/remote_control/websocket.rs +++ b/codex-rs/app-server/src/transport/remote_control/websocket.rs @@ -1011,6 +1011,7 @@ mod tests { account_id: Some("account_id".to_string()), }), last_refresh: Some(Utc::now()), + agent_identity: None, } } @@ -1090,6 +1091,7 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, ); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut enrollment = Some(RemoteControlEnrollment { @@ -1171,6 +1173,7 @@ mod tests { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, ); let mut auth_recovery = auth_manager.unauthorized_recovery(); let mut enrollment = None; diff --git a/codex-rs/app-server/tests/common/auth_fixtures.rs b/codex-rs/app-server/tests/common/auth_fixtures.rs index 99334f07706f..86f0fb456ddb 100644 --- a/codex-rs/app-server/tests/common/auth_fixtures.rs +++ b/codex-rs/app-server/tests/common/auth_fixtures.rs @@ -163,6 +163,7 @@ pub fn write_chatgpt_auth( openai_api_key: None, tokens: Some(tokens), last_refresh, + agent_identity: None, }; save_auth(codex_home, &auth, cli_auth_credentials_store_mode).context("write auth.json") diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index 7aa803452805..78a915d178bf 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -119,6 +119,7 @@ async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { openai_api_key: Some("test-api-key".to_string()), tokens: None, last_refresh: None, + agent_identity: None, }, AuthCredentialsStoreMode::File, )?; diff --git a/codex-rs/chatgpt/src/chatgpt_token.rs b/codex-rs/chatgpt/src/chatgpt_token.rs index d20a7e57c41e..fe19c3015e86 100644 --- a/codex-rs/chatgpt/src/chatgpt_token.rs +++ b/codex-rs/chatgpt/src/chatgpt_token.rs @@ -26,6 +26,7 @@ pub async fn init_chatgpt_token_from_auth( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, + /*chatgpt_base_url*/ None, ); if let Some(auth) = auth_manager.auth().await { let token_data = auth.get_token_data()?; diff --git a/codex-rs/chatgpt/src/connectors.rs b/codex-rs/chatgpt/src/connectors.rs index c054d1b8df82..4c6f05a68163 100644 --- a/codex-rs/chatgpt/src/connectors.rs +++ b/codex-rs/chatgpt/src/connectors.rs @@ -32,6 +32,7 @@ async fn apps_enabled(config: &Config) -> bool { config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config.cli_auth_credentials_store_mode, + Some(config.chatgpt_base_url.clone()), ); let auth = auth_manager.auth().await; config diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index fd0dfee3afca..42241aa933c8 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -332,6 +332,10 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { eprintln!("Logged in using ChatGPT"); std::process::exit(0); } + AuthMode::AgentIdentity => { + eprintln!("Logged in using Agent Identity"); + std::process::exit(0); + } }, Ok(None) => { eprintln!("Not logged in"); diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs index 914958d2c72c..0b50aa834fa7 100644 --- a/codex-rs/cloud-requirements/src/lib.rs +++ b/codex-rs/cloud-requirements/src/lib.rs @@ -731,6 +731,7 @@ pub fn cloud_requirements_loader_for_storage( codex_home.clone(), enable_codex_api_key_env, credentials_store_mode, + Some(chatgpt_base_url.clone()), ); cloud_requirements_loader(auth_manager, chatgpt_base_url, codex_home) } @@ -858,6 +859,7 @@ mod tests { tmp.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, )) } @@ -882,6 +884,7 @@ mod tests { tmp.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, )) } @@ -990,6 +993,7 @@ mod tests { home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, )), _home: home, } @@ -1394,6 +1398,7 @@ enabled = false auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, )); write_auth_json( @@ -1466,6 +1471,7 @@ enabled = false auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, )); write_auth_json( @@ -1596,6 +1602,7 @@ enabled = false auth_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, )); let fetcher = Arc::new(UnauthorizedFetcher { diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index cbaed17beaf3..525ea3b5945a 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -66,6 +66,7 @@ pub async fn load_auth_manager() -> Option { config.codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config.cli_auth_credentials_store_mode, + Some(config.chatgpt_base_url), )) } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 5a2ba1d0d2bb..77022029f1d3 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1703,7 +1703,9 @@ impl AuthRequestTelemetryContext { Self { auth_mode: auth_mode.map(|mode| match mode { AuthMode::ApiKey => "ApiKey", - AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens => "Chatgpt", + AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity => { + "Chatgpt" + } }), auth_header_attached: auth_telemetry.attached, auth_header_name: auth_telemetry.name, diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 995c2008c54a..a4582f9da0c8 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -634,6 +634,10 @@ impl AuthManagerConfig for Config { fn forced_chatgpt_workspace_id(&self) -> Option { self.forced_chatgpt_workspace_id.clone() } + + fn chatgpt_base_url(&self) -> String { + self.chatgpt_base_url.clone() + } } #[derive(Clone)] diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index d5303ea54cf3..026a3edf0845 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -11,6 +11,7 @@ workspace = true async-trait = { workspace = true } base64 = { workspace = true } chrono = { workspace = true, features = ["serde"] } +codex-agent-identity = { workspace = true } codex-app-server-protocol = { workspace = true } codex-client = { workspace = true } codex-config = { workspace = true } diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs new file mode 100644 index 000000000000..e8f81f39fac0 --- /dev/null +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -0,0 +1,84 @@ +use std::sync::Arc; + +use codex_agent_identity::AgentIdentityKey; +use codex_agent_identity::normalize_chatgpt_base_url; +use codex_agent_identity::register_agent_task; +use codex_protocol::account::PlanType as AccountPlanType; +use tokio::sync::OnceCell; + +use crate::default_client::build_reqwest_client; + +use super::storage::AgentIdentityAuthRecord; + +const DEFAULT_CHATGPT_BACKEND_BASE_URL: &str = "https://chatgpt.com/backend-api"; + +#[derive(Debug)] +pub struct AgentIdentityAuth { + record: AgentIdentityAuthRecord, + process_task_id: Arc>, +} + +impl Clone for AgentIdentityAuth { + fn clone(&self) -> Self { + Self { + record: self.record.clone(), + process_task_id: Arc::clone(&self.process_task_id), + } + } +} + +impl AgentIdentityAuth { + pub fn new(record: AgentIdentityAuthRecord) -> Self { + Self { + record, + process_task_id: Arc::new(OnceCell::new()), + } + } + + pub fn record(&self) -> &AgentIdentityAuthRecord { + &self.record + } + + pub async fn ensure_runtime(&self, chatgpt_base_url: Option) -> std::io::Result<()> { + self.process_task_id + .get_or_try_init(|| async { + let base_url = normalize_chatgpt_base_url( + chatgpt_base_url + .as_deref() + .unwrap_or(DEFAULT_CHATGPT_BACKEND_BASE_URL), + ); + register_agent_task(&build_reqwest_client(), &base_url, self.key()) + .await + .map_err(std::io::Error::other) + }) + .await + .map(|_| ()) + } + + pub fn account_id(&self) -> &str { + &self.record.account_id + } + + pub fn chatgpt_user_id(&self) -> &str { + &self.record.chatgpt_user_id + } + + pub fn email(&self) -> &str { + &self.record.email + } + + pub fn plan_type(&self) -> AccountPlanType { + self.record.plan_type + } + + pub fn is_fedramp_account(&self) -> bool { + self.record.chatgpt_account_is_fedramp + } + + fn key(&self) -> AgentIdentityKey<'_> { + AgentIdentityKey { + agent_runtime_id: &self.record.agent_runtime_id, + private_key_pkcs8_base64: &self.record.agent_private_key, + } + } +} diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index c008b0fc86b6..6f17822e7766 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -136,6 +136,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { account_id: None, }), last_refresh: Some(last_refresh), + agent_identity: None, }, auth_dot_json ); @@ -173,6 +174,7 @@ fn logout_removes_auth_file() -> Result<(), std::io::Error> { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + agent_identity: None, }; super::save_auth(dir.path(), &auth_dot_json, AuthCredentialsStoreMode::File)?; let auth_file = get_auth_file(dir.path()); @@ -189,6 +191,7 @@ fn unauthorized_recovery_reports_mode_and_step_names() { dir.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, ); let managed = UnauthorizedRecovery { manager: Arc::clone(&manager), diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index 449591da9300..6cc87386f55d 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -23,6 +23,8 @@ use codex_protocol::config_types::ModelProviderAuthInfo; use super::external_bearer::BearerTokenRefresher; use super::revoke::revoke_auth_tokens; +pub use crate::auth::agent_identity::AgentIdentityAuth; +pub use crate::auth::storage::AgentIdentityAuthRecord; pub use crate::auth::storage::AuthDotJson; use crate::auth::storage::AuthStorageBackend; use crate::auth::storage::create_auth_storage; @@ -47,6 +49,13 @@ pub enum CodexAuth { ApiKey(ApiKeyAuth), Chatgpt(ChatgptAuth), ChatgptAuthTokens(ChatgptAuthTokens), + AgentIdentity(AgentIdentityAuth), +} + +impl PartialEq for CodexAuth { + fn eq(&self, other: &Self) -> bool { + self.api_auth_mode() == other.api_auth_mode() + } } #[derive(Debug, Clone)] @@ -71,12 +80,6 @@ struct ChatgptAuthState { client: CodexHttpClient, } -impl PartialEq for CodexAuth { - fn eq(&self, other: &Self) -> bool { - self.api_auth_mode() == other.api_auth_mode() - } -} - const TOKEN_REFRESH_INTERVAL: i64 = 8; const REFRESH_TOKEN_EXPIRED_MESSAGE: &str = "Your access token could not be refreshed because your refresh token has expired. Please log out and sign in again."; @@ -203,6 +206,14 @@ impl CodexAuth { }; return Ok(Self::from_api_key(api_key)); } + if auth_mode == ApiAuthMode::AgentIdentity { + let Some(record) = auth_dot_json.agent_identity else { + return Err(std::io::Error::other( + "agent identity auth is missing an agent identity record.", + )); + }; + return Ok(Self::AgentIdentity(AgentIdentityAuth::new(record))); + } let storage_mode = auth_dot_json.storage_mode(auth_credentials_store_mode); let state = ChatgptAuthState { @@ -219,6 +230,7 @@ impl CodexAuth { Ok(Self::ChatgptAuthTokens(ChatgptAuthTokens { state })) } ApiAuthMode::ApiKey => unreachable!("api key mode is handled above"), + ApiAuthMode::AgentIdentity => unreachable!("agent identity mode is handled above"), } } @@ -237,6 +249,7 @@ impl CodexAuth { match self { Self::ApiKey(_) => AuthMode::ApiKey, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt, + Self::AgentIdentity(_) => AuthMode::AgentIdentity, } } @@ -245,6 +258,7 @@ impl CodexAuth { Self::ApiKey(_) => ApiAuthMode::ApiKey, Self::Chatgpt(_) => ApiAuthMode::Chatgpt, Self::ChatgptAuthTokens(_) => ApiAuthMode::ChatgptAuthTokens, + Self::AgentIdentity(_) => ApiAuthMode::AgentIdentity, } } @@ -253,7 +267,14 @@ impl CodexAuth { } pub fn is_chatgpt_auth(&self) -> bool { - self.auth_mode() == AuthMode::Chatgpt + matches!(self, Self::Chatgpt(_) | Self::ChatgptAuthTokens(_)) + } + + pub fn uses_codex_backend(&self) -> bool { + matches!( + self, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) + ) } pub fn is_external_chatgpt_tokens(&self) -> bool { @@ -264,11 +285,11 @@ impl CodexAuth { pub fn api_key(&self) -> Option<&str> { match self { Self::ApiKey(auth) => Some(auth.api_key.as_str()), - Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => None, + Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) | Self::AgentIdentity(_) => None, } } - /// Returns `Err` if `is_chatgpt_auth()` is false. + /// Returns `Err` if token-backed ChatGPT auth is unavailable. pub fn get_token_data(&self) -> Result { let auth_dot_json: Option = self.get_current_auth_json(); match auth_dot_json { @@ -289,38 +310,66 @@ impl CodexAuth { let access_token = self.get_token_data()?.access_token; Ok(access_token) } + Self::AgentIdentity(_) => Err(std::io::Error::other( + "agent identity auth does not expose a bearer token", + )), } } - /// Returns `None` if `is_chatgpt_auth()` is false. + pub async fn initialize_runtime( + &self, + chatgpt_base_url: Option, + ) -> std::io::Result<()> { + match self { + Self::AgentIdentity(auth) => auth.ensure_runtime(chatgpt_base_url).await, + Self::ApiKey(_) | Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => Ok(()), + } + } + + /// Returns `None` if Codex backend auth does not expose an account id. pub fn get_account_id(&self) -> Option { - self.get_current_token_data().and_then(|t| t.account_id) + match self { + Self::AgentIdentity(auth) => Some(auth.account_id().to_string()), + _ => self.get_current_token_data().and_then(|t| t.account_id), + } } - /// Returns false if `is_chatgpt_auth()` is false or the token omits the FedRAMP claim. + /// Returns false if Codex backend auth omits the FedRAMP claim. pub fn is_fedramp_account(&self) -> bool { - self.get_current_token_data() - .is_some_and(|t| t.id_token.is_fedramp_account()) + match self { + Self::AgentIdentity(auth) => auth.is_fedramp_account(), + _ => self + .get_current_token_data() + .is_some_and(|t| t.id_token.is_fedramp_account()), + } } - /// Returns `None` if `is_chatgpt_auth()` is false. + /// Returns `None` if Codex backend auth does not expose an account email. pub fn get_account_email(&self) -> Option { - self.get_current_token_data().and_then(|t| t.id_token.email) + match self { + Self::AgentIdentity(auth) => Some(auth.email().to_string()), + _ => self.get_current_token_data().and_then(|t| t.id_token.email), + } } - /// Returns `None` if `is_chatgpt_auth()` is false. + /// Returns `None` if Codex backend auth does not expose a ChatGPT user id. pub fn get_chatgpt_user_id(&self) -> Option { - self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_user_id) + match self { + Self::AgentIdentity(auth) => Some(auth.chatgpt_user_id().to_string()), + _ => self + .get_current_token_data() + .and_then(|t| t.id_token.chatgpt_user_id), + } } - /// Account-facing plan classification derived from the current token. + /// Account-facing plan classification derived from the current auth. /// Returns a high-level `AccountPlanType` (e.g., Free/Plus/Pro/Team/…) - /// mapped from the ID token's internal plan value. Prefer this when you - /// need to make UI or product decisions based on the user's subscription. - /// When ChatGPT auth is active but the token omits the plan claim, report - /// `Unknown` instead of treating the account as invalid. + /// for UI or product decisions based on the user's subscription. pub fn account_plan_type(&self) -> Option { + if let Self::AgentIdentity(auth) = self { + return Some(auth.plan_type()); + } + let map_known = |kp: &InternalKnownPlan| match kp { InternalKnownPlan::Free => AccountPlanType::Free, InternalKnownPlan::Go => AccountPlanType::Go, @@ -348,18 +397,18 @@ impl CodexAuth { }) } - /// Returns `None` if `is_chatgpt_auth()` is false. + /// Returns `None` if token-backed ChatGPT auth is unavailable. fn get_current_auth_json(&self) -> Option { let state = match self { Self::Chatgpt(auth) => &auth.state, Self::ChatgptAuthTokens(auth) => &auth.state, - Self::ApiKey(_) => return None, + Self::ApiKey(_) | Self::AgentIdentity(_) => return None, }; #[expect(clippy::unwrap_used)] state.auth_dot_json.lock().unwrap().clone() } - /// Returns `None` if `is_chatgpt_auth()` is false. + /// Returns `None` if token-backed ChatGPT auth is unavailable. fn get_current_token_data(&self) -> Option { self.get_current_auth_json().and_then(|t| t.tokens) } @@ -376,6 +425,7 @@ impl CodexAuth { account_id: Some("account_id".to_string()), }), last_refresh: Some(Utc::now()), + agent_identity: None, }; let client = create_client(); @@ -452,6 +502,7 @@ pub async fn logout_with_revoke( codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, auth_credentials_store_mode, + /*chatgpt_base_url*/ None, ) .logout_with_revoke() .await @@ -468,6 +519,7 @@ pub fn login_with_api_key( openai_api_key: Some(api_key.to_string()), tokens: None, last_refresh: None, + agent_identity: None, }; save_auth(codex_home, &auth_dot_json, auth_credentials_store_mode) } @@ -536,9 +588,11 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { let method_violation = match (required_method, auth.auth_mode()) { (ForcedLoginMethod::Api, AuthMode::ApiKey) => None, (ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) - | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) => None, + | (ForcedLoginMethod::Chatgpt, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Chatgpt, AuthMode::AgentIdentity) => None, (ForcedLoginMethod::Api, AuthMode::Chatgpt) - | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) => Some( + | (ForcedLoginMethod::Api, AuthMode::ChatgptAuthTokens) + | (ForcedLoginMethod::Api, AuthMode::AgentIdentity) => Some( "API key login is required, but ChatGPT is currently being used. Logging out." .to_string(), ), @@ -558,26 +612,27 @@ pub fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result<()> { } if let Some(expected_account_id) = config.forced_chatgpt_workspace_id.as_deref() { - if !auth.is_chatgpt_auth() { - return Ok(()); - } - - let token_data = match auth.get_token_data() { - Ok(data) => data, - Err(err) => { - return logout_with_message( - &config.codex_home, - format!( - "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." - ), - config.auth_credentials_store_mode, - ); + // workspace is the external identifier for account id. + let chatgpt_account_id = match auth { + CodexAuth::ApiKey(_) => return Ok(()), + CodexAuth::AgentIdentity(_) => auth.get_account_id(), + CodexAuth::Chatgpt(_) | CodexAuth::ChatgptAuthTokens(_) => { + let token_data = match auth.get_token_data() { + Ok(data) => data, + Err(err) => { + return logout_with_message( + &config.codex_home, + format!( + "Failed to load ChatGPT credentials while enforcing workspace restrictions: {err}. Logging out." + ), + config.auth_credentials_store_mode, + ); + } + }; + token_data.id_token.chatgpt_account_id } }; - - // workspace is the external identifier for account id. - let chatgpt_account_id = token_data.id_token.chatgpt_account_id.as_deref(); - if chatgpt_account_id != Some(expected_account_id) { + if chatgpt_account_id.as_deref() != Some(expected_account_id) { let message = match chatgpt_account_id { Some(actual) => format!( "Login is restricted to workspace {expected_account_id}, but current credentials belong to {actual}. Logging out." @@ -841,6 +896,7 @@ impl AuthDotJson { openai_api_key: None, tokens: Some(tokens), last_refresh: Some(Utc::now()), + agent_identity: None, }) } @@ -1134,6 +1190,7 @@ pub struct AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: RwLock>, + chatgpt_base_url: Option, refresh_lock: Semaphore, external_auth: RwLock>>, } @@ -1153,6 +1210,9 @@ pub trait AuthManagerConfig { /// Returns the workspace ID that ChatGPT auth should be restricted to, if any. fn forced_chatgpt_workspace_id(&self) -> Option; + + /// Returns the ChatGPT backend base URL used for first-party backend authorization. + fn chatgpt_base_url(&self) -> String; } impl Debug for AuthManager { @@ -1169,6 +1229,7 @@ impl Debug for AuthManager { "forced_chatgpt_workspace_id", &self.forced_chatgpt_workspace_id, ) + .field("chatgpt_base_url", &self.chatgpt_base_url) .field("has_external_auth", &self.has_external_auth()) .finish_non_exhaustive() } @@ -1183,6 +1244,7 @@ impl AuthManager { codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, ) -> Self { let managed_auth = load_auth( &codex_home, @@ -1200,6 +1262,7 @@ impl AuthManager { enable_codex_api_key_env, auth_credentials_store_mode, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), } @@ -1218,6 +1281,7 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), }) @@ -1235,6 +1299,7 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(None), }) @@ -1250,6 +1315,7 @@ impl AuthManager { enable_codex_api_key_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), + chatgpt_base_url: None, refresh_lock: Semaphore::new(/*permits*/ 1), external_auth: RwLock::new(Some( Arc::new(BearerTokenRefresher::new(config)) as Arc @@ -1287,7 +1353,12 @@ impl AuthManager { tracing::error!("Failed to refresh token: {}", err); return Some(auth); } - self.auth_cached() + let auth = self.auth_cached()?; + if let Err(err) = auth.initialize_runtime(self.chatgpt_base_url.clone()).await { + tracing::error!("Failed to initialize auth runtime: {err}"); + return None; + } + Some(auth) } /// Force a reload of the auth information from auth.json. Returns @@ -1339,6 +1410,12 @@ impl AuthManager { | (ApiAuthMode::ChatgptAuthTokens, ApiAuthMode::ChatgptAuthTokens) => { a.get_current_auth_json() == b.get_current_auth_json() } + (ApiAuthMode::AgentIdentity, ApiAuthMode::AgentIdentity) => match (a, b) { + (CodexAuth::AgentIdentity(a), CodexAuth::AgentIdentity(b)) => { + a.record() == b.record() + } + _ => false, + }, _ => false, }, _ => false, @@ -1412,7 +1489,9 @@ impl AuthManager { } pub fn set_forced_chatgpt_workspace_id(&self, workspace_id: Option) { - if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() { + if let Ok(mut guard) = self.forced_chatgpt_workspace_id.write() + && *guard != workspace_id + { *guard = workspace_id; } } @@ -1443,11 +1522,13 @@ impl AuthManager { codex_home: PathBuf, enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, ) -> Arc { Arc::new(Self::new( codex_home, enable_codex_api_key_env, auth_credentials_store_mode, + chatgpt_base_url, )) } @@ -1460,6 +1541,7 @@ impl AuthManager { config.codex_home(), enable_codex_api_key_env, config.cli_auth_credentials_store_mode(), + Some(config.chatgpt_base_url()), ); auth_manager.set_forced_chatgpt_workspace_id(config.forced_chatgpt_workspace_id()); auth_manager @@ -1581,7 +1663,7 @@ impl AuthManager { self.refresh_and_persist_chatgpt_token(&chatgpt_auth, token_data.refresh_token) .await } - CodexAuth::ApiKey(_) => Ok(()), + CodexAuth::ApiKey(_) | CodexAuth::AgentIdentity(_) => Ok(()), }; if let Err(RefreshTokenError::Permanent(error)) = &result { self.record_permanent_refresh_failure_if_unchanged(&attempted_auth, error); diff --git a/codex-rs/login/src/auth/mod.rs b/codex-rs/login/src/auth/mod.rs index b927f9a77520..07c44983a95e 100644 --- a/codex-rs/login/src/auth/mod.rs +++ b/codex-rs/login/src/auth/mod.rs @@ -1,3 +1,4 @@ +mod agent_identity; pub mod default_client; pub mod error; mod storage; diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 97e801415cab..e2e801169844 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::AuthMode; use codex_config::types::AuthCredentialsStoreMode; use codex_keyring_store::DefaultKeyringStore; use codex_keyring_store::KeyringStore; +use codex_protocol::account::PlanType as AccountPlanType; use once_cell::sync::Lazy; /// Expected structure for $CODEX_HOME/auth.json. @@ -39,6 +40,20 @@ pub struct AuthDotJson { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_refresh: Option>, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent_identity: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug, PartialEq, Eq)] +pub struct AgentIdentityAuthRecord { + pub agent_runtime_id: String, + pub agent_private_key: String, + pub account_id: String, + pub chatgpt_user_id: String, + pub email: String, + pub plan_type: AccountPlanType, + pub chatgpt_account_is_fedramp: bool, } pub(super) fn get_auth_file(codex_home: &Path) -> PathBuf { diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index 4bf72c11b94d..c06a8cfde410 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -7,6 +7,7 @@ use serde_json::json; use tempfile::tempdir; use codex_keyring_store::tests::MockKeyringStore; +use codex_protocol::account::PlanType as AccountPlanType; use keyring::Error as KeyringError; #[tokio::test] @@ -18,6 +19,7 @@ async fn file_storage_load_returns_auth_dot_json() -> anyhow::Result<()> { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + agent_identity: None, }; storage @@ -38,6 +40,7 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { openai_api_key: Some("test-key".to_string()), tokens: None, last_refresh: Some(Utc::now()), + agent_identity: None, }; let file = get_auth_file(codex_home.path()); @@ -52,6 +55,33 @@ async fn file_storage_save_persists_auth_dot_json() -> anyhow::Result<()> { Ok(()) } +#[tokio::test] +async fn file_storage_round_trips_agent_identity_auth() -> anyhow::Result<()> { + let codex_home = tempdir()?; + let storage = FileAuthStorage::new(codex_home.path().to_path_buf()); + let auth_dot_json = AuthDotJson { + auth_mode: Some(AuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(AgentIdentityAuthRecord { + agent_runtime_id: "agent-runtime-id".to_string(), + agent_private_key: "private-key".to_string(), + account_id: "account-id".to_string(), + chatgpt_user_id: "user-id".to_string(), + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Pro, + chatgpt_account_is_fedramp: false, + }), + }; + + storage.save(&auth_dot_json)?; + + let loaded = storage.load()?; + assert_eq!(Some(auth_dot_json), loaded); + Ok(()) +} + #[test] fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { let dir = tempdir()?; @@ -60,6 +90,7 @@ fn file_storage_delete_removes_auth_file() -> anyhow::Result<()> { openai_api_key: Some("sk-test-key".to_string()), tokens: None, last_refresh: None, + agent_identity: None, }; let storage = create_auth_storage(dir.path().to_path_buf(), AuthCredentialsStoreMode::File); storage.save(&auth_dot_json)?; @@ -83,6 +114,7 @@ fn ephemeral_storage_save_load_delete_is_in_memory_only() -> anyhow::Result<()> openai_api_key: Some("sk-ephemeral".to_string()), tokens: None, last_refresh: Some(Utc::now()), + agent_identity: None, }; storage.save(&auth_dot_json)?; @@ -181,6 +213,7 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson { account_id: Some(format!("{prefix}-account-id")), }), last_refresh: None, + agent_identity: None, } } @@ -197,6 +230,7 @@ fn keyring_auth_storage_load_returns_deserialized_auth() -> anyhow::Result<()> { openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, + agent_identity: None, }; seed_keyring_with_auth( &mock_keyring, @@ -239,6 +273,7 @@ fn keyring_auth_storage_save_persists_and_removes_fallback_file() -> anyhow::Res account_id: Some("account".to_string()), }), last_refresh: Some(Utc::now()), + agent_identity: None, }; storage.save(&auth)?; diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 169a8a3091bc..0c7b81018432 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -781,6 +781,7 @@ pub(crate) async fn persist_tokens_async( openai_api_key: api_key, tokens: Some(tokens), last_refresh: Some(Utc::now()), + agent_identity: None, }; save_auth(&codex_home, &auth, auth_credentials_store_mode) }) diff --git a/codex-rs/login/tests/suite/auth_refresh.rs b/codex-rs/login/tests/suite/auth_refresh.rs index bf9e03bc263d..3ae0eb5fa16c 100644 --- a/codex-rs/login/tests/suite/auth_refresh.rs +++ b/codex-rs/login/tests/suite/auth_refresh.rs @@ -54,6 +54,7 @@ async fn refresh_token_succeeds_updates_storage() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -117,6 +118,7 @@ async fn refresh_token_refreshes_when_auth_is_unchanged() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -171,6 +173,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -180,6 +183,7 @@ async fn refresh_token_skips_refresh_when_auth_changed() -> Result<()> { openai_api_key: None, tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; save_auth( ctx.codex_home.path(), @@ -234,6 +238,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -244,6 +249,7 @@ async fn refresh_token_errors_on_account_mismatch() -> Result<()> { openai_api_key: None, tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; save_auth( ctx.codex_home.path(), @@ -302,6 +308,7 @@ async fn returns_fresh_tokens_as_is() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(stale_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -349,6 +356,7 @@ async fn refreshes_token_when_access_token_is_expired() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(fresh_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -398,6 +406,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -408,6 +417,7 @@ async fn auth_reloads_disk_auth_when_cached_auth_is_stale() -> Result<()> { openai_api_key: None, tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), + agent_identity: None, }; save_auth( ctx.codex_home.path(), @@ -459,6 +469,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul openai_api_key: None, tokens: Some(initial_tokens), last_refresh: Some(stale_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -469,6 +480,7 @@ async fn auth_reloads_disk_auth_without_calling_expired_refresh_token() -> Resul openai_api_key: None, tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), + agent_identity: None, }; save_auth( ctx.codex_home.path(), @@ -518,6 +530,7 @@ async fn refresh_token_returns_permanent_error_for_expired_refresh_token() -> Re openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -570,6 +583,7 @@ async fn refresh_token_does_not_retry_after_permanent_failure() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -636,6 +650,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -657,6 +672,7 @@ async fn refresh_token_reloads_changed_auth_after_permanent_failure() -> Result< openai_api_key: None, tokens: Some(disk_tokens.clone()), last_refresh: Some(fresh_refresh), + agent_identity: None, }; save_auth( ctx.codex_home.path(), @@ -715,6 +731,7 @@ async fn refresh_token_returns_transient_error_on_server_failure() -> Result<()> openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -767,6 +784,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -776,6 +794,7 @@ async fn unauthorized_recovery_reloads_then_refreshes_tokens() -> Result<()> { openai_api_key: None, tokens: Some(disk_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; save_auth( ctx.codex_home.path(), @@ -859,6 +878,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { openai_api_key: None, tokens: Some(initial_tokens.clone()), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; ctx.write_auth(&initial_auth)?; @@ -869,6 +889,7 @@ async fn unauthorized_recovery_errors_on_account_mismatch() -> Result<()> { openai_api_key: None, tokens: Some(disk_tokens), last_refresh: Some(initial_last_refresh), + agent_identity: None, }; save_auth( ctx.codex_home.path(), @@ -926,6 +947,7 @@ async fn unauthorized_recovery_requires_chatgpt_auth() -> Result<()> { openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, + agent_identity: None, }; ctx.write_auth(&auth)?; @@ -962,6 +984,7 @@ impl RefreshTokenTestContext { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, ); Ok(Self { diff --git a/codex-rs/login/tests/suite/logout.rs b/codex-rs/login/tests/suite/logout.rs index a53006602ea5..e703b15eb19f 100644 --- a/codex-rs/login/tests/suite/logout.rs +++ b/codex-rs/login/tests/suite/logout.rs @@ -142,6 +142,7 @@ async fn auth_manager_logout_with_revoke_uses_cached_auth() -> Result<()> { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, ); save_auth( codex_home.path(), @@ -192,6 +193,7 @@ fn chatgpt_auth_with_refresh_token(refresh_token: &str) -> AuthDotJson { account_id: Some("account-id".to_string()), }), last_refresh: None, + agent_identity: None, } } diff --git a/codex-rs/model-provider-info/src/lib.rs b/codex-rs/model-provider-info/src/lib.rs index 233897e0e5c2..b1ffb737992a 100644 --- a/codex-rs/model-provider-info/src/lib.rs +++ b/codex-rs/model-provider-info/src/lib.rs @@ -229,7 +229,10 @@ impl ModelProviderInfo { } pub fn to_api_provider(&self, auth_mode: Option) -> CodexResult { - let default_base_url = if matches!(auth_mode, Some(AuthMode::Chatgpt)) { + let default_base_url = if matches!( + auth_mode, + Some(AuthMode::Chatgpt | AuthMode::ChatgptAuthTokens | AuthMode::AgentIdentity) + ) { "https://chatgpt.com/backend-api/codex" } else { "https://api.openai.com/v1" diff --git a/codex-rs/models-manager/src/manager_tests.rs b/codex-rs/models-manager/src/manager_tests.rs index d004dd89438a..5966df616d19 100644 --- a/codex-rs/models-manager/src/manager_tests.rs +++ b/codex-rs/models-manager/src/manager_tests.rs @@ -707,6 +707,7 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() { codex_home.path().to_path_buf(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, )); let provider = provider_for(server.uri()); let manager = ModelsManager::with_provider_for_tests( diff --git a/codex-rs/otel/src/lib.rs b/codex-rs/otel/src/lib.rs index c7d0b7c419c1..0ea401140e0e 100644 --- a/codex-rs/otel/src/lib.rs +++ b/codex-rs/otel/src/lib.rs @@ -52,7 +52,8 @@ impl From for TelemetryAuthMode { match mode { codex_app_server_protocol::AuthMode::ApiKey => Self::ApiKey, codex_app_server_protocol::AuthMode::Chatgpt - | codex_app_server_protocol::AuthMode::ChatgptAuthTokens => Self::Chatgpt, + | codex_app_server_protocol::AuthMode::ChatgptAuthTokens + | codex_app_server_protocol::AuthMode::AgentIdentity => Self::Chatgpt, } } } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 655947a080b6..1fcb32958138 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -917,12 +917,12 @@ pub(crate) fn status_account_display_from_auth_mode( ) -> Option { match auth_mode { Some(AuthMode::ApiKey) => Some(StatusAccountDisplay::ApiKey), - Some(AuthMode::Chatgpt) | Some(AuthMode::ChatgptAuthTokens) => { - Some(StatusAccountDisplay::ChatGpt { - email: None, - plan: plan_type.map(plan_type_display_name), - }) - } + Some(AuthMode::Chatgpt) + | Some(AuthMode::ChatgptAuthTokens) + | Some(AuthMode::AgentIdentity) => Some(StatusAccountDisplay::ChatGpt { + email: None, + plan: plan_type.map(plan_type_display_name), + }), None => None, } } diff --git a/codex-rs/tui/src/local_chatgpt_auth.rs b/codex-rs/tui/src/local_chatgpt_auth.rs index e888c0387c36..1f84b289a78c 100644 --- a/codex-rs/tui/src/local_chatgpt_auth.rs +++ b/codex-rs/tui/src/local_chatgpt_auth.rs @@ -108,6 +108,7 @@ mod tests { account_id: Some("workspace-1".to_string()), }), last_refresh: Some(Utc::now()), + agent_identity: None, }; save_auth(codex_home, &auth, AuthCredentialsStoreMode::File) .expect("chatgpt auth should save"); @@ -154,6 +155,7 @@ mod tests { openai_api_key: Some("sk-test".to_string()), tokens: None, last_refresh: None, + agent_identity: None, }, AuthCredentialsStoreMode::File, )