diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 1c82eae0047..337be81e5dd 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -14,11 +14,9 @@ use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_login_with_device_code; -use codex_cli::login::run_login_with_device_code_fallback_to_browser; use codex_cli::login::run_logout; use codex_cloud_tasks::Cli as CloudTasksCli; use codex_common::CliConfigOverrides; -use codex_core::env::is_headless_environment; use codex_exec::Cli as ExecCli; use codex_exec::Command as ExecCommand; use codex_exec::ReviewArgs; @@ -600,13 +598,6 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } else if login_cli.with_api_key { let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; - } else if is_headless_environment() { - run_login_with_device_code_fallback_to_browser( - login_cli.config_overrides, - login_cli.issuer_base_url, - login_cli.client_id, - ) - .await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; } diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 430ef5f1954..551fa9180e8 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -5,7 +5,6 @@ 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_core::env::is_headless_environment; use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -59,6 +58,13 @@ pub(crate) enum SignInState { 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."; #[derive(Clone, Default)] @@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - if self.is_chatgpt_login_allowed() { - self.highlighted_mode = AuthMode::ChatGPT; - } + self.move_highlight(-1); } KeyCode::Down | KeyCode::Char('j') => { - if self.is_api_login_allowed() { - self.highlighted_mode = AuthMode::ApiKey; - } + self.move_highlight(1); } KeyCode::Char('1') => { - if self.is_chatgpt_login_allowed() { - self.start_chatgpt_login(); - } + self.select_option_by_index(0); } KeyCode::Char('2') => { - if self.is_api_login_allowed() { - self.start_api_key_entry(); - } else { - self.disallow_api_login(); - } + self.select_option_by_index(1); + } + KeyCode::Char('3') => { + self.select_option_by_index(2); } 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::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); + } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } @@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget { #[derive(Clone)] pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, - pub highlighted_mode: AuthMode, + pub highlighted_mode: SignInOption, pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, @@ -191,8 +181,75 @@ impl AuthModeWidget { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } + fn displayed_sign_in_options(&self) -> Vec { + 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 { + 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 = AuthMode::ChatGPT; + self.highlighted_mode = SignInOption::ChatGpt; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); @@ -212,7 +269,7 @@ impl AuthModeWidget { ]; let create_mode_item = |idx: usize, - selected_mode: AuthMode, + selected_mode: SignInOption, text: &str, description: &str| -> Vec> { @@ -221,11 +278,11 @@ impl AuthModeWidget { let line1 = if is_selected { Line::from(vec![ - format!("{} {}. ", caret, idx + 1).cyan().dim(), + format!("{caret} {index}. ", index = idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { - format!(" {}. {text}", idx + 1).into() + format!(" {index}. {text}", index = idx + 1).into() }; let line2 = if is_selected { @@ -242,27 +299,42 @@ impl AuthModeWidget { let chatgpt_description = if !self.is_chatgpt_login_allowed() { "ChatGPT login is disabled" - } else if is_headless_environment() { - "Uses device code login (headless environment detected)" } else { "Usage included with Plus, Pro, Team, and Enterprise plans" }; - 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", - )); + 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()); - } else { + } + + if !self.is_api_login_allowed() { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() @@ -309,9 +381,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Use ".into(), - "codex login --device-auth".cyan(), - " instead".into(), + " On a remote or headless machine? Press Esc and choose ".into(), + "Sign in with Device Code".cyan(), + ".".into(), ])); lines.push("".into()); } @@ -588,13 +660,21 @@ impl AuthModeWidget { self.request_frame.schedule_frame(); } + fn handle_existing_chatgpt_login(&mut self) -> bool { + if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { + *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 matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { - *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; - self.request_frame.schedule_frame(); + if self.handle_existing_chatgpt_login() { return; } @@ -606,11 +686,6 @@ impl AuthModeWidget { self.cli_auth_credentials_store_mode, ); - if is_headless_environment() { - headless_chatgpt_login::start_headless_chatgpt_login(self, opts); - return; - } - match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -650,6 +725,21 @@ impl AuthModeWidget { } } } + + fn start_device_code_login(&mut self) { + if self.handle_existing_chatgpt_login() { + 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, + ); + headless_chatgpt_login::start_headless_chatgpt_login(self, opts); + } } impl StepStateProvider for AuthModeWidget { @@ -708,7 +798,7 @@ mod tests { let codex_home_path = codex_home.path().to_path_buf(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode: SignInOption::ChatGpt, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 14999b2229f..7e076ae160f 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -11,11 +11,11 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -92,8 +92,8 @@ impl OnboardingScreen { ))); if show_login_screen { let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, - _ => AuthMode::ChatGPT, + Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, + _ => SignInOption::ChatGpt, }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(), diff --git a/codex-rs/tui2/src/onboarding/auth.rs b/codex-rs/tui2/src/onboarding/auth.rs index 640fca14dab..a1fb3dee123 100644 --- a/codex-rs/tui2/src/onboarding/auth.rs +++ b/codex-rs/tui2/src/onboarding/auth.rs @@ -5,7 +5,6 @@ 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_core::env::is_headless_environment; use codex_login::DeviceCode; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -59,6 +58,13 @@ pub(crate) enum SignInState { 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."; #[derive(Clone, Default)] @@ -96,42 +102,26 @@ impl KeyboardHandler for AuthModeWidget { match key_event.code { KeyCode::Up | KeyCode::Char('k') => { - if self.is_chatgpt_login_allowed() { - self.highlighted_mode = AuthMode::ChatGPT; - } + self.move_highlight(-1); } KeyCode::Down | KeyCode::Char('j') => { - if self.is_api_login_allowed() { - self.highlighted_mode = AuthMode::ApiKey; - } + self.move_highlight(1); } KeyCode::Char('1') => { - if self.is_chatgpt_login_allowed() { - self.start_chatgpt_login(); - } + self.select_option_by_index(0); } KeyCode::Char('2') => { - if self.is_api_login_allowed() { - self.start_api_key_entry(); - } else { - self.disallow_api_login(); - } + self.select_option_by_index(1); + } + KeyCode::Char('3') => { + self.select_option_by_index(2); } 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::PickMode => { + self.handle_sign_in_option(self.highlighted_mode); + } SignInState::ChatGptSuccessMessage => { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; } @@ -170,7 +160,7 @@ impl KeyboardHandler for AuthModeWidget { #[derive(Clone)] pub(crate) struct AuthModeWidget { pub request_frame: FrameRequester, - pub highlighted_mode: AuthMode, + pub highlighted_mode: SignInOption, pub error: Option, pub sign_in_state: Arc>, pub codex_home: PathBuf, @@ -191,8 +181,75 @@ impl AuthModeWidget { !matches!(self.forced_login_method, Some(ForcedLoginMethod::Api)) } + fn displayed_sign_in_options(&self) -> Vec { + 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 { + 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 = AuthMode::ChatGPT; + self.highlighted_mode = SignInOption::ChatGpt; self.error = Some(API_KEY_DISABLED_MESSAGE.to_string()); *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.request_frame.schedule_frame(); @@ -212,7 +269,7 @@ impl AuthModeWidget { ]; let create_mode_item = |idx: usize, - selected_mode: AuthMode, + selected_mode: SignInOption, text: &str, description: &str| -> Vec> { @@ -221,11 +278,11 @@ impl AuthModeWidget { let line1 = if is_selected { Line::from(vec![ - format!("{} {}. ", caret, idx + 1).cyan().dim(), + format!("{caret} {index}. ", index = idx + 1).cyan().dim(), text.to_string().cyan(), ]) } else { - format!(" {}. {text}", idx + 1).into() + format!(" {index}. {text}", index = idx + 1).into() }; let line2 = if is_selected { @@ -242,27 +299,42 @@ impl AuthModeWidget { let chatgpt_description = if !self.is_chatgpt_login_allowed() { "ChatGPT login is disabled" - } else if is_headless_environment() { - "Uses device code login (headless environment detected)" } else { "Usage included with Plus, Pro, Team, and Enterprise plans" }; - 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", - )); + 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()); - } else { + } + + if !self.is_api_login_allowed() { lines.push( " API key login is disabled by this workspace. Sign in with ChatGPT to continue." .dim() @@ -309,9 +381,9 @@ impl AuthModeWidget { ])); lines.push("".into()); lines.push(Line::from(vec![ - " On a remote or headless machine? Use ".into(), - "codex login --device-auth".cyan(), - " instead".into(), + " On a remote or headless machine? Press Esc and choose ".into(), + "Sign in with Device Code".cyan(), + ".".into(), ])); lines.push("".into()); } @@ -588,12 +660,20 @@ impl AuthModeWidget { 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. + fn handle_existing_chatgpt_login(&mut self) -> bool { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; self.request_frame.schedule_frame(); + true + } else { + false + } + } + + 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; } @@ -605,11 +685,6 @@ impl AuthModeWidget { self.cli_auth_credentials_store_mode, ); - if is_headless_environment() { - headless_chatgpt_login::start_headless_chatgpt_login(self, opts); - return; - } - match run_login_server(opts) { Ok(child) => { let sign_in_state = self.sign_in_state.clone(); @@ -649,6 +724,21 @@ impl AuthModeWidget { } } } + + fn start_device_code_login(&mut self) { + if self.handle_existing_chatgpt_login() { + 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, + ); + headless_chatgpt_login::start_headless_chatgpt_login(self, opts); + } } impl StepStateProvider for AuthModeWidget { @@ -707,7 +797,7 @@ mod tests { let codex_home_path = codex_home.path().to_path_buf(); let widget = AuthModeWidget { request_frame: FrameRequester::test_dummy(), - highlighted_mode: AuthMode::ChatGPT, + highlighted_mode: SignInOption::ChatGpt, error: None, sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home_path.clone(), diff --git a/codex-rs/tui2/src/onboarding/onboarding_screen.rs b/codex-rs/tui2/src/onboarding/onboarding_screen.rs index 3ba2619a872..b16a980625c 100644 --- a/codex-rs/tui2/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui2/src/onboarding/onboarding_screen.rs @@ -11,11 +11,11 @@ use ratatui::style::Color; use ratatui::widgets::Clear; use ratatui::widgets::WidgetRef; -use codex_app_server_protocol::AuthMode; use codex_protocol::config_types::ForcedLoginMethod; use crate::LoginStatus; use crate::onboarding::auth::AuthModeWidget; +use crate::onboarding::auth::SignInOption; use crate::onboarding::auth::SignInState; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; @@ -92,8 +92,8 @@ impl OnboardingScreen { ))); if show_login_screen { let highlighted_mode = match forced_login_method { - Some(ForcedLoginMethod::Api) => AuthMode::ApiKey, - _ => AuthMode::ChatGPT, + Some(ForcedLoginMethod::Api) => SignInOption::ApiKey, + _ => SignInOption::ChatGpt, }; steps.push(Step::Auth(AuthModeWidget { request_frame: tui.frame_requester(),