mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Allow ChatGPT accounts without email (#28991)
# Summary Codex required every ChatGPT account to have an email address. A service-account personal access token can return valid account metadata without one, so PAT login failed while decoding the metadata response. This change makes email optional in the account metadata type that owns it and preserves that absence through authentication, provider account state, the app-server API, generated clients, and TUI bootstrap. Existing accounts with email addresses keep the same behavior. ## Behavior-changing call sites | Call site | Behavior after this change | | --- | --- | | `login/src/auth/personal_access_token.rs` | PAT metadata accepts a missing or null email and retains `None`. | | `agent-identity/src/lib.rs` | Agent Identity JWT claims accept an omitted email. | | `login/src/auth/storage.rs` and `login/src/auth/agent_identity.rs` | Stored and managed Agent Identity records carry `Option<String>`. Deserialization maps the legacy empty-string sentinel to `None`. | | `login/src/auth/manager.rs` | `get_account_email` returns the stored option, and managed identity bootstrap no longer converts `None` to an empty string. | | `model-provider/src/provider.rs` and `protocol/src/account.rs` | A ChatGPT provider account requires a plan type but may carry no email. | | `app-server-protocol/src/protocol/v2/account.rs` | `account/read` keeps the `email` field on the wire and returns `null` when the account has no email. Generated TypeScript and JSON schemas describe a required, nullable field. | | `sdk/python/src/openai_codex/generated/v2_all.py` | The generated Python `ChatgptAccount` model accepts `None` for email. | | `tui/src/app_server_session.rs` | Email-less ChatGPT accounts bootstrap normally, keep external feedback routing, omit account-email telemetry, and display the plan in account status. | ## Design decisions - Missing email remains `None` at every layer. The code never uses an empty string as a substitute. - The app-server response includes `"email": null` instead of omitting the field. Clients retain a stable response shape. - Plan type remains required for provider account state. This change relaxes only the email assumption. ## Testing Tests: affected test targets compile, scoped Clippy and formatting pass, a focused TUI snapshot covers plan-only account status, real before/after PAT login smoke covers metadata without email, app-server smoke covers `account/read` with `email: null`, and a regression smoke covers an existing email-bearing PAT. Unit tests run in CI. ## Evidence Visual smoke evidence will be attached here.
This commit is contained in:
1 parent
1659c4a629
commit
5a67d898a5
29 files changed
+485
-61
No files matched your search
@@ -90,7 +90,7 @@ pub struct AgentIdentityAuth {
|
||||
pub(super) struct ManagedChatGptAgentIdentityBinding {
|
||||
pub(super) account_id: String,
|
||||
pub(super) chatgpt_user_id: String,
|
||||
pub(super) email: String,
|
||||
pub(super) email: Option<String>,
|
||||
pub(super) plan_type: AccountPlanType,
|
||||
pub(super) chatgpt_account_is_fedramp: bool,
|
||||
pub(super) access_token: String,
|
||||
@@ -156,8 +156,8 @@ impl AgentIdentityAuth {
|
||||
&self.record.chatgpt_user_id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
&self.record.email
|
||||
pub fn email(&self) -> Option<&str> {
|
||||
self.record.email.as_deref()
|
||||
}
|
||||
|
||||
pub fn plan_type(&self) -> AccountPlanType {
|
||||
@@ -363,7 +363,7 @@ mod tests {
|
||||
agent_private_key: private_key,
|
||||
account_id: "account-1".to_string(),
|
||||
chatgpt_user_id: "user-1".to_string(),
|
||||
email: "agent@example.com".to_string(),
|
||||
email: Some("agent@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Plus,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
|
||||
@@ -607,7 +607,7 @@ async fn chatgpt_auth_task_registration_retry_exhaustion_is_fallback_eligible()
|
||||
)?;
|
||||
let mut record = agent_identity_record("account-123");
|
||||
record.chatgpt_user_id = "user-12345".to_string();
|
||||
record.email = "user@example.com".to_string();
|
||||
record.email = Some("user@example.com".to_string());
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_path = get_auth_file(codex_home.path());
|
||||
let mut auth_json = storage.try_read_auth_json(&auth_path)?;
|
||||
@@ -1926,7 +1926,7 @@ fn agent_identity_record(account_id: &str) -> AgentIdentityAuthRecord {
|
||||
agent_private_key: key_material.private_key_pkcs8_base64,
|
||||
account_id: account_id.to_string(),
|
||||
chatgpt_user_id: "user-id".to_string(),
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
|
||||
@@ -506,8 +506,8 @@ impl CodexAuth {
|
||||
/// Returns `None` if Codex backend auth does not expose an account email.
|
||||
pub fn get_account_email(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::AgentIdentity(auth) => Some(auth.email().to_string()),
|
||||
Self::PersonalAccessToken(auth) => Some(auth.email().to_string()),
|
||||
Self::AgentIdentity(auth) => auth.email().map(str::to_string),
|
||||
Self::PersonalAccessToken(auth) => auth.email().map(str::to_string),
|
||||
_ => self.get_current_token_data().and_then(|t| t.id_token.email),
|
||||
}
|
||||
}
|
||||
@@ -723,7 +723,7 @@ impl ManagedChatGptAgentIdentityBinding {
|
||||
Some(Self {
|
||||
account_id,
|
||||
chatgpt_user_id,
|
||||
email: token_data.id_token.email.clone().unwrap_or_default(),
|
||||
email: token_data.id_token.email.clone(),
|
||||
plan_type: auth.account_plan_type().unwrap_or(AccountPlanType::Unknown),
|
||||
chatgpt_account_is_fedramp: auth.is_fedramp_account(),
|
||||
access_token: token_data.access_token,
|
||||
|
||||
@@ -14,7 +14,7 @@ const WHOAMI_PATH: &str = "/v1/user-auth-credential/whoami";
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
|
||||
struct PersonalAccessTokenMetadata {
|
||||
email: String,
|
||||
email: Option<String>,
|
||||
chatgpt_user_id: String,
|
||||
chatgpt_account_id: String,
|
||||
chatgpt_plan_type: String,
|
||||
@@ -63,8 +63,8 @@ impl PersonalAccessTokenAuth {
|
||||
&self.metadata.chatgpt_user_id
|
||||
}
|
||||
|
||||
pub fn email(&self) -> &str {
|
||||
&self.metadata.email
|
||||
pub fn email(&self) -> Option<&str> {
|
||||
self.metadata.email.as_deref()
|
||||
}
|
||||
|
||||
pub fn plan_type(&self) -> AccountPlanType {
|
||||
|
||||
@@ -40,7 +40,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
|
||||
PersonalAccessTokenAuth {
|
||||
access_token: "at-example".to_string(),
|
||||
metadata: PersonalAccessTokenMetadata {
|
||||
email: "user@example.com".to_string(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
chatgpt_user_id: "user-123".to_string(),
|
||||
chatgpt_account_id: "account-123".to_string(),
|
||||
chatgpt_plan_type: "enterprise".to_string(),
|
||||
@@ -52,7 +52,7 @@ async fn hydrate_sends_bearer_token_and_preserves_metadata() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hydrate_rejects_missing_email() {
|
||||
async fn hydrate_preserves_missing_email() {
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(WHOAMI_PATH))
|
||||
@@ -62,13 +62,22 @@ async fn hydrate_rejects_missing_email() {
|
||||
.await;
|
||||
|
||||
let endpoint = whoami_endpoint(&server.uri());
|
||||
let err = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
|
||||
let auth = hydrate_personal_access_token(&create_client(), &endpoint, "at-example")
|
||||
.await
|
||||
.expect_err("personal access token hydration should reject missing email");
|
||||
.expect("personal access token hydration should accept missing email");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("failed to decode personal access token metadata")
|
||||
assert_eq!(
|
||||
auth,
|
||||
PersonalAccessTokenAuth {
|
||||
access_token: "at-example".to_string(),
|
||||
metadata: PersonalAccessTokenMetadata {
|
||||
email: None,
|
||||
chatgpt_user_id: "user-123".to_string(),
|
||||
chatgpt_account_id: "account-123".to_string(),
|
||||
chatgpt_plan_type: "enterprise".to_string(),
|
||||
chatgpt_account_is_fedramp: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
server.verify().await;
|
||||
}
|
||||
@@ -92,13 +92,37 @@ pub struct AgentIdentityAuthRecord {
|
||||
pub agent_private_key: String,
|
||||
pub account_id: String,
|
||||
pub chatgpt_user_id: String,
|
||||
pub email: String,
|
||||
#[serde(
|
||||
default,
|
||||
deserialize_with = "deserialize_optional_non_empty_string",
|
||||
serialize_with = "serialize_optional_string_as_empty"
|
||||
)]
|
||||
pub email: Option<String>,
|
||||
pub plan_type: AccountPlanType,
|
||||
pub chatgpt_account_is_fedramp: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub task_id: Option<String>,
|
||||
}
|
||||
|
||||
fn deserialize_optional_non_empty_string<'de, D>(
|
||||
deserializer: D,
|
||||
) -> Result<Option<String>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
Option::<String>::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty()))
|
||||
}
|
||||
|
||||
fn serialize_optional_string_as_empty<S>(
|
||||
value: &Option<String>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
value.as_deref().unwrap_or_default().serialize(serializer)
|
||||
}
|
||||
|
||||
impl AgentIdentityAuthRecord {
|
||||
pub(crate) fn from_agent_identity_jwt(jwt: &str) -> std::io::Result<Self> {
|
||||
let claims =
|
||||
|
||||
@@ -102,7 +102,7 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re
|
||||
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(),
|
||||
email: Some("user@example.com".to_string()),
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: Some("task-id".to_string()),
|
||||
@@ -124,6 +124,85 @@ async fn file_storage_round_trips_registered_agent_identity_auth() -> anyhow::Re
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_loads_empty_agent_identity_email_as_none() -> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
let storage = FileAuthStorage::new(codex_home.path().to_path_buf());
|
||||
let auth_file = get_auth_file(codex_home.path());
|
||||
std::fs::write(
|
||||
&auth_file,
|
||||
serde_json::to_string_pretty(&json!({
|
||||
"auth_mode": "chatgpt",
|
||||
"agent_identity": {
|
||||
"agent_runtime_id": "agent-runtime-id",
|
||||
"agent_private_key": "private-key",
|
||||
"account_id": "account-id",
|
||||
"chatgpt_user_id": "user-id",
|
||||
"email": "",
|
||||
"plan_type": "pro",
|
||||
"chatgpt_account_is_fedramp": false,
|
||||
},
|
||||
}))?,
|
||||
)?;
|
||||
|
||||
let loaded = storage.load()?;
|
||||
|
||||
assert_eq!(
|
||||
loaded,
|
||||
Some(AuthDotJson {
|
||||
auth_mode: Some(AuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(AgentIdentityStorage::Record(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: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
})),
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_writes_missing_agent_identity_email_as_empty_string() -> 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::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: None,
|
||||
last_refresh: None,
|
||||
agent_identity: Some(AgentIdentityStorage::Record(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: None,
|
||||
plan_type: AccountPlanType::Pro,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
task_id: None,
|
||||
})),
|
||||
personal_access_token: None,
|
||||
bedrock_api_key: None,
|
||||
};
|
||||
|
||||
storage.save(&auth_dot_json)?;
|
||||
|
||||
let auth_file = get_auth_file(codex_home.path());
|
||||
let saved: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(auth_file)?)?;
|
||||
assert_eq!(saved["agent_identity"]["email"], "");
|
||||
assert_eq!(storage.load()?, Some(auth_dot_json));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn file_storage_round_trips_personal_access_token_auth() -> anyhow::Result<()> {
|
||||
let codex_home = tempdir()?;
|
||||
|
||||
Reference in new issue
Block a user