From 69c8913e24f2f455c8f000fa7afe039a38bdd48d Mon Sep 17 00:00:00 2001 From: efrazer-oai Date: Tue, 21 Apr 2026 22:33:24 -0700 Subject: [PATCH] feat: add explicit AgentIdentity auth mode (#18785) ## Summary This PR adds `CodexAuth::AgentIdentity` as an explicit auth mode. An AgentIdentity auth record is a standalone `auth.json` mode. When `AuthManager::auth().await` loads that mode, it registers one process-scoped task and stores it in runtime-only state on the auth value. Header creation stays synchronous after that because the task is initialized before callers receive the auth object. This PR also removes the old feature flag path. AgentIdentity is selected by explicit auth mode, not by a hidden flag or lazy mutation of ChatGPT auth records. Reference old stack: https://github.com/openai/codex/pull/17387/changes ## Design Decisions - AgentIdentity is a real auth enum variant because it can be the only credential in `auth.json`. - The process task is ephemeral runtime state. It is not serialized and is not stored in rollout/session data. - Account/user metadata needed by existing Codex backend checks lives on the AgentIdentity record for now. - `is_chatgpt_auth()` remains token-specific. - `uses_codex_backend()` is the broader predicate for ChatGPT-token auth and AgentIdentity auth. ## Stack 1. https://github.com/openai/codex/pull/18757: full revert 2. https://github.com/openai/codex/pull/18871: isolated Agent Identity crate 3. This PR: explicit AgentIdentity auth mode and startup task allocation 4. https://github.com/openai/codex/pull/18811: migrate Codex backend auth callsites through AuthProvider 5. https://github.com/openai/codex/pull/18904: accept AgentIdentity JWTs and load `CODEX_AGENT_IDENTITY` ## Testing Tests: targeted Rust checks, cargo-shear, Bazel lock check, and CI. --- codex-rs/Cargo.lock | 2 + codex-rs/Cargo.toml | 1 + codex-rs/agent-identity/Cargo.toml | 1 + codex-rs/agent-identity/src/lib.rs | 65 +++++++ .../schema/json/ServerNotification.json | 7 + .../codex_app_server_protocol.schemas.json | 7 + .../codex_app_server_protocol.v2.schemas.json | 7 + .../json/v2/AccountUpdatedNotification.json | 7 + .../schema/typescript/AuthMode.ts | 2 +- .../src/protocol/common.rs | 5 + .../app-server/src/codex_message_processor.rs | 8 +- .../src/transport/remote_control/tests.rs | 3 + .../src/transport/remote_control/websocket.rs | 3 + .../app-server/tests/common/auth_fixtures.rs | 1 + .../app-server/tests/suite/v2/app_list.rs | 1 + codex-rs/chatgpt/src/chatgpt_token.rs | 1 + codex-rs/chatgpt/src/connectors.rs | 1 + codex-rs/cli/src/login.rs | 4 + codex-rs/cloud-requirements/src/lib.rs | 7 + codex-rs/cloud-tasks/src/util.rs | 1 + codex-rs/core/src/client.rs | 4 +- codex-rs/core/src/config/mod.rs | 4 + codex-rs/login/Cargo.toml | 1 + codex-rs/login/src/auth/agent_identity.rs | 84 ++++++++ codex-rs/login/src/auth/auth_tests.rs | 3 + codex-rs/login/src/auth/manager.rs | 182 +++++++++++++----- codex-rs/login/src/auth/mod.rs | 1 + codex-rs/login/src/auth/storage.rs | 15 ++ codex-rs/login/src/auth/storage_tests.rs | 35 ++++ codex-rs/login/src/server.rs | 1 + codex-rs/login/tests/suite/auth_refresh.rs | 23 +++ codex-rs/login/tests/suite/logout.rs | 2 + codex-rs/model-provider-info/src/lib.rs | 5 +- codex-rs/models-manager/src/manager_tests.rs | 1 + codex-rs/otel/src/lib.rs | 3 +- codex-rs/tui/src/app_server_session.rs | 12 +- codex-rs/tui/src/local_chatgpt_auth.rs | 2 + 37 files changed, 450 insertions(+), 62 deletions(-) create mode 100644 codex-rs/login/src/auth/agent_identity.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bc5522ea5..980f3b87a 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 ad9442279..cb5879187 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 079f57bcb..7976c3354 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 a3c6d1f7a..a6d7e25df 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 144344468..167c63791 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 7af378859..5e1e44bdd 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 9ba97ed05..d91c1c160 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 64109ca2a..e7546f557 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 5e0cad886..210e54c4a 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 4179e18b5..f59551057 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 466edd2b9..6c4b55927 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 99946b491..6b0051f8d 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 b68c98e73..4eb58a87f 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 99334f077..86f0fb456 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 7aa803452..78a915d17 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 d20a7e57c..fe19c3015 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 c054d1b8d..4c6f05a68 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 fd0dfee3a..42241aa93 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 914958d2c..0b50aa834 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 cbaed17be..525ea3b59 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 5a2ba1d0d..77022029f 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 12b5b2c6c..185dc9f93 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -646,6 +646,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 d5303ea54..026a3edf0 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 000000000..e8f81f39f --- /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 c008b0fc8..6f17822e7 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 449591da9..6cc87386f 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 b927f9a77..07c44983a 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 97e801415..e2e801169 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 4bf72c11b..c06a8cfde 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 169a8a309..0c7b81018 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 bf9e03bc2..3ae0eb5fa 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 a53006602..e703b15eb 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 233897e0e..b1ffb7379 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 d004dd894..5966df616 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 c7d0b7c41..0ea401140 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 655947a08..1fcb32958 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 e888c0387..1f84b289a 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, )