From ef76b76b8d0fb0a13c17d67cc687923c22719bcc Mon Sep 17 00:00:00 2001 From: shiny-code-bot Date: Wed, 17 Jun 2026 15:16:56 -0400 Subject: [PATCH] Show default login identity in picker --- codex-rs/login/src/auth/manager.rs | 34 +++++++++++ codex-rs/login/src/auth/storage_tests.rs | 28 +++++++++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 37 +++++++++++- .../src/chatwidget/tests/slash_commands.rs | 60 +++++++++++++++++++ 4 files changed, 158 insertions(+), 1 deletion(-) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index ef67965a1783..4e479931fa98 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -1014,6 +1014,40 @@ fn refresh_token_endpoint() -> String { } impl AuthDotJson { + pub fn account_email(&self) -> Option { + match self.resolved_mode() { + ApiAuthMode::AgentIdentity => self + .agent_identity + .as_deref() + .and_then(|jwt| AgentIdentityAuthRecord::from_agent_identity_jwt(jwt).ok()) + .map(|record| record.email), + ApiAuthMode::PersonalAccessToken => None, + ApiAuthMode::ApiKey | ApiAuthMode::Chatgpt | ApiAuthMode::ChatgptAuthTokens => self + .tokens + .as_ref() + .and_then(|tokens| tokens.id_token.email.clone()), + } + } + + pub fn account_id(&self) -> Option { + match self.resolved_mode() { + ApiAuthMode::AgentIdentity => self + .agent_identity + .as_deref() + .and_then(|jwt| AgentIdentityAuthRecord::from_agent_identity_jwt(jwt).ok()) + .map(|record| record.account_id), + ApiAuthMode::PersonalAccessToken => None, + ApiAuthMode::ApiKey | ApiAuthMode::Chatgpt | ApiAuthMode::ChatgptAuthTokens => { + self.tokens.as_ref().and_then(|tokens| { + tokens + .account_id + .clone() + .or(tokens.id_token.chatgpt_account_id.clone()) + }) + } + } + } + fn from_external_tokens(external: &ExternalAuthTokens) -> std::io::Result { let Some(chatgpt_metadata) = external.chatgpt_metadata() else { return Err(std::io::Error::other( diff --git a/codex-rs/login/src/auth/storage_tests.rs b/codex-rs/login/src/auth/storage_tests.rs index debe0e18d37a..50c1d10ad639 100644 --- a/codex-rs/login/src/auth/storage_tests.rs +++ b/codex-rs/login/src/auth/storage_tests.rs @@ -274,6 +274,34 @@ fn auth_with_prefix(prefix: &str) -> AuthDotJson { } } +#[test] +fn auth_dot_json_exposes_agent_identity_account_metadata() { + let agent_identity = jwt_with_payload(json!({ + "iss": "https://chatgpt.com/codex-backend/agent-identity", + "aud": "codex-app-server", + "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", + "email": "agent@example.com", + "plan_type": "pro", + "chatgpt_account_is_fedramp": false, + })); + let auth = AuthDotJson { + auth_mode: Some(AuthMode::AgentIdentity), + openai_api_key: None, + tokens: None, + last_refresh: None, + agent_identity: Some(agent_identity), + personal_access_token: None, + }; + + assert_eq!(auth.account_email().as_deref(), Some("agent@example.com")); + assert_eq!(auth.account_id().as_deref(), Some("account-id")); +} + fn jwt_with_payload(payload: serde_json::Value) -> String { let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); let header_b64 = encode(br#"{"alg":"EdDSA","typ":"JWT"}"#); diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 92f6f45fe7b9..0e34d5559a41 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -15,6 +15,7 @@ use crate::bottom_pane::slash_commands::ServiceTierCommand; use crate::bottom_pane::slash_commands::SlashCommandItem; use crate::bottom_pane::slash_commands::find_slash_command; use crate::goal_display::GOAL_USAGE; +use codex_config::types::AuthCredentialsStoreMode; #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum SlashCommandDispatchSource { @@ -51,11 +52,15 @@ impl ChatWidget { let current_auth_home = self.config.auth_home.to_path_buf(); let default_auth_home = self.config.codex_home.to_path_buf(); let codex_home = self.config.codex_home.to_path_buf(); + let default_description = default_login_profile_description( + &default_auth_home, + self.config.cli_auth_credentials_store_mode, + ); let add_command = "/login add ".to_string(); let mut items = Vec::with_capacity(profiles.len() + 2); items.push(SelectionItem { name: "default".to_string(), - description: Some("Use the default Codex Lab login".to_string()), + description: Some(default_description), is_current: current_auth_home == default_auth_home, actions: vec![Box::new(|tx| { tx.send(AppEvent::SwitchAuthProfile { @@ -1232,3 +1237,33 @@ fn login_profile_description(profile: &codex_login::AuthProfileEntry) -> String details.join(" - ") } } + +fn default_login_profile_description( + codex_home: &std::path::Path, + auth_credentials_store_mode: AuthCredentialsStoreMode, +) -> String { + match codex_login::load_auth_dot_json(codex_home, auth_credentials_store_mode) { + Ok(Some(auth)) => auth_dot_json_login_description(&auth), + Ok(None) => "Use the default Codex Lab login".to_string(), + Err(err) => format!("Default login status unavailable: {err}"), + } +} + +fn auth_dot_json_login_description(auth: &codex_login::AuthDotJson) -> String { + if auth.openai_api_key.is_some() { + return "API key login".to_string(); + } + + let mut details = Vec::new(); + if let Some(email) = auth.account_email() { + details.push(email); + } + if let Some(account_id) = auth.account_id() { + details.push(account_id); + } + if !details.is_empty() { + return details.join(" - "); + } + + "Use the default Codex Lab login".to_string() +} diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index 97961c0fc97b..fb2c12a4441f 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,7 +1,16 @@ use super::*; use crate::app_event::AuthProfileSelection; use crate::bottom_pane::slash_commands::ServiceTierCommand; +use base64::Engine; +use chrono::Utc; +use codex_app_server_protocol::AuthMode; +use codex_config::types::AuthCredentialsStoreMode; +use codex_login::AuthDotJson; +use codex_login::save_auth; +use codex_login::token_data::TokenData; use pretty_assertions::assert_eq; +use serde::Serialize; +use serde_json::json; use serial_test::serial; fn force_pet_image_support(chat: &mut ChatWidget) { @@ -36,6 +45,50 @@ fn fast_tier_command() -> ServiceTierCommand { } } +fn fake_jwt(email: &str, account_id: &str) -> String { + #[derive(Serialize)] + struct Header { + alg: &'static str, + typ: &'static str, + } + + let header = Header { + alg: "none", + typ: "JWT", + }; + let payload = json!({ + "email": email, + "https://api.openai.com/auth": { + "chatgpt_account_id": account_id, + }, + }); + let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes); + let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header")); + let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload")); + let signature_b64 = encode(b"sig"); + format!("{header_b64}.{payload_b64}.{signature_b64}") +} + +fn write_chatgpt_auth(codex_home: &std::path::Path, email: &str, account_id: &str) { + let id_token = fake_jwt(email, account_id); + let access_token = fake_jwt(email, account_id); + let auth = AuthDotJson { + auth_mode: Some(AuthMode::Chatgpt), + openai_api_key: None, + tokens: Some(TokenData { + id_token: codex_login::token_data::parse_chatgpt_jwt_claims(&id_token) + .expect("id token should parse"), + access_token, + refresh_token: "refresh-token".to_string(), + account_id: Some(account_id.to_string()), + }), + last_refresh: Some(Utc::now()), + agent_identity: None, + personal_access_token: None, + }; + save_auth(codex_home, &auth, AuthCredentialsStoreMode::File).expect("chatgpt auth saved"); +} + fn complete_turn_with_message(chat: &mut ChatWidget, turn_id: &str, message: Option<&str>) { if let Some(message) = message { complete_assistant_message( @@ -120,6 +173,11 @@ async fn service_tier_commands_lowercase_catalog_names() { #[tokio::test] async fn login_slash_command_opens_profile_picker() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; + write_chatgpt_auth( + &chat.config.codex_home, + "default@example.com", + "account-default", + ); codex_login::record_auth_profile_login( &chat.config.codex_home, "work", @@ -133,6 +191,8 @@ async fn login_slash_command_opens_profile_picker() { let popup = render_bottom_popup(&chat, /*width*/ 100); assert!(popup.contains("Choose Login")); assert!(popup.contains("default")); + assert!(popup.contains("default@example.com")); + assert!(popup.contains("account-default")); assert!(popup.contains("work")); assert!(popup.contains("me@example.com")); assert!(popup.contains("Add login..."));