mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
0fef35dc3a
commit
69c8913e24
Generated
+2
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<String>,
|
||||
#[serde(default, rename = "taskId")]
|
||||
task_id_camel: Option<String>,
|
||||
#[serde(default)]
|
||||
encrypted_task_id: Option<String>,
|
||||
#[serde(default, rename = "encryptedTaskId")]
|
||||
encrypted_task_id_camel: Option<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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<String> {
|
||||
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,
|
||||
|
||||
@@ -436,6 +436,13 @@
|
||||
"chatgptAuthTokens"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
|
||||
"enum": [
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+7
@@ -5856,6 +5856,13 @@
|
||||
"chatgptAuthTokens"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
|
||||
"enum": [
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+7
@@ -777,6 +777,13 @@
|
||||
"chatgptAuthTokens"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
|
||||
"enum": [
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
"chatgptAuthTokens"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"description": "Programmatic Codex auth backed by a registered Agent Identity.",
|
||||
"enum": [
|
||||
"agentIdentity"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
+1
-1
@@ -5,4 +5,4 @@
|
||||
/**
|
||||
* Authentication mode for OpenAI-backed providers.
|
||||
*/
|
||||
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens";
|
||||
export type AuthMode = "apikey" | "chatgpt" | "chatgptAuthTokens" | "agentIdentity";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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::<TransportEvent>(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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
)?;
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -66,6 +66,7 @@ pub async fn load_auth_manager() -> Option<AuthManager> {
|
||||
config.codex_home.to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
config.cli_auth_credentials_store_mode,
|
||||
Some(config.chatgpt_base_url),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -646,6 +646,10 @@ impl AuthManagerConfig for Config {
|
||||
fn forced_chatgpt_workspace_id(&self) -> Option<String> {
|
||||
self.forced_chatgpt_workspace_id.clone()
|
||||
}
|
||||
|
||||
fn chatgpt_base_url(&self) -> String {
|
||||
self.chatgpt_base_url.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
mod agent_identity;
|
||||
pub mod default_client;
|
||||
pub mod error;
|
||||
mod storage;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -229,7 +229,10 @@ impl ModelProviderInfo {
|
||||
}
|
||||
|
||||
pub fn to_api_provider(&self, auth_mode: Option<AuthMode>) -> CodexResult<ApiProvider> {
|
||||
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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -52,7 +52,8 @@ impl From<codex_app_server_protocol::AuthMode> 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -917,12 +917,12 @@ pub(crate) fn status_account_display_from_auth_mode(
|
||||
) -> Option<StatusAccountDisplay> {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user