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.
This commit is contained in:
efrazer-oai authored and GitHub committed 2026-04-21 22:33:24 -07:00
1 parent 0fef35dc3a
commit 69c8913e24
37 files changed
+450 -62

No files matched your search

+1
View File
@@ -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 }
+84
View File
@@ -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<OnceCell<String>>,
}
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<String>) -> 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,
}
}
}
+3
View File
@@ -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),
+132 -50
View File
@@ -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<TokenData, std::io::Error> {
let auth_dot_json: Option<AuthDotJson> = 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<String>,
) -> 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<String> {
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<String> {
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<String> {
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<AccountPlanType> {
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<AuthDotJson> {
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<TokenData> {
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<Option<String>>,
chatgpt_base_url: Option<String>,
refresh_lock: Semaphore,
external_auth: RwLock<Option<Arc<dyn ExternalAuth>>>,
}
@@ -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<String>;
/// 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<String>,
) -> 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<dyn ExternalAuth>
@@ -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<String>) {
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<String>,
) -> Arc<Self> {
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);
+1
View File
@@ -1,3 +1,4 @@
mod agent_identity;
pub mod default_client;
pub mod error;
mod storage;
+15
View File
@@ -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<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_identity: Option<AgentIdentityAuthRecord>,
}
#[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 {
+35
View File
@@ -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)?;
+1
View File
@@ -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)
})
@@ -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 {
+2
View File
@@ -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,
}
}