#![allow(clippy::unwrap_used)] use codex_core::AuthManager; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::auth::CLIENT_ID; use codex_core::auth::login_with_api_key; use codex_core::auth::read_openai_api_key_from_env; use codex_login::ServerOptions; use codex_login::ShutdownHandle; use codex_login::run_login_server; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Constraint; use ratatui::layout::Layout; use ratatui::layout::Rect; use ratatui::prelude::Widget; use ratatui::style::Color; use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Block; use ratatui::widgets::BorderType; use ratatui::widgets::Borders; use ratatui::widgets::Paragraph; use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use std::sync::RwLock; use crate::LoginStatus; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; use std::path::PathBuf; use std::sync::Arc; use super::onboarding_screen::StepState; #[derive(Clone)] pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), ChatGptSuccessMessage, ChatGptSuccess, ApiKeyEntry(ApiKeyInputState), ApiKeyConfigured, } const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled."; #[derive(Clone, Default)] pub(crate) struct ApiKeyInputState { value: String, prepopulated_from_env: bool, } #[derive(Clone)] /// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. pub(crate) struct ContinueInBrowserState { auth_url: String, shutdown_flag: Option, } impl Drop for ContinueInBrowserState { fn drop(&mut self) { if let Some(handle) = &self.shutdown_flag { handle.shutdown(); } } } impl KeyboardHandler for AuthModeWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { if self.handle_api_key_entry_key_event(&key_event) { return; } match key_event.code { KeyCode::Up | KeyCode::Char('k') => { if self.is_chatgpt_login_allowed() { self.highlighted_mode = AuthMode::ChatGPT; } } KeyCode::Down | KeyCode::Char('j') => { if self.is_api_login_allowed() { self.highlighted_mode = AuthMode::ApiKey; } } KeyCode::Char('1') => { if self.is_chatgpt_login_allowed() { self.start_chatgpt_login(); } } KeyCode::Char('2') => { if self.is_api_login_allowed() { self.start_api_key_entry(); } else { self.disallow_api_login(); } } KeyCode::Enter => { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { SignInState::PickMode => match self.highlighted_mode { AuthMode::ChatGPT if self.is_chatgpt_login_allowed() => { self.start_chatgpt_login(); } AuthMode::ApiKey if self.is_api_login_allowed() => { self.start_api_key_entry(); } AuthMode::ChatGPT => {} AuthMode::ApiKey => { self.disallow_api_login(); } }, SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } _ => {} } } KeyCode::Esc => { tracing::info!("Esc pressed"); let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); } } _ => {} } } fn handle_paste(&mut self, pasted: String) { let _ = self.handle_api_key_entry_paste(pasted); } } #[derive(Clone)] pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, pub highlighted_mode: AuthMode, pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, pub cli_auth_credentials_store_mode: AuthCredentialsStoreMode, pub login_status: LoginStatus, pub auth_manager: Arc, pub forced_chatgpt_workspace_id: Option, pub forced_login_method: Option, } impl AuthModeWidget { fn is_api_login_allowed(&self) -> bool { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) } fn is_chatgpt_login_allowed(&self) -> bool { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } fn disallow_api_login(&mut self) { self.highlighted_mode = AuthMode::ChatGPT; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); } fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) { let mut lines: Vec = vec![ Line::from(vec![ " ".into(), "Sign in with ChatGPT to use Codex as part of your paid plan".into(), ]), Line::from(vec![ " ".into(), "or connect an API key for usage-based billing".into(), ]), "".into(), ]; let create_mode_item = |idx: usize, selected_mode: AuthMode, text: &str, description: &str| -> Vec> { let is_selected = self.highlighted_mode == selected_mode; let caret = if is_selected { ">" } else { " " }; let line1 = if is_selected { Line::from(vec![ format!("{} {}. ", caret, idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { format!(" {}. {text}", idx + 1).into() }; let line2 = if is_selected { Line::from(format!(" {description}")) .fg(Color::Cyan) .add_modifier(Modifier::DIM) } else { Line::from(format!(" {description}")) .style(Style::default().add_modifier(Modifier::DIM)) }; vec![line1, line2] }; let chatgpt_description = if self.is_chatgpt_login_allowed() { "Usage included with Plus, Pro, and Team plans" } else { "ChatGPT login is disabled" }; lines.extend(create_mode_item( 0, AuthMode::ChatGPT, "Sign in with ChatGPT", chatgpt_description, )); lines.push("".into()); if self.is_api_login_allowed() { lines.extend(create_mode_item( 1, AuthMode::ApiKey, "Provide your own API key", "Pay for what you use", )); lines.push("".into()); } else { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() .into(), ); lines.push("".into()); } lines.push( // AE: Following styles.md, this should probably be Cyan because it's a user input tip. // But leaving this for a future cleanup. " Press Enter to continue".dim().into(), ); if let Some(err) = &self.error { lines.push("".into()); lines.push(err.as_str().red().into()); } Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); } fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { let mut spans = vec![" ".into()]; // Schedule a follow-up frame to keep the shimmer animation going. self.request_frame .schedule_frame_in(std::time::Duration::from_millis(100)); spans.extend(shimmer_spans("Finish signing in via your browser")); let mut lines = vec![spans.into(), "".into()]; let sign_in_state = self.sign_in_state.read().unwrap(); if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); lines.push("".into()); lines.push(Line::from(state.auth_url.as_str().cyan().underlined())); lines.push("".into()); } lines.push(" Press Esc to cancel".dim().into()); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); } fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ "✓ Signed in with your ChatGPT account".fg(Color::Green).into(), "".into(), " Before you start:".into(), "".into(), " Decide how much autonomy you want to grant Codex".into(), Line::from(vec![ " For more details see the ".into(), "\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}".underlined(), ]) .dim(), "".into(), " Codex can make mistakes".into(), " Review the code it writes and commands it runs".dim().into(), "".into(), " Powered by your ChatGPT account".into(), Line::from(vec![ " Uses your plan's rate limits and ".into(), "\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(), ]) .dim(), "".into(), " Press Enter to continue".fg(Color::Cyan).into(), ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); } fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ "✓ Signed in with your ChatGPT account" .fg(Color::Green) .into(), ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); } fn render_api_key_configured(&self, area: Rect, buf: &mut Buffer) { let lines = vec![ "✓ API key configured".fg(Color::Green).into(), "".into(), " Codex will use usage-based billing with your API key.".into(), ]; Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); } fn render_api_key_entry(&self, area: Rect, buf: &mut Buffer, state: &ApiKeyInputState) { let [intro_area, input_area, footer_area] = Layout::vertical([ Constraint::Min(4), Constraint::Length(3), Constraint::Min(2), ]) .areas(area); let mut intro_lines: Vec = vec![ Line::from(vec![ "> ".into(), "Use your own OpenAI API key for usage-based billing".bold(), ]), "".into(), " Paste or type your API key below. It will be stored locally in auth.json.".into(), "".into(), ]; if state.prepopulated_from_env { intro_lines.push(" Detected OPENAI_API_KEY environment variable.".into()); intro_lines.push( " Paste a different key if you prefer to use another account." .dim() .into(), ); intro_lines.push("".into()); } Paragraph::new(intro_lines) .wrap(Wrap { trim: false }) .render(intro_area, buf); let content_line: Line = if state.value.is_empty() { vec!["Paste or type your API key".dim()].into() } else { Line::from(state.value.clone()) }; Paragraph::new(content_line) .wrap(Wrap { trim: false }) .block( Block::default() .title("API key") .borders(Borders::ALL) .border_type(BorderType::Rounded) .border_style(Style::default().fg(Color::Cyan)), ) .render(input_area, buf); let mut footer_lines: Vec = vec![ " Press Enter to save".dim().into(), " Press Esc to go back".dim().into(), ]; if let Some(error) = &self.error { footer_lines.push("".into()); footer_lines.push(error.as_str().red().into()); } Paragraph::new(footer_lines) .wrap(Wrap { trim: false }) .render(footer_area, buf); } fn handle_api_key_entry_key_event(&mut self, key_event: &KeyEvent) -> bool { let mut should_save: Option = None; let mut should_request_frame = false; { let mut guard = self.sign_in_state.write().unwrap(); if let SignInState::ApiKeyEntry(state) = &mut *guard { match key_event.code { KeyCode::Esc => { *guard = SignInState::PickMode; self.error = None; should_request_frame = true; } KeyCode::Enter => { let trimmed = state.value.trim().to_string(); if trimmed.is_empty() { self.error = Some("API key cannot be empty".to_string()); should_request_frame = true; } else { should_save = Some(trimmed); } } KeyCode::Backspace => { if state.prepopulated_from_env { state.value.clear(); state.prepopulated_from_env = false; } else { state.value.pop(); } self.error = None; should_request_frame = true; } KeyCode::Char(c) if !key_event.modifiers.contains(KeyModifiers::CONTROL) && !key_event.modifiers.contains(KeyModifiers::ALT) => { if state.prepopulated_from_env { state.value.clear(); state.prepopulated_from_env = false; } state.value.push(c); self.error = None; should_request_frame = true; } _ => {} } // handled; let guard drop before potential save } else { return false; } } if let Some(api_key) = should_save { self.save_api_key(api_key); } else if should_request_frame { self.request_frame.schedule_frame(); } true } fn handle_api_key_entry_paste(&mut self, pasted: String) -> bool { let trimmed = pasted.trim(); if trimmed.is_empty() { return false; } let mut guard = self.sign_in_state.write().unwrap(); if let SignInState::ApiKeyEntry(state) = &mut *guard { if state.prepopulated_from_env { state.value = trimmed.to_string(); state.prepopulated_from_env = false; } else { state.value.push_str(trimmed); } self.error = None; } else { return false; } drop(guard); self.request_frame.schedule_frame(); true } fn start_api_key_entry(&mut self) { if !self.is_api_login_allowed() { self.disallow_api_login(); return; } self.error = None; let prefill_from_env = read_openai_api_key_from_env(); let mut guard = self.sign_in_state.write().unwrap(); match &mut *guard { SignInState::ApiKeyEntry(state) => { if state.value.is_empty() { if let Some(prefill) = prefill_from_env { state.value = prefill; state.prepopulated_from_env = true; } else { state.prepopulated_from_env = false; } } } _ => { *guard = SignInState::ApiKeyEntry(ApiKeyInputState { value: prefill_from_env.clone().unwrap_or_default(), prepopulated_from_env: prefill_from_env.is_some(), }); } } drop(guard); self.request_frame.schedule_frame(); } fn save_api_key(&mut self, api_key: String) { if !self.is_api_login_allowed() { self.disallow_api_login(); return; } match login_with_api_key( &self.codex_home, &api_key, self.cli_auth_credentials_store_mode, ) { Ok(()) => { self.error = None; self.login_status = LoginStatus::AuthMode(AuthMode::ApiKey); self.auth_manager.reload(); *self.sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured; } Err(err) => { self.error = Some(format!("Failed to save API key: {err}")); let mut guard = self.sign_in_state.write().unwrap(); if let SignInState::ApiKeyEntry(existing) = &mut *guard { if existing.value.is_empty() { existing.value.push_str(&api_key); } existing.prepopulated_from_env = false; } else { *guard = SignInState::ApiKeyEntry(ApiKeyInputState { value: api_key, prepopulated_from_env: false, }); } } } self.request_frame.schedule_frame(); } fn start_chatgpt_login(&mut self) { // If we're already authenticated with ChatGPT, don't start a new login – // just proceed to the success message flow. if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); return; } self.error = None; let opts = ServerOptions::new( self.codex_home.clone(), CLIENT_ID.to_string(), self.forced_chatgpt_workspace_id.clone(), self.cli_auth_credentials_store_mode, ); match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); let request_frame = self.request_frame.clone(); let auth_manager = self.auth_manager.clone(); tokio::spawn(async move { let auth_url = child.auth_url.clone(); { *sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { auth_url, shutdown_flag: Some(child.cancel_handle()), }); } request_frame.schedule_frame(); let r = child.block_until_done().await; match r { Ok(()) => { // Force the auth manager to reload the new auth information. auth_manager.reload(); *sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage; request_frame.schedule_frame(); } _ => { *sign_in_state.write().unwrap() = SignInState::PickMode; // self.error = Some(e.to_string()); request_frame.schedule_frame(); } } }); } Err(e) => { *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.error = Some(e.to_string()); self.request_frame.schedule_frame(); } } } } impl StepStateProvider for AuthModeWidget { fn get_step_state(&self) -> StepState { let sign_in_state = self.sign_in_state.read().unwrap(); match &*sign_in_state { SignInState::PickMode | SignInState::ApiKeyEntry(_) | SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptSuccessMessage => StepState::InProgress, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, } } } impl WidgetRef for AuthModeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let sign_in_state = self.sign_in_state.read().unwrap(); match &*sign_in_state { SignInState::PickMode => { self.render_pick_mode(area, buf); } SignInState::ChatGptContinueInBrowser(_) => { self.render_continue_in_browser(area, buf); } SignInState::ChatGptSuccessMessage => { self.render_chatgpt_success_message(area, buf); } SignInState::ChatGptSuccess => { self.render_chatgpt_success(area, buf); } SignInState::ApiKeyEntry(state) => { self.render_api_key_entry(area, buf, state); } SignInState::ApiKeyConfigured => { self.render_api_key_configured(area, buf); } } } } #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; use tempfile::TempDir; use codex_core::auth::AuthCredentialsStoreMode; fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { let codex_home = TempDir::new().unwrap(); let codex_home_path = codex_home.path().to_path_buf(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), highlighted_mode: AuthMode::ChatGPT, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), cli_auth_credentials_store_mode: AuthCredentialsStoreMode::File, login_status: LoginStatus::NotAuthenticated, auth_manager: AuthManager::shared( codex_home_path, false, AuthCredentialsStoreMode::File, ), forced_chatgpt_workspace_id: None, forced_login_method: Some(ForcedLoginMethod::Chatgpt), }; (widget, codex_home) } #[test] fn api_key_flow_disabled_when_chatgpt_forced() { let (mut widget, _tmp) = widget_forced_chatgpt(); widget.start_api_key_entry(); assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE)); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::PickMode )); } #[test] fn saving_api_key_is_blocked_when_chatgpt_forced() { let (mut widget, _tmp) = widget_forced_chatgpt(); widget.save_api_key("sk-test".to_string()); assert_eq!(widget.error.as_deref(), Some(API_KEY_DISABLED_MESSAGE)); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::PickMode )); assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); } }