diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 8fbf7b04b6d..822667b49fb 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -14,6 +14,18 @@ use std::io::IsTerminal; use std::io::Read; use std::path::PathBuf; +const CHATGPT_LOGIN_DISABLED_MESSAGE: &str = + "ChatGPT login is disabled. Use API key login instead."; +const API_KEY_LOGIN_DISABLED_MESSAGE: &str = + "API key login is disabled. Use ChatGPT login instead."; +const LOGIN_SUCCESS_MESSAGE: &str = "Successfully logged in"; + +fn print_login_server_start(actual_port: u16, auth_url: &str) { + eprintln!( + "Starting local login server on http://localhost:{actual_port}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}" + ); +} + pub async fn login_with_chatgpt( codex_home: PathBuf, forced_chatgpt_workspace_id: Option, @@ -27,10 +39,7 @@ pub async fn login_with_chatgpt( ); let server = run_login_server(opts)?; - eprintln!( - "Starting local login server on http://localhost:{}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{}", - server.actual_port, server.auth_url, - ); + print_login_server_start(server.actual_port, &server.auth_url); server.block_until_done().await } @@ -39,7 +48,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> let config = load_config_or_exit(cli_config_overrides).await; if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - eprintln!("ChatGPT login is disabled. Use API key login instead."); + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } @@ -53,7 +62,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> .await { Ok(_) => { - eprintln!("Successfully logged in"); + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } Err(e) => { @@ -70,7 +79,7 @@ pub async fn run_login_with_api_key( let config = load_config_or_exit(cli_config_overrides).await; if matches!(config.forced_login_method, Some(ForcedLoginMethod::Chatgpt)) { - eprintln!("API key login is disabled. Use ChatGPT login instead."); + eprintln!("{API_KEY_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } @@ -80,7 +89,7 @@ pub async fn run_login_with_api_key( config.cli_auth_credentials_store_mode, ) { Ok(_) => { - eprintln!("Successfully logged in"); + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } Err(e) => { @@ -125,7 +134,7 @@ pub async fn run_login_with_device_code( ) -> ! { let config = load_config_or_exit(cli_config_overrides).await; if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { - eprintln!("ChatGPT login is disabled. Use API key login instead."); + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); std::process::exit(1); } let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); @@ -140,7 +149,7 @@ pub async fn run_login_with_device_code( } match run_device_code_login(opts).await { Ok(()) => { - eprintln!("Successfully logged in"); + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); std::process::exit(0); } Err(e) => { @@ -150,6 +159,68 @@ pub async fn run_login_with_device_code( } } +/// Prefers device-code login (with `open_browser = false`) when headless environment is detected, but keeps +/// `codex login` working in environments where device-code may be disabled/feature-gated. +/// If `run_device_code_login` returns `ErrorKind::NotFound` ("device-code unsupported"), this +/// falls back to starting the local browser login server. +pub async fn run_login_with_device_code_fallback_to_browser( + cli_config_overrides: CliConfigOverrides, + issuer_base_url: Option, + client_id: Option, +) -> ! { + let config = load_config_or_exit(cli_config_overrides).await; + if matches!(config.forced_login_method, Some(ForcedLoginMethod::Api)) { + eprintln!("{CHATGPT_LOGIN_DISABLED_MESSAGE}"); + std::process::exit(1); + } + + let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone(); + let mut opts = ServerOptions::new( + config.codex_home, + client_id.unwrap_or(CLIENT_ID.to_string()), + forced_chatgpt_workspace_id, + config.cli_auth_credentials_store_mode, + ); + if let Some(iss) = issuer_base_url { + opts.issuer = iss; + } + opts.open_browser = false; + + match run_device_code_login(opts.clone()).await { + Ok(()) => { + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); + std::process::exit(0); + } + Err(e) => { + if e.kind() == std::io::ErrorKind::NotFound { + eprintln!("Device code login is not enabled; falling back to browser login."); + match run_login_server(opts) { + Ok(server) => { + print_login_server_start(server.actual_port, &server.auth_url); + match server.block_until_done().await { + Ok(()) => { + eprintln!("{LOGIN_SUCCESS_MESSAGE}"); + std::process::exit(0); + } + Err(e) => { + eprintln!("Error logging in: {e}"); + std::process::exit(1); + } + } + } + Err(e) => { + eprintln!("Error logging in: {e}"); + std::process::exit(1); + } + } + } else { + eprintln!("Error logging in with device code: {e}"); + std::process::exit(1); + } + } + } +} + pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! { let config = load_config_or_exit(cli_config_overrides).await; diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 8c2ff504175..48337b57882 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -14,9 +14,11 @@ 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; @@ -539,6 +541,13 @@ 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/core/src/env.rs b/codex-rs/core/src/env.rs index 5370c0ffd82..c99b2427774 100644 --- a/codex-rs/core/src/env.rs +++ b/codex-rs/core/src/env.rs @@ -1,5 +1,9 @@ //! Functions for environment detection that need to be shared across crates. +fn env_var_set(key: &str) -> bool { + std::env::var(key).is_ok_and(|v| !v.trim().is_empty()) +} + /// Returns true if the current process is running under Windows Subsystem for Linux. pub fn is_wsl() -> bool { #[cfg(target_os = "linux")] @@ -17,3 +21,26 @@ pub fn is_wsl() -> bool { false } } + +/// Returns true when Codex is likely running in an environment without a usable GUI. +/// +/// This is intentionally conservative and is used by frontends to avoid flows that would try to +/// open a browser (e.g. device-code auth fallback). +pub fn is_headless_environment() -> bool { + if env_var_set("CI") + || env_var_set("SSH_CONNECTION") + || env_var_set("SSH_CLIENT") + || env_var_set("SSH_TTY") + { + return true; + } + + #[cfg(target_os = "linux")] + { + if !env_var_set("DISPLAY") && !env_var_set("WAYLAND_DISPLAY") { + return true; + } + } + + false +} diff --git a/codex-rs/login/src/device_code_auth.rs b/codex-rs/login/src/device_code_auth.rs index 5864ff65d9e..9bf477181fc 100644 --- a/codex-rs/login/src/device_code_auth.rs +++ b/codex-rs/login/src/device_code_auth.rs @@ -14,6 +14,14 @@ const ANSI_BLUE: &str = "\x1b[94m"; const ANSI_GRAY: &str = "\x1b[90m"; const ANSI_RESET: &str = "\x1b[0m"; +#[derive(Debug, Clone)] +pub struct DeviceCode { + pub verification_url: String, + pub user_code: String, + device_auth_id: String, + interval: u64, +} + #[derive(Deserialize)] struct UserCodeResp { device_auth_id: String, @@ -73,7 +81,8 @@ async fn request_user_code( if !resp.status().is_success() { let status = resp.status(); if status == StatusCode::NOT_FOUND { - return Err(std::io::Error::other( + return Err(io::Error::new( + io::ErrorKind::NotFound, "device code login is not enabled for this Codex server. Use the browser login or verify the server URL.", )); } @@ -137,34 +146,45 @@ async fn poll_for_token( } } -fn print_device_code_prompt(code: &str, issuer_base_url: &str) { +fn print_device_code_prompt(verification_url: &str, code: &str) { + let version = env!("CARGO_PKG_VERSION"); println!( "\nWelcome to Codex [v{ANSI_GRAY}{version}{ANSI_RESET}]\n{ANSI_GRAY}OpenAI's command-line coding agent{ANSI_RESET}\n\ \nFollow these steps to sign in with ChatGPT using device code authorization:\n\ -\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{issuer_base_url}/codex/device{ANSI_RESET}\n\ +\n1. Open this link in your browser and sign in to your account\n {ANSI_BLUE}{verification_url}{ANSI_RESET}\n\ \n2. Enter this one-time code {ANSI_GRAY}(expires in 15 minutes){ANSI_RESET}\n {ANSI_BLUE}{code}{ANSI_RESET}\n\ \n{ANSI_GRAY}Device codes are a common phishing target. Never share this code.{ANSI_RESET}\n", - version = env!("CARGO_PKG_VERSION"), - code = code, - issuer_base_url = issuer_base_url ); } -/// Full device code login flow. -pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { +pub async fn request_device_code(opts: &ServerOptions) -> std::io::Result { let client = reqwest::Client::new(); - let issuer_base_url = opts.issuer.trim_end_matches('/'); - let api_base_url = format!("{issuer_base_url}/api/accounts"); + let base_url = opts.issuer.trim_end_matches('/'); + let api_base_url = format!("{base_url}/api/accounts"); let uc = request_user_code(&client, &api_base_url, &opts.client_id).await?; - print_device_code_prompt(&uc.user_code, issuer_base_url); + Ok(DeviceCode { + verification_url: format!("{base_url}/codex/device"), + user_code: uc.user_code, + device_auth_id: uc.device_auth_id, + interval: uc.interval, + }) +} + +pub async fn complete_device_code_login( + opts: ServerOptions, + device_code: DeviceCode, +) -> std::io::Result<()> { + let client = reqwest::Client::new(); + let base_url = opts.issuer.trim_end_matches('/'); + let api_base_url = format!("{base_url}/api/accounts"); let code_resp = poll_for_token( &client, &api_base_url, - &uc.device_auth_id, - &uc.user_code, - uc.interval, + &device_code.device_auth_id, + &device_code.user_code, + device_code.interval, ) .await?; @@ -172,10 +192,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { code_verifier: code_resp.code_verifier, code_challenge: code_resp.code_challenge, }; - let redirect_uri = format!("{issuer_base_url}/deviceauth/callback"); + let redirect_uri = format!("{base_url}/deviceauth/callback"); let tokens = crate::server::exchange_code_for_tokens( - issuer_base_url, + base_url, &opts.client_id, &redirect_uri, &pkce, @@ -201,3 +221,10 @@ pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { ) .await } + +/// Full device code login flow. +pub async fn run_device_code_login(opts: ServerOptions) -> std::io::Result<()> { + let device_code = request_device_code(&opts).await?; + print_device_code_prompt(&device_code.verification_url, &device_code.user_code); + complete_device_code_login(opts, device_code).await +} diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index ac2cd28bea5..256e60eedb8 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -2,6 +2,9 @@ mod device_code_auth; mod pkce; mod server; +pub use device_code_auth::DeviceCode; +pub use device_code_auth::complete_device_code_login; +pub use device_code_auth::request_device_code; pub use device_code_auth::run_device_code_login; pub use server::LoginServer; pub use server::ServerOptions; diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 9de8fd17093..430ef5f1954 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -5,6 +5,8 @@ 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; use codex_login::run_login_server; @@ -40,13 +42,17 @@ use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; use std::path::PathBuf; use std::sync::Arc; +use tokio::sync::Notify; use super::onboarding_screen::StepState; +mod headless_chatgpt_login; + #[derive(Clone)] pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), + ChatGptDeviceCode(ContinueWithDeviceCodeState), ChatGptSuccessMessage, ChatGptSuccess, ApiKeyEntry(ApiKeyInputState), @@ -68,6 +74,12 @@ pub(crate) struct ContinueInBrowserState { shutdown_flag: Option, } +#[derive(Clone)] +pub(crate) struct ContinueWithDeviceCodeState { + device_code: Option, + cancel: Option>, +} + impl Drop for ContinueInBrowserState { fn drop(&mut self) { if let Some(handle) = &self.shutdown_flag { @@ -128,10 +140,22 @@ impl KeyboardHandler for AuthModeWidget { } KeyCode::Esc => { tracing::info!("Esc pressed"); - let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; - if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { - *self.sign_in_state.write().unwrap() = SignInState::PickMode; - self.request_frame.schedule_frame(); + let mut sign_in_state = self.sign_in_state.write().unwrap(); + match &*sign_in_state { + SignInState::ChatGptContinueInBrowser(_) => { + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.request_frame.schedule_frame(); + } + SignInState::ChatGptDeviceCode(state) => { + if let Some(cancel) = &state.cancel { + cancel.notify_one(); + } + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.request_frame.schedule_frame(); + } + _ => {} } } _ => {} @@ -216,10 +240,12 @@ impl AuthModeWidget { vec![line1, line2] }; - let chatgpt_description = if self.is_chatgpt_login_allowed() { - "Usage included with Plus, Pro, Business, Education, and Enterprise plans" - } else { + 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, @@ -277,7 +303,10 @@ impl AuthModeWidget { { lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); lines.push("".into()); - lines.push(Line::from(state.auth_url.as_str().cyan().underlined())); + lines.push(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? Use ".into(), @@ -559,6 +588,7 @@ impl AuthModeWidget { self.request_frame.schedule_frame(); } + /// 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. @@ -575,6 +605,12 @@ impl AuthModeWidget { self.forced_chatgpt_workspace_id.clone(), 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(); @@ -623,6 +659,7 @@ impl StepStateProvider for AuthModeWidget { SignInState::PickMode | SignInState::ApiKeyEntry(_) | SignInState::ChatGptContinueInBrowser(_) + | SignInState::ChatGptDeviceCode(_) | SignInState::ChatGptSuccessMessage => StepState::InProgress, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, } @@ -639,6 +676,9 @@ impl WidgetRef for AuthModeWidget { 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); } diff --git a/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs new file mode 100644 index 00000000000..f4949fe13f2 --- /dev/null +++ b/codex-rs/tui/src/onboarding/auth/headless_chatgpt_login.rs @@ -0,0 +1,377 @@ +use codex_core::AuthManager; +use codex_login::ServerOptions; +use codex_login::complete_device_code_login; +use codex_login::request_device_code; +use codex_login::run_login_server; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::sync::Arc; +use std::sync::RwLock; +use tokio::sync::Notify; + +use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; + +use super::AuthModeWidget; +use super::ContinueInBrowserState; +use super::ContinueWithDeviceCodeState; +use super::SignInState; + +pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) { + opts.open_browser = false; + let sign_in_state = widget.sign_in_state.clone(); + let request_frame = widget.request_frame.clone(); + let auth_manager = widget.auth_manager.clone(); + let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); + + tokio::spawn(async move { + let device_code = match request_device_code(&opts).await { + Ok(device_code) => device_code, + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + let should_fallback = { + let guard = sign_in_state.read().unwrap(); + device_code_attempt_matches(&guard, &cancel) + }; + + if !should_fallback { + return; + } + + match run_login_server(opts) { + Ok(child) => { + let auth_url = child.auth_url.clone(); + { + *sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url, + shutdown_flag: Some(child.cancel_handle()), + }); + } + request_frame.schedule_frame(); + let r = child.block_until_done().await; + match r { + Ok(()) => { + auth_manager.reload(); + *sign_in_state.write().unwrap() = + SignInState::ChatGptSuccessMessage; + request_frame.schedule_frame(); + } + _ => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + request_frame.schedule_frame(); + } + } + } + Err(_) => { + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ); + } + } + } else { + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ); + } + + return; + } + }; + + if !set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: Some(device_code.clone()), + cancel: Some(cancel.clone()), + }), + ) { + return; + } + + tokio::select! { + _ = cancel.notified() => {} + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => { + set_device_code_success_message_for_active_attempt( + &sign_in_state, + &request_frame, + &auth_manager, + &cancel, + ); + } + Err(_) => { + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ); + } + } + } + } + }); +} + +pub(super) fn render_device_code_login( + widget: &AuthModeWidget, + area: Rect, + buf: &mut Buffer, + state: &ContinueWithDeviceCodeState, +) { + let banner = if state.device_code.is_some() { + "Finish signing in via your browser" + } else { + "Preparing device code login" + }; + + let mut spans = vec![" ".into()]; + if widget.animations_enabled { + // Schedule a follow-up frame to keep the shimmer animation going. + widget + .request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); + spans.extend(shimmer_spans(banner)); + } else { + spans.push(banner.into()); + } + + let mut lines = vec![spans.into(), "".into()]; + + if let Some(device_code) = &state.device_code { + lines.push(" 1. Open this link in your browser and sign in".into()); + lines.push("".into()); + lines.push(Line::from(vec![ + " ".into(), + device_code.verification_url.as_str().cyan().underlined(), + ])); + lines.push("".into()); + lines.push( + " 2. Enter this one-time code after you are signed in (expires in 15 minutes)".into(), + ); + lines.push("".into()); + lines.push(Line::from(vec![ + " ".into(), + device_code.user_code.as_str().cyan().bold(), + ])); + lines.push("".into()); + lines.push( + " Device codes are a common phishing target. Never share this code." + .dim() + .into(), + ); + lines.push("".into()); + } else { + lines.push(" Requesting a one-time code...".dim().into()); + lines.push("".into()); + } + + lines.push(" Press Esc to cancel".dim().into()); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); +} + +fn device_code_attempt_matches(state: &SignInState, cancel: &Arc) -> bool { + matches!( + state, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|existing| Arc::ptr_eq(existing, cancel)) + ) +} + +fn begin_device_code_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, +) -> Arc { + let cancel = Arc::new(Notify::new()); + *sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel.clone()), + }); + request_frame.schedule_frame(); + cancel +} + +fn set_device_code_state_for_active_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, + cancel: &Arc, + next_state: SignInState, +) -> bool { + let mut guard = sign_in_state.write().unwrap(); + if !device_code_attempt_matches(&guard, cancel) { + return false; + } + + *guard = next_state; + drop(guard); + request_frame.schedule_frame(); + true +} + +fn set_device_code_success_message_for_active_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, + auth_manager: &AuthManager, + cancel: &Arc, +) -> bool { + let mut guard = sign_in_state.write().unwrap(); + if !device_code_attempt_matches(&guard, cancel) { + return false; + } + + auth_manager.reload(); + *guard = SignInState::ChatGptSuccessMessage; + drop(guard); + request_frame.schedule_frame(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::auth::AuthCredentialsStoreMode; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn device_code_sign_in_state(cancel: Arc) -> Arc> { + Arc::new(RwLock::new(SignInState::ChatGptDeviceCode( + ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel), + }, + ))) + } + + #[test] + fn device_code_attempt_matches_only_for_matching_cancel() { + let cancel = Arc::new(Notify::new()); + let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel.clone()), + }); + + assert_eq!(device_code_attempt_matches(&state, &cancel), true); + assert_eq!( + device_code_attempt_matches(&state, &Arc::new(Notify::new())), + false + ); + assert_eq!( + device_code_attempt_matches(&SignInState::PickMode, &cancel), + false + ); + } + + #[test] + fn begin_device_code_attempt_sets_state() { + let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode)); + let request_frame = FrameRequester::test_dummy(); + + let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); + let guard = sign_in_state.read().unwrap(); + + let state: &SignInState = &guard; + assert_eq!(device_code_attempt_matches(state, &cancel), true); + assert!(matches!( + state, + SignInState::ChatGptDeviceCode(state) if state.device_code.is_none() + )); + } + + #[test] + fn set_device_code_state_for_active_attempt_updates_only_when_active() { + let request_frame = FrameRequester::test_dummy(); + let cancel = Arc::new(Notify::new()); + let sign_in_state = device_code_sign_in_state(cancel.clone()); + + assert_eq!( + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ), + true + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::PickMode + )); + + let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); + assert_eq!( + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ), + false + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptDeviceCode(_) + )); + } + + #[test] + fn set_device_code_success_message_for_active_attempt_updates_only_when_active() { + let request_frame = FrameRequester::test_dummy(); + let cancel = Arc::new(Notify::new()); + let sign_in_state = device_code_sign_in_state(cancel.clone()); + let temp_dir = TempDir::new().unwrap(); + let auth_manager = AuthManager::shared( + temp_dir.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + ); + + assert_eq!( + set_device_code_success_message_for_active_attempt( + &sign_in_state, + &request_frame, + &auth_manager, + &cancel, + ), + true + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptSuccessMessage + )); + + let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); + assert_eq!( + set_device_code_success_message_for_active_attempt( + &sign_in_state, + &request_frame, + &auth_manager, + &cancel, + ), + false + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptDeviceCode(_) + )); + } +} diff --git a/codex-rs/tui2/src/onboarding/auth.rs b/codex-rs/tui2/src/onboarding/auth.rs index 9de8fd17093..640fca14dab 100644 --- a/codex-rs/tui2/src/onboarding/auth.rs +++ b/codex-rs/tui2/src/onboarding/auth.rs @@ -5,6 +5,8 @@ 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; use codex_login::run_login_server; @@ -40,13 +42,17 @@ use crate::shimmer::shimmer_spans; use crate::tui::FrameRequester; use std::path::PathBuf; use std::sync::Arc; +use tokio::sync::Notify; use super::onboarding_screen::StepState; +mod headless_chatgpt_login; + #[derive(Clone)] pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), + ChatGptDeviceCode(ContinueWithDeviceCodeState), ChatGptSuccessMessage, ChatGptSuccess, ApiKeyEntry(ApiKeyInputState), @@ -68,6 +74,12 @@ pub(crate) struct ContinueInBrowserState { shutdown_flag: Option, } +#[derive(Clone)] +pub(crate) struct ContinueWithDeviceCodeState { + device_code: Option, + cancel: Option>, +} + impl Drop for ContinueInBrowserState { fn drop(&mut self) { if let Some(handle) = &self.shutdown_flag { @@ -128,10 +140,22 @@ impl KeyboardHandler for AuthModeWidget { } KeyCode::Esc => { tracing::info!("Esc pressed"); - let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; - if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { - *self.sign_in_state.write().unwrap() = SignInState::PickMode; - self.request_frame.schedule_frame(); + let mut sign_in_state = self.sign_in_state.write().unwrap(); + match &*sign_in_state { + SignInState::ChatGptContinueInBrowser(_) => { + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.request_frame.schedule_frame(); + } + SignInState::ChatGptDeviceCode(state) => { + if let Some(cancel) = &state.cancel { + cancel.notify_one(); + } + *sign_in_state = SignInState::PickMode; + drop(sign_in_state); + self.request_frame.schedule_frame(); + } + _ => {} } } _ => {} @@ -216,10 +240,12 @@ impl AuthModeWidget { vec![line1, line2] }; - let chatgpt_description = if self.is_chatgpt_login_allowed() { - "Usage included with Plus, Pro, Business, Education, and Enterprise plans" - } else { + 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, @@ -277,7 +303,10 @@ impl AuthModeWidget { { lines.push(" If the link doesn't open automatically, open the following link to authenticate:".into()); lines.push("".into()); - lines.push(Line::from(state.auth_url.as_str().cyan().underlined())); + lines.push(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? Use ".into(), @@ -575,6 +604,12 @@ impl AuthModeWidget { self.forced_chatgpt_workspace_id.clone(), 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(); @@ -623,6 +658,7 @@ impl StepStateProvider for AuthModeWidget { SignInState::PickMode | SignInState::ApiKeyEntry(_) | SignInState::ChatGptContinueInBrowser(_) + | SignInState::ChatGptDeviceCode(_) | SignInState::ChatGptSuccessMessage => StepState::InProgress, SignInState::ChatGptSuccess | SignInState::ApiKeyConfigured => StepState::Complete, } @@ -639,6 +675,9 @@ impl WidgetRef for AuthModeWidget { 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); } diff --git a/codex-rs/tui2/src/onboarding/auth/headless_chatgpt_login.rs b/codex-rs/tui2/src/onboarding/auth/headless_chatgpt_login.rs new file mode 100644 index 00000000000..f4949fe13f2 --- /dev/null +++ b/codex-rs/tui2/src/onboarding/auth/headless_chatgpt_login.rs @@ -0,0 +1,377 @@ +use codex_core::AuthManager; +use codex_login::ServerOptions; +use codex_login::complete_device_code_login; +use codex_login::request_device_code; +use codex_login::run_login_server; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::prelude::Widget; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Wrap; +use std::sync::Arc; +use std::sync::RwLock; +use tokio::sync::Notify; + +use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; + +use super::AuthModeWidget; +use super::ContinueInBrowserState; +use super::ContinueWithDeviceCodeState; +use super::SignInState; + +pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, mut opts: ServerOptions) { + opts.open_browser = false; + let sign_in_state = widget.sign_in_state.clone(); + let request_frame = widget.request_frame.clone(); + let auth_manager = widget.auth_manager.clone(); + let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); + + tokio::spawn(async move { + let device_code = match request_device_code(&opts).await { + Ok(device_code) => device_code, + Err(err) => { + if err.kind() == std::io::ErrorKind::NotFound { + let should_fallback = { + let guard = sign_in_state.read().unwrap(); + device_code_attempt_matches(&guard, &cancel) + }; + + if !should_fallback { + return; + } + + match run_login_server(opts) { + Ok(child) => { + let auth_url = child.auth_url.clone(); + { + *sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url, + shutdown_flag: Some(child.cancel_handle()), + }); + } + request_frame.schedule_frame(); + let r = child.block_until_done().await; + match r { + Ok(()) => { + auth_manager.reload(); + *sign_in_state.write().unwrap() = + SignInState::ChatGptSuccessMessage; + request_frame.schedule_frame(); + } + _ => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + request_frame.schedule_frame(); + } + } + } + Err(_) => { + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ); + } + } + } else { + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ); + } + + return; + } + }; + + if !set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: Some(device_code.clone()), + cancel: Some(cancel.clone()), + }), + ) { + return; + } + + tokio::select! { + _ = cancel.notified() => {} + r = complete_device_code_login(opts, device_code) => { + match r { + Ok(()) => { + set_device_code_success_message_for_active_attempt( + &sign_in_state, + &request_frame, + &auth_manager, + &cancel, + ); + } + Err(_) => { + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ); + } + } + } + } + }); +} + +pub(super) fn render_device_code_login( + widget: &AuthModeWidget, + area: Rect, + buf: &mut Buffer, + state: &ContinueWithDeviceCodeState, +) { + let banner = if state.device_code.is_some() { + "Finish signing in via your browser" + } else { + "Preparing device code login" + }; + + let mut spans = vec![" ".into()]; + if widget.animations_enabled { + // Schedule a follow-up frame to keep the shimmer animation going. + widget + .request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); + spans.extend(shimmer_spans(banner)); + } else { + spans.push(banner.into()); + } + + let mut lines = vec![spans.into(), "".into()]; + + if let Some(device_code) = &state.device_code { + lines.push(" 1. Open this link in your browser and sign in".into()); + lines.push("".into()); + lines.push(Line::from(vec![ + " ".into(), + device_code.verification_url.as_str().cyan().underlined(), + ])); + lines.push("".into()); + lines.push( + " 2. Enter this one-time code after you are signed in (expires in 15 minutes)".into(), + ); + lines.push("".into()); + lines.push(Line::from(vec![ + " ".into(), + device_code.user_code.as_str().cyan().bold(), + ])); + lines.push("".into()); + lines.push( + " Device codes are a common phishing target. Never share this code." + .dim() + .into(), + ); + lines.push("".into()); + } else { + lines.push(" Requesting a one-time code...".dim().into()); + lines.push("".into()); + } + + lines.push(" Press Esc to cancel".dim().into()); + Paragraph::new(lines) + .wrap(Wrap { trim: false }) + .render(area, buf); +} + +fn device_code_attempt_matches(state: &SignInState, cancel: &Arc) -> bool { + matches!( + state, + SignInState::ChatGptDeviceCode(state) + if state + .cancel + .as_ref() + .is_some_and(|existing| Arc::ptr_eq(existing, cancel)) + ) +} + +fn begin_device_code_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, +) -> Arc { + let cancel = Arc::new(Notify::new()); + *sign_in_state.write().unwrap() = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel.clone()), + }); + request_frame.schedule_frame(); + cancel +} + +fn set_device_code_state_for_active_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, + cancel: &Arc, + next_state: SignInState, +) -> bool { + let mut guard = sign_in_state.write().unwrap(); + if !device_code_attempt_matches(&guard, cancel) { + return false; + } + + *guard = next_state; + drop(guard); + request_frame.schedule_frame(); + true +} + +fn set_device_code_success_message_for_active_attempt( + sign_in_state: &Arc>, + request_frame: &FrameRequester, + auth_manager: &AuthManager, + cancel: &Arc, +) -> bool { + let mut guard = sign_in_state.write().unwrap(); + if !device_code_attempt_matches(&guard, cancel) { + return false; + } + + auth_manager.reload(); + *guard = SignInState::ChatGptSuccessMessage; + drop(guard); + request_frame.schedule_frame(); + true +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_core::auth::AuthCredentialsStoreMode; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + fn device_code_sign_in_state(cancel: Arc) -> Arc> { + Arc::new(RwLock::new(SignInState::ChatGptDeviceCode( + ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel), + }, + ))) + } + + #[test] + fn device_code_attempt_matches_only_for_matching_cancel() { + let cancel = Arc::new(Notify::new()); + let state = SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState { + device_code: None, + cancel: Some(cancel.clone()), + }); + + assert_eq!(device_code_attempt_matches(&state, &cancel), true); + assert_eq!( + device_code_attempt_matches(&state, &Arc::new(Notify::new())), + false + ); + assert_eq!( + device_code_attempt_matches(&SignInState::PickMode, &cancel), + false + ); + } + + #[test] + fn begin_device_code_attempt_sets_state() { + let sign_in_state = Arc::new(RwLock::new(SignInState::PickMode)); + let request_frame = FrameRequester::test_dummy(); + + let cancel = begin_device_code_attempt(&sign_in_state, &request_frame); + let guard = sign_in_state.read().unwrap(); + + let state: &SignInState = &guard; + assert_eq!(device_code_attempt_matches(state, &cancel), true); + assert!(matches!( + state, + SignInState::ChatGptDeviceCode(state) if state.device_code.is_none() + )); + } + + #[test] + fn set_device_code_state_for_active_attempt_updates_only_when_active() { + let request_frame = FrameRequester::test_dummy(); + let cancel = Arc::new(Notify::new()); + let sign_in_state = device_code_sign_in_state(cancel.clone()); + + assert_eq!( + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ), + true + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::PickMode + )); + + let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); + assert_eq!( + set_device_code_state_for_active_attempt( + &sign_in_state, + &request_frame, + &cancel, + SignInState::PickMode, + ), + false + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptDeviceCode(_) + )); + } + + #[test] + fn set_device_code_success_message_for_active_attempt_updates_only_when_active() { + let request_frame = FrameRequester::test_dummy(); + let cancel = Arc::new(Notify::new()); + let sign_in_state = device_code_sign_in_state(cancel.clone()); + let temp_dir = TempDir::new().unwrap(); + let auth_manager = AuthManager::shared( + temp_dir.path().to_path_buf(), + false, + AuthCredentialsStoreMode::File, + ); + + assert_eq!( + set_device_code_success_message_for_active_attempt( + &sign_in_state, + &request_frame, + &auth_manager, + &cancel, + ), + true + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptSuccessMessage + )); + + let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new())); + assert_eq!( + set_device_code_success_message_for_active_attempt( + &sign_in_state, + &request_frame, + &auth_manager, + &cancel, + ), + false + ); + assert!(matches!( + &*sign_in_state.read().unwrap(), + SignInState::ChatGptDeviceCode(_) + )); + } +}