Codex Handbook
tui/src/onboarding/auth.rs 1312 lines
//! 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"        );    }}