diff --git a/codex-rs/app-server/tests/suite/v2/account.rs b/codex-rs/app-server/tests/suite/v2/account.rs index 0c9680095..d4d702a0d 100644 --- a/codex-rs/app-server/tests/suite/v2/account.rs +++ b/codex-rs/app-server/tests/suite/v2/account.rs @@ -1227,3 +1227,45 @@ async fn get_account_with_chatgpt() -> Result<()> { assert_eq!(received, expected); Ok(()) } + +#[tokio::test] +async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> 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").email("user@example.com"), + AuthCredentialsStoreMode::File, + )?; + + let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let params = GetAccountParams { + refresh_token: false, + }; + let request_id = mcp.send_get_account_request(params).await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let received: GetAccountResponse = to_response(resp)?; + + let expected = GetAccountResponse { + account: Some(Account::Chatgpt { + email: "user@example.com".to_string(), + plan_type: AccountPlanType::Unknown, + }), + requires_openai_auth: true, + }; + assert_eq!(received, expected); + Ok(()) +} diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index 217334b2a..cca57d5c0 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -266,6 +266,8 @@ impl CodexAuth { /// 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. pub fn account_plan_type(&self) -> Option { let map_known = |kp: &InternalKnownPlan| match kp { InternalKnownPlan::Free => AccountPlanType::Free, @@ -278,12 +280,15 @@ impl CodexAuth { InternalKnownPlan::Edu => AccountPlanType::Edu, }; - self.get_current_token_data() - .and_then(|t| t.id_token.chatgpt_plan_type) - .map(|pt| match pt { - InternalPlanType::Known(k) => map_known(&k), - InternalPlanType::Unknown(_) => AccountPlanType::Unknown, - }) + self.get_current_token_data().map(|t| { + t.id_token + .chatgpt_plan_type + .map(|pt| match pt { + InternalPlanType::Known(k) => map_known(&k), + InternalPlanType::Unknown(_) => AccountPlanType::Unknown, + }) + .unwrap_or(AccountPlanType::Unknown) + }) } /// Returns `None` if `is_chatgpt_auth()` is false. @@ -1377,7 +1382,7 @@ mod tests { let fake_jwt = write_auth_file( AuthFileParams { openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), + chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: None, }, codex_home.path(), @@ -1447,7 +1452,7 @@ mod tests { let fake_jwt = write_auth_file( AuthFileParams { openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), + chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: None, }, codex_home.path(), @@ -1528,7 +1533,7 @@ mod tests { struct AuthFileParams { openai_api_key: Option, - chatgpt_plan_type: String, + chatgpt_plan_type: Option, chatgpt_account_id: Option, } @@ -1545,11 +1550,14 @@ mod tests { typ: "JWT", }; let mut auth_payload = serde_json::json!({ - "chatgpt_plan_type": params.chatgpt_plan_type, "chatgpt_user_id": "user-12345", "user_id": "user-12345", }); + if let Some(chatgpt_plan_type) = params.chatgpt_plan_type { + auth_payload["chatgpt_plan_type"] = serde_json::Value::String(chatgpt_plan_type); + } + if let Some(chatgpt_account_id) = params.chatgpt_account_id { let org_value = serde_json::Value::String(chatgpt_account_id); auth_payload["chatgpt_account_id"] = org_value; @@ -1650,7 +1658,7 @@ mod tests { let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), + chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: Some("org_another_org".to_string()), }, codex_home.path(), @@ -1675,7 +1683,7 @@ mod tests { let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), + chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: Some("org_mine".to_string()), }, codex_home.path(), @@ -1729,7 +1737,7 @@ mod tests { let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, - chatgpt_plan_type: "pro".to_string(), + chatgpt_plan_type: Some("pro".to_string()), chatgpt_account_id: None, }, codex_home.path(), @@ -1749,7 +1757,27 @@ mod tests { let _jwt = write_auth_file( AuthFileParams { openai_api_key: None, - chatgpt_plan_type: "mystery-tier".to_string(), + chatgpt_plan_type: Some("mystery-tier".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth(codex_home.path(), false, AuthCredentialsStoreMode::File) + .expect("load auth") + .expect("auth available"); + + pretty_assertions::assert_eq!(auth.account_plan_type(), Some(AccountPlanType::Unknown)); + } + + #[test] + fn missing_plan_type_maps_to_unknown() { + let codex_home = tempdir().unwrap(); + let _jwt = write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: None, chatgpt_account_id: None, }, codex_home.path(),