diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index f35402efd..ae50cb5c8 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4016,6 +4016,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "app_test_support", "arboard", "assert_matches", "base64 0.22.1", diff --git a/codex-rs/agent-identity/src/lib.rs b/codex-rs/agent-identity/src/lib.rs index 0e29a3870..267fdf53d 100644 --- a/codex-rs/agent-identity/src/lib.rs +++ b/codex-rs/agent-identity/src/lib.rs @@ -119,7 +119,7 @@ pub struct AgentIdentityJwtClaims { pub agent_private_key: String, pub account_id: String, pub chatgpt_user_id: String, - pub email: String, + pub email: Option, pub plan_type: AuthPlanType, pub chatgpt_account_is_fedramp: bool, } @@ -667,13 +667,33 @@ mod tests { 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: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, } ); } + #[test] + fn decode_agent_identity_jwt_accepts_missing_email() { + let jwt = jwt_with_payload(serde_json::json!({ + "iss": AGENT_IDENTITY_JWT_ISSUER, + "aud": AGENT_IDENTITY_JWT_AUDIENCE, + "iat": 1_700_000_000usize, + "exp": 4_000_000_000usize, + "agent_runtime_id": "agent-runtime-id", + "agent_private_key": "private-key", + "account_id": "account-id", + "chatgpt_user_id": "user-id", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + + let claims = decode_agent_identity_jwt(&jwt, /*jwks*/ None).expect("JWT should decode"); + + assert_eq!(claims.email, None); + } + #[test] fn decode_agent_identity_jwt_maps_raw_plan_aliases() { let jwt = jwt_with_payload(serde_json::json!({ @@ -707,7 +727,7 @@ mod tests { 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: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; @@ -739,7 +759,7 @@ mod tests { 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: AuthPlanType::Known(KnownPlan::Pro), chatgpt_account_is_fedramp: false, }; diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 3a2af11db..f44e2f37a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -5900,7 +5900,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/v2/PlanType" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 286fc0e2f..5a8214b3a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -26,7 +26,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" diff --git a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json index 62b520de4..ca641799e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/GetAccountResponse.json @@ -22,7 +22,10 @@ { "properties": { "email": { - "type": "string" + "type": [ + "string", + "null" + ] }, "planType": { "$ref": "#/definitions/PlanType" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts index 09f74469b..1b7953e5d 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/Account.ts @@ -4,4 +4,4 @@ import type { AmazonBedrockCredentialSource } from "../AmazonBedrockCredentialSource"; import type { PlanType } from "../PlanType"; -export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; +export type Account = { "type": "apiKey", } | { "type": "chatgpt", email: string | null, planType: PlanType, } | { "type": "amazonBedrock", credentialSource: AmazonBedrockCredentialSource, }; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 9bcc6a86d..71773f2fc 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2854,7 +2854,7 @@ mod tests { ); let chatgpt = v2::Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Plus, }; assert_eq!( @@ -2866,6 +2866,19 @@ mod tests { serde_json::to_value(&chatgpt)?, ); + let chatgpt_without_email = v2::Account::Chatgpt { + email: None, + plan_type: PlanType::Pro, + }; + assert_eq!( + json!({ + "type": "chatgpt", + "email": null, + "planType": "pro", + }), + serde_json::to_value(&chatgpt_without_email)?, + ); + let codex_managed_bedrock = v2::Account::AmazonBedrock { credential_source: AmazonBedrockCredentialSource::CodexManaged, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/account.rs b/codex-rs/app-server-protocol/src/protocol/v2/account.rs index 348d6810f..635d001bb 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/account.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/account.rs @@ -25,7 +25,11 @@ pub enum Account { #[serde(rename = "chatgpt", rename_all = "camelCase")] #[ts(rename = "chatgpt", rename_all = "camelCase")] - Chatgpt { email: String, plan_type: PlanType }, + Chatgpt { + #[schemars(required, schema_with = "nullable_string_schema")] + email: Option, + plan_type: PlanType, + }, #[serde(rename = "amazonBedrock", rename_all = "camelCase")] #[ts(rename = "amazonBedrock", rename_all = "camelCase")] @@ -35,6 +39,12 @@ pub enum Account { }, } +fn nullable_string_schema( + generator: &mut schemars::r#gen::SchemaGenerator, +) -> schemars::schema::Schema { + generator.subschema_for::>() +} + fn default_bedrock_credential_source() -> AmazonBedrockCredentialSource { AmazonBedrockCredentialSource::AwsManaged } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index e70db8bba..83eec5379 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1941,6 +1941,7 @@ Response examples: { "id": 1, "result": { "account": null, "requiresOpenaiAuth": true } } // OpenAI auth required (typical for OpenAI-hosted models) { "id": 1, "result": { "account": { "type": "apiKey" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "chatgpt", "email": "user@example.com", "planType": "pro" }, "requiresOpenaiAuth": true } } +{ "id": 1, "result": { "account": { "type": "chatgpt", "email": null, "planType": "enterprise" }, "requiresOpenaiAuth": true } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "codexManaged" }, "requiresOpenaiAuth": false } } { "id": 1, "result": { "account": { "type": "amazonBedrock", "credentialSource": "awsManaged" }, "requiresOpenaiAuth": false } } ``` @@ -1948,6 +1949,7 @@ Response examples: Field notes: - `refreshToken` (bool): set `true` to force a token refresh. +- `email` is `null` when the ChatGPT account does not have an email address. - `requiresOpenaiAuth` reflects the active provider; when `false`, Codex can run without OpenAI credentials. - Amazon Bedrock reports `credentialSource: "codexManaged"` when it uses a Bedrock API key managed by Codex. Otherwise it reports `credentialSource: "awsManaged"` for the external AWS credential path. This identifies the selected credential source; it does not validate that the AWS credential chain can resolve credentials. diff --git a/codex-rs/app-server/tests/suite/auth.rs b/codex-rs/app-server/tests/suite/auth.rs index 6e88866c7..e49e841f6 100644 --- a/codex-rs/app-server/tests/suite/auth.rs +++ b/codex-rs/app-server/tests/suite/auth.rs @@ -5,7 +5,10 @@ use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use chrono::Duration; use chrono::Utc; +use codex_app_server_protocol::Account; use codex_app_server_protocol::AuthMode; +use codex_app_server_protocol::GetAccountParams; +use codex_app_server_protocol::GetAccountResponse; use codex_app_server_protocol::GetAuthStatusParams; use codex_app_server_protocol::GetAuthStatusResponse; use codex_app_server_protocol::JSONRPCError; @@ -14,6 +17,7 @@ use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; +use codex_protocol::account::PlanType as AccountPlanType; use pretty_assertions::assert_eq; use std::path::Path; use tempfile::TempDir; @@ -162,7 +166,7 @@ async fn get_auth_status_with_api_key() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> { +async fn personal_access_token_without_email_supports_auth_status_and_account_read() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path())?; @@ -171,7 +175,7 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> .and(path("/v1/user-auth-credential/whoami")) .and(header("Authorization", "Bearer at-test-token")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "email": "user@example.com", + "email": null, "chatgpt_user_id": "user-123", "chatgpt_account_id": "account-123", "chatgpt_plan_type": "pro", @@ -215,6 +219,34 @@ async fn get_auth_status_with_personal_access_token_omits_token() -> Result<()> } ); + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + assert_eq!( + response + .result + .get("account") + .and_then(|account| account.get("email")), + Some(&serde_json::Value::Null), + ); + assert_eq!( + to_response::(response)?, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + server.verify().await; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 06874c423..3cd98e2e5 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -320,7 +320,7 @@ async fn set_auth_token_updates_account_and_notifies() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -388,7 +388,7 @@ async fn account_read_refresh_token_is_noop_in_external_mode() -> Result<()> { account, GetAccountResponse { account: Some(Account::Chatgpt { - email: "embedded@example.com".to_string(), + email: Some("embedded@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1817,7 +1817,7 @@ async fn get_account_with_chatgpt() -> Result<()> { let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Pro, }), requires_openai_auth: true, @@ -1826,6 +1826,51 @@ async fn get_account_with_chatgpt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn get_account_with_chatgpt_without_email() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + CreateConfigTomlParams { + requires_openai_auth: Some(true), + ..Default::default() + }, + )?; + write_chatgpt_auth( + codex_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("pro"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_get_account_request(GetAccountParams { + refresh_token: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(response)?; + + assert_eq!( + received, + GetAccountResponse { + account: Some(Account::Chatgpt { + email: None, + plan_type: AccountPlanType::Pro, + }), + requires_openai_auth: true, + } + ); + Ok(()) +} + #[tokio::test] async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> { let codex_home = TempDir::new()?; @@ -1944,7 +1989,7 @@ async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result let expected = GetAccountResponse { account: Some(Account::Chatgpt { - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: AccountPlanType::Unknown, }), requires_openai_auth: true, diff --git a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs index d45dcdc3a..86ea09504 100644 --- a/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs +++ b/codex-rs/app-server/tests/suite/v2/rate_limit_reset_credits.rs @@ -204,12 +204,11 @@ async fn consume_timeout_releases_account_auth_queue() -> Result<()> { "rate limit reset consume timed out" ); - let account_error = read_error_response(&mut mcp, account_id).await?; - assert_eq!(account_error.error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - account_error.error.message, - "email and plan type are required for chatgpt authentication" - ); + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(account_id)), + ) + .await??; Ok(()) } diff --git a/codex-rs/cloud-config/src/service_tests.rs b/codex-rs/cloud-config/src/service_tests.rs index 47b49d43b..dfd4fe50c 100644 --- a/codex-rs/cloud-config/src/service_tests.rs +++ b/codex-rs/cloud-config/src/service_tests.rs @@ -104,7 +104,7 @@ async fn auth_manager_with_agent_identity_business_plan() -> Arc { agent_private_key: key_material.private_key_pkcs8_base64, account_id: "account-12345".to_string(), chatgpt_user_id: "user-12345".to_string(), - email: "user@example.com".to_string(), + email: Some("user@example.com".to_string()), plan_type: PlanType::Business, chatgpt_account_is_fedramp: false, task_id: Some("task-123".to_string()), diff --git a/codex-rs/login/src/auth/agent_identity.rs b/codex-rs/login/src/auth/agent_identity.rs index a164c7e3a..79ee2e7f5 100644 --- a/codex-rs/login/src/auth/agent_identity.rs +++ b/codex-rs/login/src/auth/agent_identity.rs @@ -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, 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, diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index fbb6aac8e..bcce562e6 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -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, diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a762bd546..26580a672 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -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 { 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, diff --git a/codex-rs/login/src/auth/personal_access_token.rs b/codex-rs/login/src/auth/personal_access_token.rs index 5c9fcc3bd..ea75648d1 100644 --- a/codex-rs/login/src/auth/personal_access_token.rs +++ b/codex-rs/login/src/auth/personal_access_token.rs @@ -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, 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 { diff --git a/codex-rs/login/src/auth/personal_access_token_tests.rs b/codex-rs/login/src/auth/personal_access_token_tests.rs index 4d1b5e1ef..b05edb068 100644 --- a/codex-rs/login/src/auth/personal_access_token_tests.rs +++ b/codex-rs/login/src/auth/personal_access_token_tests.rs @@ -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; } diff --git a/codex-rs/login/src/auth/storage.rs b/codex-rs/login/src/auth/storage.rs index 19ba018f3..b5ae19055 100644 --- a/codex-rs/login/src/auth/storage.rs +++ b/codex-rs/login/src/auth/storage.rs @@ -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, pub plan_type: AccountPlanType, pub chatgpt_account_is_fedramp: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub task_id: Option, } +fn deserialize_optional_non_empty_string<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|value| value.filter(|value| !value.is_empty())) +} + +fn serialize_optional_string_as_empty( + value: &Option, + serializer: S, +) -> Result +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 { let claims = diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index db51a7746..647af79b6 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -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()?; diff --git a/codex-rs/model-provider/src/provider.rs b/codex-rs/model-provider/src/provider.rs index 91a1c06a5..3bd503136 100644 --- a/codex-rs/model-provider/src/provider.rs +++ b/codex-rs/model-provider/src/provider.rs @@ -60,10 +60,7 @@ impl fmt::Display for ProviderAccountError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::MissingChatgptAccountDetails => { - write!( - f, - "email and plan type are required for chatgpt authentication" - ) + write!(f, "plan type is required for chatgpt authentication") } Self::UnsupportedBedrockApiKeyAuth => { write!( @@ -261,12 +258,9 @@ impl ModelProvider for ConfiguredModelProvider { let email = auth.get_account_email(); let plan_type = auth.account_plan_type(); - match (email, plan_type) { - (Some(email), Some(plan_type)) => { - Ok(ProviderAccount::Chatgpt { email, plan_type }) - } - _ => Err(ProviderAccountError::MissingChatgptAccountDetails), - } + plan_type + .map(|plan_type| ProviderAccount::Chatgpt { email, plan_type }) + .ok_or(ProviderAccountError::MissingChatgptAccountDetails) } }) .transpose()? @@ -313,6 +307,7 @@ mod tests { use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::WireApi; use codex_models_manager::manager::RefreshStrategy; + use codex_protocol::account::PlanType; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::openai_models::ModelInfo; use codex_protocol::openai_models::ModelsResponse; @@ -518,7 +513,7 @@ mod tests { } #[test] - fn openai_provider_rejects_chatgpt_account_state_without_email() { + fn openai_provider_returns_chatgpt_account_state_without_email() { let provider = create_model_provider( ModelProviderInfo::create_openai_provider(/*base_url*/ None), Some(AuthManager::from_auth_for_testing( @@ -528,7 +523,13 @@ mod tests { assert_eq!( provider.account_state(), - Err(ProviderAccountError::MissingChatgptAccountDetails) + Ok(ProviderAccountState { + account: Some(ProviderAccount::Chatgpt { + email: None, + plan_type: PlanType::Unknown, + }), + requires_openai_auth: true, + }) ); } diff --git a/codex-rs/protocol/src/account.rs b/codex-rs/protocol/src/account.rs index 11286eddc..dfcac51d3 100644 --- a/codex-rs/protocol/src/account.rs +++ b/codex-rs/protocol/src/account.rs @@ -35,7 +35,7 @@ pub enum PlanType { pub enum ProviderAccount { ApiKey, Chatgpt { - email: String, + email: Option, plan_type: PlanType, }, AmazonBedrock { diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 2a10f00b4..6c0b83a72 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -148,6 +148,7 @@ arboard = { workspace = true } [dev-dependencies] +app_test_support = { workspace = true } codex-cli = { workspace = true } codex-mcp = { workspace = true } core_test_support = { workspace = true } diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 9ed78fed5..527cde57e 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -311,16 +311,19 @@ impl AppServerSession { false, ), Some(Account::Chatgpt { email, plan_type }) => { - let feedback_audience = if email.ends_with("@openai.com") { + let feedback_audience = if email + .as_deref() + .is_some_and(|email| email.ends_with("@openai.com")) + { FeedbackAudience::OpenAiEmployee } else { FeedbackAudience::External }; ( - Some(email.clone()), + email.clone(), Some(TelemetryAuthMode::Chatgpt), Some(StatusAccountDisplay::ChatGpt { - email: Some(email), + email, plan: Some(plan_type_display_name(plan_type)), }), Some(plan_type), diff --git a/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap new file mode 100644 index 000000000..4ac5239b9 --- /dev/null +++ b/codex-rs/tui/src/status/snapshots/codex_tui__status__tests__status_snapshot_shows_chatgpt_plan_without_email.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/status/tests.rs +expression: sanitized +--- +/status + +╭──────────────────────────────────────────────────────────────────────────╮ +│ >_ OpenAI Codex (v0.0.0) │ +│ │ +│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │ +│ information on rate limits and credits │ +│ │ +│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │ +│ Directory: [[workspace]] │ +│ Permissions: Custom (workspace with network access, Ask for approval) │ +│ Agents.md: │ +│ Account: Enterprise │ +│ │ +│ Limits: data not available yet │ +╰──────────────────────────────────────────────────────────────────────────╯ diff --git a/codex-rs/tui/src/status/tests.rs b/codex-rs/tui/src/status/tests.rs index 4ab7a2cfa..50ef2c500 100644 --- a/codex-rs/tui/src/status/tests.rs +++ b/codex-rs/tui/src/status/tests.rs @@ -17,6 +17,9 @@ use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; use crate::token_usage::TokenUsage; use crate::token_usage::TokenUsageInfo; +use app_test_support::ChatGptAuthFixture; +use app_test_support::write_chatgpt_auth; +use app_test_support::write_models_cache; use chrono::Duration as ChronoDuration; use chrono::Local; use chrono::TimeZone; @@ -27,6 +30,7 @@ use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::SpendControlLimitSnapshot; use codex_config::LoaderOverrides; +use codex_config::types::AuthCredentialsStoreMode; use codex_model_provider_info::ModelProviderAwsAuthInfo; use codex_model_provider_info::ModelProviderInfo; use codex_models_manager::test_support::construct_model_info_offline_for_tests; @@ -318,6 +322,67 @@ async fn status_snapshot_includes_reasoning_details() { assert_snapshot!(sanitized); } +#[tokio::test] +async fn status_snapshot_shows_chatgpt_plan_without_email() { + let temp_home = TempDir::new().expect("temp home"); + write_models_cache(temp_home.path()).expect("write models cache"); + let mut config = test_config(&temp_home).await; + config.model = Some("gpt-5.1-codex-max".to_string()); + config.model_provider_id = "openai".to_string(); + config.cli_auth_credentials_store_mode = AuthCredentialsStoreMode::File; + set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs()); + + write_chatgpt_auth( + temp_home.path(), + ChatGptAuthFixture::new("access-chatgpt").plan_type("enterprise"), + AuthCredentialsStoreMode::File, + ) + .expect("write email-less ChatGPT auth"); + let mut app_server = crate::start_embedded_app_server_for_picker(&config) + .await + .expect("start embedded app server"); + let bootstrap = app_server + .bootstrap(&config) + .await + .expect("bootstrap app server session"); + app_server.shutdown().await.expect("shut down app server"); + let account_display = bootstrap + .status_account_display + .expect("bootstrap should return ChatGPT account display"); + assert_eq!( + account_display, + StatusAccountDisplay::ChatGpt { + email: None, + plan: Some("Enterprise".to_string()), + } + ); + let usage = TokenUsage::default(); + let captured_at = chrono::Local + .with_ymd_and_hms(2024, 1, 2, 3, 4, 5) + .single() + .expect("timestamp"); + let model_slug = get_model_offline_for_tests(config.model.as_deref()); + + let composite = new_status_output( + &config, + Some(&account_display), + /*token_info*/ None, + &usage, + &None, + /*thread_name*/ None, + /*forked_from*/ None, + /*rate_limits*/ None, + None, + captured_at, + &model_slug, + /*collaboration_mode*/ None, + /*reasoning_effort_override*/ None, + ); + let sanitized = + sanitize_directory(render_lines(&composite.display_lines(/*width*/ 80))).join("\n"); + assert_snapshot!(sanitized); +} + #[tokio::test] async fn status_permissions_non_default_workspace_write_uses_workspace_label() { let temp_home = TempDir::new().expect("temp home"); diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index a4af5d605..f3b97c6b6 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -503,6 +503,33 @@ def _annotate_schema(value: Any, base: str | None = None) -> None: _annotate_schema(child, base) +def _make_chatgpt_account_email_nullable(schema: dict[str, Any]) -> None: + definitions = schema.get("definitions") + if not isinstance(definitions, dict): + raise RuntimeError("Schema bundle is missing definitions") + + account = definitions.get("Account") + if not isinstance(account, dict): + raise RuntimeError("Schema bundle is missing the Account definition") + + for variant in account.get("oneOf", []): + if not isinstance(variant, dict): + continue + properties = variant.get("properties") + if not isinstance(properties, dict): + continue + account_type = properties.get("type") + if not isinstance(account_type, dict) or account_type.get("enum") != ["chatgpt"]: + continue + email = properties.get("email") + if not isinstance(email, dict): + raise RuntimeError("ChatGPT account schema is missing email") + email["type"] = ["string", "null"] + return + + raise RuntimeError("Schema bundle is missing the ChatGPT account variant") + + def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: """Generate app-server schemas by invoking the installed pinned runtime binary.""" codex_path = pinned_runtime_codex_path() @@ -525,6 +552,7 @@ def generate_schema_from_pinned_runtime(schema_dir: Path) -> Path: def _normalized_schema_bundle_text(schema_dir: Path) -> str: """Normalize the schema bundle before feeding it to the Python type generator.""" schema = json.loads(schema_bundle_path(schema_dir).read_text()) + _make_chatgpt_account_email_nullable(schema) definitions = schema.get("definitions", {}) if isinstance(definitions, dict): for definition in definitions.values(): @@ -580,9 +608,34 @@ def generate_v2_all(schema_dir: Path) -> None: ], cwd=sdk_root(), ) + _require_nullable_chatgpt_account_email(out_path) _normalize_generated_timestamps(out_path) +def _require_nullable_chatgpt_account_email(out_path: Path) -> None: + """Preserve required-but-nullable email semantics in the generated SDK model.""" + source = out_path.read_text() + class_start = source.find("class ChatgptAccount(BaseModel):") + if class_start == -1: + raise RuntimeError("Generated SDK is missing ChatgptAccount") + class_end = source.find("\n\nclass ", class_start) + if class_end == -1: + class_end = len(source) + + class_source = source[class_start:class_end] + nullable_with_default = " email: str | None = None" + if class_source.count(nullable_with_default) != 1: + raise RuntimeError( + "Generated ChatgptAccount email did not have the expected nullable shape" + ) + class_source = class_source.replace( + nullable_with_default, + " email: str | None", + 1, + ) + out_path.write_text(source[:class_start] + class_source + source[class_end:]) + + def _notification_specs(schema_dir: Path) -> list[tuple[str, str]]: """Map each server notification method to its generated payload model class.""" server_notifications = json.loads((schema_dir / "ServerNotification.json").read_text()) diff --git a/sdk/python/src/openai_codex/generated/v2_all.py b/sdk/python/src/openai_codex/generated/v2_all.py index 15ede1801..c024a2c8c 100644 --- a/sdk/python/src/openai_codex/generated/v2_all.py +++ b/sdk/python/src/openai_codex/generated/v2_all.py @@ -4654,7 +4654,7 @@ class ChatgptAccount(BaseModel): model_config = ConfigDict( populate_by_name=True, ) - email: str + email: str | None plan_type: Annotated[PlanType, Field(alias="planType")] type: Annotated[Literal["chatgpt"], Field(title="ChatgptAccountType")] diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 524260035..b2cfda53b 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -10,6 +10,7 @@ from pathlib import Path import pytest import tomllib +from pydantic import ValidationError ROOT = Path(__file__).resolve().parents[1] @@ -306,6 +307,32 @@ def test_schema_normalization_only_flattens_string_literal_oneofs( ] +def test_schema_normalization_makes_chatgpt_account_email_nullable() -> None: + script = _load_update_script_module() + schema = { + "definitions": { + "Account": { + "oneOf": [ + { + "properties": { + "email": {"type": "string"}, + "type": {"enum": ["chatgpt"], "type": "string"}, + }, + "required": ["email", "type"], + "type": "object", + } + ] + } + } + } + + script._make_chatgpt_account_email_nullable(schema) + + chatgpt_account = schema["definitions"]["Account"]["oneOf"][0] + assert chatgpt_account["properties"]["email"]["type"] == ["string", "null"] + assert "email" in chatgpt_account["required"] + + def test_python_codegen_schema_annotation_adds_stable_variant_titles( tmp_path: Path, ) -> None: @@ -350,6 +377,17 @@ def test_generate_v2_all_uses_titles_for_generated_names() -> None: assert "ruff-format" in source +def test_generated_chatgpt_account_email_is_required_nullable() -> None: + from openai_codex.generated.v2_all import ChatgptAccount + + account = ChatgptAccount.model_validate({"email": None, "planType": "pro", "type": "chatgpt"}) + assert account.email is None + assert ChatgptAccount.model_fields["email"].is_required() + + with pytest.raises(ValidationError): + ChatgptAccount.model_validate({"planType": "pro", "type": "chatgpt"}) + + def test_runtime_package_template_has_no_checked_in_binaries() -> None: runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin" assert sorted(