//! Authentication step UI and state transitions used by onboarding.//!//! This module owns the auth-step state machine (ChatGPT login/device-code/API//! key), renders the corresponding UI, and handles auth-scoped keyboard input.//! It intentionally does not decide onboarding flow completion; the enclosing//! onboarding screen coordinates step progression.#![allow(clippy::unwrap_used)]use codex_app_server_client::AppServerRequestHandle;use codex_app_server_protocol::AccountLoginCompletedNotification;use codex_app_server_protocol::AccountUpdatedNotification;#[cfg(test)]use codex_app_server_protocol::AuthMode as AppServerAuthMode;use codex_app_server_protocol::CancelLoginAccountParams;use codex_app_server_protocol::ClientRequest;use codex_app_server_protocol::LoginAccountParams;use codex_app_server_protocol::LoginAccountResponse;use codex_login::read_openai_api_key_from_env;use crossterm::event::KeyCode;use crossterm::event::KeyEvent;use crossterm::event::KeyEventKind;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_protocol::config_types::ForcedLoginMethod;use std::cell::Cell;use std::sync::Arc;use std::sync::RwLock;use uuid::Uuid;use crate::LoginStatus;use crate::key_hint::KeyBinding;use crate::key_hint::KeyBindingListExt;use crate::motion::MotionMode;use crate::motion::shimmer_text;use crate::onboarding::keys;use crate::onboarding::onboarding_screen::KeyboardHandler;use crate::onboarding::onboarding_screen::StepStateProvider;use crate::tui::FrameRequester;/// Marks buffer cells that have cyan+underlined style as an OSC 8 hyperlink.////// Terminal emulators recognise the OSC 8 escape sequence and treat the entire/// marked region as a single clickable link, regardless of row wrapping. This/// is necessary because ratatui's cell-based rendering emits `MoveTo` at every/// row boundary, which breaks normal terminal URL detection for long URLs that/// wrap across multiple rows.pub(crate) fn mark_url_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { crate::terminal_hyperlinks::mark_url_hyperlink(buf, area, url);}/// Marks any underlined buffer cells as an OSC 8 hyperlink.pub(crate) fn mark_underlined_hyperlink(buf: &mut Buffer, area: Rect, url: &str) { crate::terminal_hyperlinks::mark_underlined_hyperlink(buf, area, url);}use super::onboarding_screen::StepState;mod headless_chatgpt_login;#[derive(Clone)]pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), #[allow(dead_code)] ChatGptDeviceCode(ContinueWithDeviceCodeState), ChatGptSuccessMessage, ChatGptSuccess, ApiKeyEntry(ApiKeyInputState), ApiKeyConfigured,}#[derive(Clone, Copy, Debug, PartialEq, Eq)]pub(crate) enum SignInOption { ChatGpt, DeviceCode, ApiKey,}const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";fn onboarding_request_id() -> codex_app_server_protocol::RequestId { codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())}pub(super) async fn cancel_login_attempt( request_handle: &AppServerRequestHandle, login_id: String,) { let _ = request_handle .request_typed::<codex_app_server_protocol::CancelLoginAccountResponse>( ClientRequest::CancelLoginAccount { request_id: onboarding_request_id(), params: CancelLoginAccountParams { login_id }, }, ) .await;}#[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 { login_id: String, auth_url: String,}#[derive(Clone)]pub(crate) struct ContinueWithDeviceCodeState { request_id: String, login_id: Option<String>, verification_url: Option<String>, user_code: Option<String>,}impl ContinueWithDeviceCodeState { pub(crate) fn pending(request_id: String) -> Self { Self { request_id, login_id: None, verification_url: None, user_code: None, } } pub(crate) fn ready( request_id: String, login_id: String, verification_url: String, user_code: String, ) -> Self { Self { request_id, login_id: Some(login_id), verification_url: Some(verification_url), user_code: Some(user_code), } } pub(crate) fn login_id(&self) -> Option<&str> { self.login_id.as_deref() } pub(crate) fn is_showing_copyable_auth(&self) -> bool { self.verification_url .as_deref() .is_some_and(|url| !url.is_empty()) && self .user_code .as_deref() .is_some_and(|user_code| !user_code.is_empty()) }}impl KeyboardHandler for AuthModeWidget { fn handle_key_event(&mut self, key_event: KeyEvent) { if self.handle_api_key_entry_key_event(&key_event) { return; } if keys::MOVE_UP.is_pressed(key_event) { self.move_highlight(/*delta*/ -1); return; } if keys::MOVE_DOWN.is_pressed(key_event) { self.move_highlight(/*delta*/ 1); return; } if keys::SELECT_FIRST.is_pressed(key_event) { self.select_option_by_index(/*index*/ 0); return; } if keys::SELECT_SECOND.is_pressed(key_event) { self.select_option_by_index(/*index*/ 1); return; } if keys::SELECT_THIRD.is_pressed(key_event) { self.select_option_by_index(/*index*/ 2); return; } if keys::CONFIRM.is_pressed(key_event) { let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; match sign_in_state { SignInState::PickMode => { self.handle_sign_in_option(self.highlighted_mode); } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } _ => {} } return; } if keys::CANCEL.is_pressed(key_event) { tracing::info!("Cancel onboarding auth step"); self.cancel_active_attempt(); } } fn handle_paste(&mut self, pasted: String) { let _ = self.handle_api_key_entry_paste(pasted); }}#[derive(Clone)]#[allow(dead_code)]pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, pub highlighted_mode: SignInOption, pub error: Arc<RwLock<Option<String>>>, pub sign_in_state: Arc<RwLock<SignInState>>, pub login_status: LoginStatus, pub app_server_request_handle: AppServerRequestHandle, pub forced_login_method: Option<ForcedLoginMethod>, pub animations_enabled: bool, pub animations_suppressed: Cell<bool>,}impl AuthModeWidget { pub(crate) fn set_animations_suppressed(&self, suppressed: bool) { self.animations_suppressed.set(suppressed); } pub(crate) fn should_suppress_animations(&self) -> bool { matches!( &*self.sign_in_state.read().unwrap(), SignInState::ChatGptContinueInBrowser(_) | SignInState::ChatGptDeviceCode(_) ) } pub(crate) fn cancel_active_attempt(&self) { let mut sign_in_state = self.sign_in_state.write().unwrap(); match &*sign_in_state { SignInState::ChatGptContinueInBrowser(state) => { let request_handle = self.app_server_request_handle.clone(); let login_id = state.login_id.clone(); tokio::spawn(async move { cancel_login_attempt(&request_handle, login_id).await; }); } SignInState::ChatGptDeviceCode(state) => { if let Some(login_id) = state.login_id().map(str::to_owned) { let request_handle = self.app_server_request_handle.clone(); tokio::spawn(async move { cancel_login_attempt(&request_handle, login_id).await; }); } } _ => return, } *sign_in_state = SignInState::PickMode; drop(sign_in_state); self.set_error(/*message*/ None); self.request_frame.schedule_frame(); } fn set_error(&self, message: Option<String>) { *self.error.write().unwrap() = message; } fn error_message(&self) -> Option<String> { self.error.read().unwrap().clone() } /// Returns whether the auth flow is currently in API-key entry mode. pub(crate) fn is_api_key_entry_active(&self) -> bool { self.sign_in_state .read() .is_ok_and(|guard| matches!(&*guard, SignInState::ApiKeyEntry(_))) } /// Returns whether the API-key entry field currently contains any text. pub(crate) fn api_key_entry_has_text(&self) -> bool { self.sign_in_state.read().is_ok_and( |guard| matches!(&*guard, SignInState::ApiKeyEntry(state) if !state.value.is_empty()), ) } fn confirm_binding(&self) -> KeyBinding { keys::CONFIRM[0] } fn cancel_binding(&self) -> KeyBinding { keys::CANCEL[0] } 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 displayed_sign_in_options(&self) -> Vec<SignInOption> { let mut options = vec![SignInOption::ChatGpt]; if self.is_chatgpt_login_allowed() { options.push(SignInOption::DeviceCode); } if self.is_api_login_allowed() { options.push(SignInOption::ApiKey); } options } fn selectable_sign_in_options(&self) -> Vec<SignInOption> { let mut options = Vec::new(); if self.is_chatgpt_login_allowed() { options.push(SignInOption::ChatGpt); options.push(SignInOption::DeviceCode); } if self.is_api_login_allowed() { options.push(SignInOption::ApiKey); } options } fn move_highlight(&mut self, delta: isize) { let options = self.selectable_sign_in_options(); if options.is_empty() { return; } let current_index = options .iter() .position(|option| *option == self.highlighted_mode) .unwrap_or(0); let next_index = (current_index as isize + delta).rem_euclid(options.len() as isize) as usize; self.highlighted_mode = options[next_index]; } fn select_option_by_index(&mut self, index: usize) { let options = self.displayed_sign_in_options(); if let Some(option) = options.get(index).copied() { self.handle_sign_in_option(option); } } fn handle_sign_in_option(&mut self, option: SignInOption) { match option { SignInOption::ChatGpt => { if self.is_chatgpt_login_allowed() { self.start_chatgpt_login(); } } SignInOption::DeviceCode => { if self.is_chatgpt_login_allowed() { self.start_device_code_login(); } } SignInOption::ApiKey => { if self.is_api_login_allowed() { self.start_api_key_entry(); } else { self.disallow_api_login(); } } } } fn disallow_api_login(&mut self) { self.highlighted_mode = SignInOption::ChatGpt; self.set_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<Line> = 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: SignInOption, text: &str, description: &str| -> Vec<Line<'static>> { let is_selected = self.highlighted_mode == selected_mode; let caret = if is_selected { ">" } else { " " }; let line1 = if is_selected { Line::from(vec![ format!("{caret} {index}. ", index = idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { format!(" {index}. {text}", index = 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() { "ChatGPT login is disabled" } else { "Usage included with Plus, Pro, Business, and Enterprise plans" }; let device_code_description = "Sign in from another device with a one-time code"; for (idx, option) in self.displayed_sign_in_options().into_iter().enumerate() { match option { SignInOption::ChatGpt => { lines.extend(create_mode_item( idx, option, "Sign in with ChatGPT", chatgpt_description, )); } SignInOption::DeviceCode => { lines.extend(create_mode_item( idx, option, "Sign in with Device Code", device_code_description, )); } SignInOption::ApiKey => { lines.extend(create_mode_item( idx, option, "Provide your own API key", "Pay for what you use", )); } } lines.push("".into()); } if !self.is_api_login_allowed() { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() .into(), ); lines.push("".into()); } lines.push(Line::from(vec![ " Press ".dim(), self.confirm_binding().into(), " to continue".dim(), ])); if let Some(err) = self.error_message() { lines.push("".into()); lines.push(err.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()]; if self.animations_enabled && !self.animations_suppressed.get() { // 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_text( "Finish signing in via your browser", MotionMode::Animated, )); } else { spans.push("Finish signing in via your browser".into()); } let mut lines = vec![spans.into(), "".into()]; let sign_in_state = self.sign_in_state.read().unwrap(); let auth_url = 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(vec![ " ".into(), state.auth_url.as_str().cyan().underlined(), ])); lines.push("".into()); lines.push(Line::from(vec![ " On a remote or headless machine? Press ".into(), self.cancel_binding().into(), " and choose ".into(), "Sign in with Device Code".cyan(), ".".into(), ])); lines.push("".into()); Some(state.auth_url.clone()) } else { None }; lines.push(Line::from(vec![ " Press ".dim(), self.cancel_binding().into(), " to cancel".dim(), ])); Paragraph::new(lines) .wrap(Wrap { trim: false }) .render(area, buf); // Wrap cyan+underlined URL cells with OSC 8 so the terminal treats // the entire region as a single clickable hyperlink. if let Some(url) = &auth_url { mark_url_hyperlink(buf, area, url); } } 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(), crate::terminal_hyperlinks::osc8_hyperlink( "https://developers.openai.com/codex/security", "Codex docs", ) .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(), crate::terminal_hyperlinks::osc8_hyperlink( "https://chatgpt.com/#settings", "training data preferences", ) .underlined(), ]) .dim(), "".into(), Line::from(vec![ " Press ".fg(Color::Cyan), self.confirm_binding().into(), " to continue".fg(Color::Cyan), ]), ]; 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<Line> = 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<Line> = vec![ Line::from(vec![ " Press ".dim(), self.confirm_binding().into(), " to save".dim(), ]), Line::from(vec![ " Press ".dim(), self.cancel_binding().into(), " to go back".dim(), ]), ]; if let Some(error) = self.error_message() { footer_lines.push("".into()); footer_lines.push(error.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<String> = None; let mut should_request_frame = false; { let mut guard = self.sign_in_state.write().unwrap(); if let SignInState::ApiKeyEntry(state) = &mut *guard { if keys::CANCEL.is_pressed(*key_event) { *guard = SignInState::PickMode; self.set_error(/*message*/ None); should_request_frame = true; } else if keys::CONFIRM.is_pressed(*key_event) { let trimmed = state.value.trim().to_string(); if trimmed.is_empty() { self.set_error(Some("API key cannot be empty".to_string())); should_request_frame = true; } else { should_save = Some(trimmed); } } else { match key_event.code { KeyCode::Backspace => { if state.prepopulated_from_env { state.value.clear(); state.prepopulated_from_env = false; } else { state.value.pop(); } self.set_error(/*message*/ None); should_request_frame = true; } KeyCode::Char(c) if key_event.kind == KeyEventKind::Press && !key_event.modifiers.contains(KeyModifiers::SUPER) && !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.set_error(/*message*/ 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.set_error(/*message*/ 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.set_error(/*message*/ 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; } self.set_error(/*message*/ None); let request_handle = self.app_server_request_handle.clone(); let sign_in_state = self.sign_in_state.clone(); let error = self.error.clone(); let request_frame = self.request_frame.clone(); tokio::spawn(async move { match request_handle .request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount { request_id: onboarding_request_id(), params: LoginAccountParams::ApiKey { api_key: api_key.clone(), }, }) .await { Ok(LoginAccountResponse::ApiKey {}) => { *error.write().unwrap() = None; *sign_in_state.write().unwrap() = SignInState::ApiKeyConfigured; } Ok(other) => { *error.write().unwrap() = Some(format!( "Unexpected account/login/start response: {other:?}" )); *sign_in_state.write().unwrap() = SignInState::ApiKeyEntry(ApiKeyInputState { value: api_key, prepopulated_from_env: false, }); } Err(err) => { *error.write().unwrap() = Some(format!("Failed to save API key: {err}")); *sign_in_state.write().unwrap() = SignInState::ApiKeyEntry(ApiKeyInputState { value: api_key, prepopulated_from_env: false, }); } } request_frame.schedule_frame(); }); self.request_frame.schedule_frame(); } fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!( self.login_status, LoginStatus::AuthMode(auth_mode) if auth_mode.has_chatgpt_account() ) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); true } else { false } } /// Kicks off the ChatGPT auth flow and keeps the UI state consistent with the attempt. 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 self.handle_existing_chatgpt_login() { return; } self.set_error(/*message*/ None); let request_handle = self.app_server_request_handle.clone(); let sign_in_state = self.sign_in_state.clone(); let error = self.error.clone(); let request_frame = self.request_frame.clone(); tokio::spawn(async move { match request_handle .request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount { request_id: onboarding_request_id(), params: LoginAccountParams::Chatgpt { codex_streamlined_login: false, }, }) .await { Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => { maybe_open_auth_url_in_browser(&request_handle, &auth_url); *error.write().unwrap() = None; *sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { login_id, auth_url, }); } Ok(other) => { *sign_in_state.write().unwrap() = SignInState::PickMode; *error.write().unwrap() = Some(format!( "Unexpected account/login/start response: {other:?}" )); } Err(err) => { *sign_in_state.write().unwrap() = SignInState::PickMode; *error.write().unwrap() = Some(err.to_string()); } } request_frame.schedule_frame(); }); } fn start_device_code_login(&mut self) { if self.handle_existing_chatgpt_login() { return; } self.set_error(/*message*/ None); headless_chatgpt_login::start_headless_chatgpt_login(self); } pub(crate) fn on_account_login_completed( &mut self, notification: AccountLoginCompletedNotification, ) { let Some(login_id) = notification.login_id else { return; }; let guard = self.sign_in_state.read().unwrap(); let is_matching_login = matches!( &*guard, SignInState::ChatGptContinueInBrowser(state) if state.login_id == login_id ) || matches!( &*guard, SignInState::ChatGptDeviceCode(state) if state.login_id() == Some(login_id.as_str()) ); drop(guard); if !is_matching_login { return; } if notification.success { self.set_error(/*message*/ None); *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage; } else { self.set_error(notification.error); *self.sign_in_state.write().unwrap() = SignInState::PickMode; } self.request_frame.schedule_frame(); } pub(crate) fn on_account_updated(&mut self, notification: AccountUpdatedNotification) { self.login_status = notification .auth_mode .map(LoginStatus::AuthMode) .unwrap_or(LoginStatus::NotAuthenticated); }}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::ChatGptDeviceCode(_) | 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::ChatGptDeviceCode(state) => { headless_chatgpt_login::render_device_code_login(self, area, buf, state); } 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); } } }}pub(super) fn maybe_open_auth_url_in_browser(request_handle: &AppServerRequestHandle, url: &str) { if !matches!(request_handle, AppServerRequestHandle::InProcess(_)) { return; } if let Err(err) = webbrowser::open(url) { tracing::warn!("failed to open browser for login URL: {err}"); }}#[cfg(test)]mod tests { use super::*; use crate::legacy_core::config::ConfigBuilder; use codex_app_server_client::AppServerRequestHandle; use codex_app_server_client::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; use codex_app_server_client::InProcessAppServerClient; use codex_app_server_client::InProcessClientStartArgs; use codex_arg0::Arg0DispatchPaths; use codex_cloud_config::cloud_config_bundle_loader_for_storage; use codex_config::types::AuthCredentialsStoreMode; use codex_login::AuthKeyringBackendKind; use pretty_assertions::assert_eq; use std::sync::Arc; use tempfile::TempDir; async fn widget_forced_chatgpt() -> (AuthModeWidget, TempDir) { let codex_home = TempDir::new().unwrap(); let codex_home_path = codex_home.path().to_path_buf(); let config = ConfigBuilder::default() .codex_home(codex_home_path.clone()) .build() .await .unwrap(); let client = InProcessAppServerClient::start(InProcessClientStartArgs { arg0_paths: Arg0DispatchPaths::default(), config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides: Default::default(), strict_config: false, cloud_config_bundle: cloud_config_bundle_loader_for_storage( codex_home_path.clone(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, AuthKeyringBackendKind::default(), "https://chatgpt.com/backend-api/".to_string(), ) .await, feedback: codex_feedback::CodexFeedback::new(), log_db: None, state_db: None, environment_manager: Arc::new( codex_app_server_client::EnvironmentManager::default_for_tests(), ), config_warnings: Vec::new(), session_source: serde_json::from_value(serde_json::json!("cli")) .expect("cli session source should deserialize"), enable_codex_api_key_env: false, client_name: "test".to_string(), client_version: "test".to_string(), experimental_api: true, opt_out_notification_methods: Vec::new(), channel_capacity: DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) .await .unwrap(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), highlighted_mode: SignInOption::ChatGpt, error: Arc::new(RwLock::new(None)), sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), login_status: LoginStatus::NotAuthenticated, app_server_request_handle: AppServerRequestHandle::InProcess(client.request_handle()), forced_login_method: Some(ForcedLoginMethod::Chatgpt), animations_enabled: true, animations_suppressed: std::cell::Cell::new(false), }; (widget, codex_home) } #[tokio::test] async fn api_key_flow_disabled_when_chatgpt_forced() { let (mut widget, _tmp) = widget_forced_chatgpt().await; widget.start_api_key_entry(); assert_eq!( widget.error_message().as_deref(), Some(API_KEY_DISABLED_MESSAGE) ); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::PickMode )); } #[tokio::test] async fn saving_api_key_is_blocked_when_chatgpt_forced() { let (mut widget, _tmp) = widget_forced_chatgpt().await; widget.save_api_key("sk-test".to_string()); assert_eq!( widget.error_message().as_deref(), Some(API_KEY_DISABLED_MESSAGE) ); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::PickMode )); assert_eq!(widget.login_status, LoginStatus::NotAuthenticated); } #[tokio::test] async fn existing_non_oauth_chatgpt_login_counts_as_signed_in() { for auth_mode in [ AppServerAuthMode::ChatgptAuthTokens, AppServerAuthMode::PersonalAccessToken, ] { let (mut widget, _tmp) = widget_forced_chatgpt().await; widget.login_status = LoginStatus::AuthMode(auth_mode); let handled = widget.handle_existing_chatgpt_login(); assert_eq!(handled, true); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::ChatGptSuccess )); } } #[tokio::test] async fn cancel_active_attempt_resets_browser_login_state() { let (widget, _tmp) = widget_forced_chatgpt().await; *widget.error.write().unwrap() = Some("still logging in".to_string()); *widget.sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { login_id: "login-1".to_string(), auth_url: "https://auth.example.com".to_string(), }); widget.cancel_active_attempt(); assert_eq!(widget.error_message(), None); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::PickMode )); } #[tokio::test] async fn cancel_active_attempt_notifies_device_code_login() { let (widget, _tmp) = widget_forced_chatgpt().await; *widget.error.write().unwrap() = Some("still logging in".to_string()); *widget.sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready( "request-1".to_string(), "login-1".to_string(), "https://chatgpt.com/device".to_string(), "ABCD-EFGH".to_string(), )); widget.cancel_active_attempt(); assert_eq!(widget.error_message(), None); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::PickMode )); } /// Collects all buffer cell symbols that contain the OSC 8 open sequence /// for the given URL. Returns the concatenated "inner" characters. fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String { let open = format!("\x1B]8;;{url}\x07"); let close = "\x1B]8;;\x07"; let mut chars = String::new(); for y in area.top()..area.bottom() { for x in area.left()..area.right() { let sym = buf[(x, y)].symbol(); if let Some(rest) = sym.strip_prefix(open.as_str()) && let Some(ch) = rest.strip_suffix(close) { chars.push_str(ch); } } } chars } #[test] fn continue_in_browser_renders_osc8_hyperlink() { let runtime = tokio::runtime::Runtime::new().unwrap(); let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt()); let url = "https://auth.example.com/login?state=abc123"; *widget.sign_in_state.write().unwrap() = SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { login_id: "login-1".to_string(), auth_url: url.to_string(), }); // Render into a narrow buffer so the URL wraps across multiple rows. let area = Rect::new(0, 0, 30, 20); let mut buf = Buffer::empty(area); widget.render_continue_in_browser(area, &mut buf); // Every character of the URL should be present as an OSC 8 cell. let found = collect_osc8_chars(&buf, area, url); assert_eq!(found, url, "OSC 8 hyperlink should cover the full URL"); } #[test] fn auth_widget_suppresses_animations_when_device_code_is_visible() { let runtime = tokio::runtime::Runtime::new().unwrap(); let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt()); *widget.sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready( "request-1".to_string(), "login-1".to_string(), "https://chatgpt.com/device".to_string(), "ABCD-EFGH".to_string(), )); assert_eq!(widget.should_suppress_animations(), true); } #[test] fn auth_widget_suppresses_animations_while_requesting_device_code() { let runtime = tokio::runtime::Runtime::new().unwrap(); let (widget, _tmp) = runtime.block_on(widget_forced_chatgpt()); *widget.sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode( ContinueWithDeviceCodeState::pending("request-1".to_string()), ); assert_eq!(widget.should_suppress_animations(), true); } #[tokio::test] async fn device_code_login_completion_advances_to_success_message() { let (mut widget, _tmp) = widget_forced_chatgpt().await; *widget.sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState::ready( "request-1".to_string(), "login-1".to_string(), "https://chatgpt.com/device".to_string(), "ABCD-EFGH".to_string(), )); widget.on_account_login_completed(AccountLoginCompletedNotification { login_id: Some("login-1".to_string()), success: true, error: None, }); assert!(matches!( &*widget.sign_in_state.read().unwrap(), SignInState::ChatGptSuccessMessage )); } #[test] fn mark_url_hyperlink_wraps_cyan_underlined_cells() { let url = "https://example.com"; let area = Rect::new(0, 0, 20, 1); let mut buf = Buffer::empty(area); // Manually write some cyan+underlined characters to simulate a rendered URL. for (i, ch) in "example".chars().enumerate() { let cell = &mut buf[(i as u16, 0)]; cell.set_symbol(&ch.to_string()); cell.fg = Color::Cyan; cell.modifier = Modifier::UNDERLINED; } // Leave a plain cell that should NOT be marked. buf[(7, 0)].set_symbol("X"); mark_url_hyperlink(&mut buf, area, url); // Each cyan+underlined cell should now carry the OSC 8 wrapper. let found = collect_osc8_chars(&buf, area, url); assert_eq!(found, "example"); // The plain "X" cell should be untouched. assert_eq!(buf[(7, 0)].symbol(), "X"); } #[test] fn mark_url_hyperlink_sanitizes_control_chars() { let area = Rect::new(0, 0, 10, 1); let mut buf = Buffer::empty(area); // One cyan+underlined cell to mark. let cell = &mut buf[(0, 0)]; cell.set_symbol("a"); cell.fg = Color::Cyan; cell.modifier = Modifier::UNDERLINED; // URL contains ESC and BEL that could break the OSC 8 sequence. let malicious_url = "https://evil.com/\x1B]8;;\x07injected"; mark_url_hyperlink(&mut buf, area, malicious_url); let sym = buf[(0, 0)].symbol().to_string(); // The sanitized URL retains `]` (printable) but strips ESC and BEL. let sanitized = "https://evil.com/]8;;injected"; assert!( sym.contains(sanitized), "symbol should contain sanitized URL, got: {sym:?}" ); // The injected close-sequence must not survive: \x1B and \x07 are gone. assert!( !sym.contains("\x1B]8;;\x07injected"), "symbol must not contain raw control chars from URL" ); }}